From 955a0f309cbecf82f9c9cbcdff343d3ef8b5d6b8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:19:05 +0100 Subject: [PATCH] feat(web): settings rows on one line with icon actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each root and policy row is one line: identity left, chips at the right edge, then pencil/trash icon controls — the episode-row idiom instead of a two-line group. Every icon action's aria-label names the action and the row (edit root /mnt/media/tv/main). Delete keeps the arm-then-confirm behaviour via the shared armedDeleteIcon; the local text-button armedDelete is gone with it. Under 46rem the row wraps like an episode row: identity, chips full-width, actions keeping the right edge. Closes #231 Co-Authored-By: Claude Fable 5 --- web/src/settings.ts | 76 +++++++++++++-------------------------------- web/src/style.css | 25 +++++++++++++-- 2 files changed, 44 insertions(+), 57 deletions(-) diff --git a/web/src/settings.ts b/web/src/settings.ts index f0736d4..1e5bdbf 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -2,6 +2,7 @@ // plus the settings view over them — same reasoning as search.ts: the // generated client (src/api/) is uncommitted, so CI's tsc cannot see it. +import { armedDeleteIcon, icon } from "./icons"; import { navigate } from "./router"; import type { Root } from "./search"; @@ -185,40 +186,14 @@ function listValue(input: HTMLInputElement): string[] { .filter((part) => part !== ""); } -/** - * First click arms the destructive action, second confirms. The armed state - * clears on blur or after a few seconds, so an accidental double click - * never deletes. Shared with the series detail rows — one confirmation - * idiom for row-level destruction, not one per page. - */ -export function armedDelete(label: string, execute: () => void): HTMLButtonElement { - const button = el("button", "control control-quiet readout", label); +/** Icon-only edit toggle for one row; the label names the row it opens. */ +function editIcon(label: string): HTMLButtonElement { + const button = el("button", "control control-quiet control-icon"); button.type = "button"; - let armed = false; - let resetTimer: number | undefined; - const disarm = () => { - armed = false; - window.clearTimeout(resetTimer); - delete button.dataset.armed; - button.textContent = label; - }; - button.addEventListener("click", () => { - if (armed) { - disarm(); - button.disabled = true; - execute(); - return; - } - armed = true; - button.dataset.armed = "true"; - button.textContent = `confirm ${label}`; - resetTimer = window.setTimeout(disarm, 4000); - }); - button.addEventListener("blur", () => { - if (armed) { - disarm(); - } - }); + button.setAttribute("aria-label", label); + button.title = label; + button.setAttribute("aria-expanded", "false"); + button.append(icon("pencil")); return button; } @@ -300,31 +275,29 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): /* -- roots -------------------------------------------------------------- */ - /** A delete/edit action pair sharing the row's feedback line. */ - function rowActions( + /** The row's line: identity, chips right, the icon action pair, feedback. */ + function rowLine( item: HTMLLIElement, + title: string, + chips: HTMLSpanElement, del: HTMLButtonElement, edit: HTMLButtonElement, ): HTMLParagraphElement { const actions = el("div", "settings-row-actions"); actions.append(edit, del); - item.append(actions); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; - item.append(note); + item.append(rowTitle(title), chips, actions, note); return note; } function rootRow(root: Root): HTMLLIElement { - const item = el("li", "queue-item"); - const row = el("div", "row"); + const item = el("li", "queue-item settings-row"); const chips = el("span", "row-chips"); chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name)); - row.append(rowTitle(root.path), chips); - item.append(row); - const del = armedDelete("delete", () => { + const del = armedDeleteIcon(`delete root ${root.path}`, () => { void deleteRow("roots", root.id).then((detail) => { if (detail !== null) { note.hidden = false; @@ -336,9 +309,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): }); }); - const edit = el("button", "control control-quiet readout", "edit"); - edit.type = "button"; - edit.setAttribute("aria-expanded", "false"); + const edit = editIcon(`edit root ${root.path}`); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { @@ -350,7 +321,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): edit.setAttribute("aria-expanded", "true"); }); - const note = rowActions(item, del, edit); + const note = rowLine(item, root.path, chips, del, edit); return item; } @@ -432,17 +403,14 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): } function policyRow(policy: PolicyDoc): HTMLLIElement { - const item = el("li", "queue-item"); - const row = el("div", "row"); + const item = el("li", "queue-item settings-row"); const chips = el("span", "row-chips"); chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions")); chips.append(chip(requiredAudioLabel(policy))); const bandCount = Object.keys(policy.size_bands).length; chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`)); - row.append(rowTitle(policy.name), chips); - item.append(row); - const del = armedDelete("delete", () => { + const del = armedDeleteIcon(`delete policy ${policy.name}`, () => { void deleteRow("policies", policy.id).then((detail) => { if (detail !== null) { note.hidden = false; @@ -454,9 +422,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): }); }); - const edit = el("button", "control control-quiet readout", "edit"); - edit.type = "button"; - edit.setAttribute("aria-expanded", "false"); + const edit = editIcon(`edit policy ${policy.name}`); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { @@ -468,7 +434,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): edit.setAttribute("aria-expanded", "true"); }); - const note = rowActions(item, del, edit); + const note = rowLine(item, policy.name, chips, del, edit); return item; } diff --git a/web/src/style.css b/web/src/style.css index 41bdf3e..ab58117 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1957,11 +1957,32 @@ body { /* ---- settings (issue #116) -------------------------------------------- */ -/* rows follow the queues pattern: line, then an action pair */ +/* one machined line per row (#231): identity, chips at the right edge, + then the icon action pair — the episode-row idiom, not a two-line group */ .settings-row-actions { display: flex; + align-items: center; gap: var(--space-2); - margin: var(--space-1) 0 var(--space-2); + margin-left: var(--space-3); +} + +.settings-row .row-title { + overflow-wrap: anywhere; +} + +/* feedback and the edit panel drop below the line, full width */ +.settings-row .add-note, +.settings-row .edit-panel { + flex-basis: 100%; +} + +/* narrow, the row wraps like an episode row: identity first, chips + full-width (the shared .row-chips rule), the icon pair keeps the right + edge. Lives here, after the base rule, so the margin actually flips. */ +@media (max-width: 46rem) { + .settings-row-actions { + margin-left: auto; + } } .form-grid {