v0.6.0: per-artifact actions menu (⋯ + right-click)

A ⋯ button on row hover and right-click on a feed row open the same floating
menu: Keep / Open / Copy link / Delete. Dismisses on click-away/Escape,
edge-flips to stay on-screen. Standalone one-click Delete icon stays; the
Trash view keeps its dedicated Undelete.

- Row markup: actions grouped in a .row-actions container (delete + ⋯ in the
  feed/keep views; undelete in trash). Reuses the existing keep endpoint.
- Menu only in the normal/keep views, not trash. Keep-toggle syncs the row's
  kept badge + the preview Keep button, and (in the Keep folder) drops the row
  when unkept.
- Frontend-only (no backend change); inline JS validated with node --check;
  152 tests pass; feed renders in all views.

Share + Add-label join the menu in later slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-07 13:12:27 +01:00
parent 57ad58ff2d
commit 548ba3a669
5 changed files with 166 additions and 18 deletions

View file

@ -147,6 +147,13 @@
</section>
</main>
<div class="row-menu" hidden data-row-menu>
<button type="button" data-menu-action="keep">Keep</button>
<button type="button" data-menu-action="open">Open</button>
<button type="button" data-menu-action="copy">Copy link</button>
<button type="button" data-menu-action="delete" class="danger">Delete</button>
</div>
<script>
(() => {
const feed = document.querySelector(".feed");
@ -528,6 +535,85 @@
});
}
// --- Per-row actions menu (⋯ trigger + right-click) ---
const rowMenu = document.querySelector("[data-row-menu]");
let menuRowId = null;
const currentMenuRow = () =>
menuRowId ? artifactList.querySelector(`.artifact-row[data-artifact-id="${menuRowId}"]`) : null;
const closeRowMenu = () => {
if (rowMenu) rowMenu.hidden = true;
menuRowId = null;
};
const openRowMenu = (row, x, y) => {
if (!rowMenu || !row) return;
menuRowId = row.dataset.artifactId;
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
rowMenu.hidden = false;
const rect = rowMenu.getBoundingClientRect();
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
rowMenu.style.left = `${left}px`;
rowMenu.style.top = `${top}px`;
};
const copyText = async (text) => {
try { await navigator.clipboard.writeText(text); } catch (e) { /* clipboard unavailable */ }
};
const keepFromMenu = async (row, id) => {
try {
const response = await fetch(`/a/${id}/keep`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
if (!response.ok) return;
const data = await response.json();
row.dataset.kept = data.is_kept ? "1" : "0";
row.classList.toggle("kept", data.is_kept);
const badge = row.querySelector("[data-kept-badge]");
if (badge) badge.hidden = !data.is_kept;
bumpFolderCount("keep", data.is_kept ? 1 : -1);
if (feed.dataset.folder === "keep" && !data.is_kept) removeRowFromList(row);
const keepButton = document.querySelector("[data-keep-button]");
if (keepButton && keepButton.dataset.artifactId === id) {
keepButton.classList.toggle("kept", data.is_kept);
keepButton.textContent = data.is_kept ? "✓ Kept" : "Keep";
}
} catch (e) { /* leave as-is */ }
};
if (rowMenu) {
rowMenu.addEventListener("click", (event) => {
const btn = event.target.closest("[data-menu-action]");
if (!btn) return;
const row = currentMenuRow();
const id = menuRowId;
closeRowMenu();
if (!row || !id) return;
const action = btn.dataset.menuAction;
if (action === "delete") softDeleteRow(id);
else if (action === "keep") keepFromMenu(row, id);
else if (action === "open") window.open(row.dataset.renderedUrl, "_blank", "noopener");
else if (action === "copy") copyText(window.location.origin + row.dataset.renderedUrl);
});
}
document.addEventListener("click", (event) => {
if (rowMenu && !rowMenu.hidden && !rowMenu.contains(event.target)) closeRowMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeRowMenu();
});
// Right-click a feed row opens the same menu at the cursor.
artifactList.addEventListener("contextmenu", (event) => {
const row = event.target.closest(".artifact-row");
if (!row || feed.dataset.folder) return; // normal feed only
event.preventDefault();
openRowMenu(row, event.clientX, event.clientY);
});
const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts";
markRead(row);
@ -563,13 +649,19 @@
// Delegated so rows appended by infinite scroll work without rebinding.
artifactList.addEventListener("click", (event) => {
// Row action icons (delete/undelete) intercept before selection.
const action = event.target.closest("[data-delete], [data-undelete]");
// Row action icons (delete/undelete/menu) intercept before selection.
const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
if (action) {
event.preventDefault();
event.stopPropagation();
if (action.hasAttribute("data-delete")) softDeleteRow(action.getAttribute("data-delete"));
else undeleteRow(action.getAttribute("data-undelete"));
if (action.hasAttribute("data-menu-trigger")) {
const rect = action.getBoundingClientRect();
openRowMenu(action.closest(".artifact-row"), rect.right, rect.bottom);
} else if (action.hasAttribute("data-delete")) {
softDeleteRow(action.getAttribute("data-delete"));
} else {
undeleteRow(action.getAttribute("data-undelete"));
}
return;
}
const row = event.target.closest(".artifact-row");