From 687c0c106fbb9f099b41b17a77c9306584469edf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 06:40:26 +0100 Subject: [PATCH] feat(arr): delete affordance on subtitle chips Adds a two-click delete icon to each present-subtitle chip, naming the sidecar in its title/aria-label. Wires the existing DELETE /api/subtitles/{id} endpoint and reuses the panel's refresh() so chips and the manual panel repaint after a delete (#223). --- web/src/main.ts | 93 ++++++++++++++++++++++++++++++++++++++++++-- web/src/style.css | 47 ++++++++++++++++++++++ web/src/subtitles.ts | 21 ++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index ee1e434..8ae3995 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -117,6 +117,7 @@ import { import { armedDelete, settingsMain } from "./settings"; import "./style.css"; import { + deleteSubtitle, type EpisodeSubtitleStatus, formatCandidateFlags, formatDownloads, @@ -856,8 +857,8 @@ function countsChip( * case of "has a subtitle", not the clean one, and §15 wants it readable at * a glance rather than by hovering for the origin word. */ -function subtitleChip(subtitle: Subtitle): HTMLSpanElement { - return chip(subtitleChipLabel(subtitle), (span) => { +function subtitleChip(subtitle: Subtitle, onDeleted: () => void): HTMLSpanElement { + const chipEl = chip(subtitleChipLabel(subtitle), (span) => { if (!subtitle.forced) { span.dataset.verdict = "eligible"; } @@ -865,6 +866,77 @@ function subtitleChip(subtitle: Subtitle): HTMLSpanElement { span.dataset.mt = "true"; } }); + // An embedded track has no sidecar (§15) — nothing on disk to name or delete. + if (subtitle.path !== null) { + chipEl.append(subtitleDeleteButton(subtitle.id, subtitle.path, chipEl, onDeleted)); + } + return chipEl; +} + +/** + * The delete affordance nested in a present-subtitle chip (issue #223): first + * click arms it, second confirms — same idiom as `armedDelete`, scoped down + * to an icon so it fits inside a chip. The confirmation names the sidecar + * itself, in the title and `aria-label` both, since a chip is too narrow to + * spell out a filename in its own text (§9.6's one-row constraint). + */ +function subtitleDeleteButton( + subtitleId: number, + path: string, + chipEl: HTMLSpanElement, + onDeleted: () => void, +): HTMLButtonElement { + const name = fileName(path); + const button = document.createElement("button"); + button.type = "button"; + button.className = "chip-delete"; + button.append(closeIcon()); + + let armed = false; + let resetTimer: number | undefined; + const label = (confirming: boolean) => `${confirming ? "confirm " : ""}delete ${name}`; + const paint = (confirming: boolean) => { + button.title = label(confirming); + button.setAttribute("aria-label", label(confirming)); + }; + const disarm = () => { + armed = false; + window.clearTimeout(resetTimer); + delete button.dataset.armed; + paint(false); + }; + + paint(false); + button.addEventListener("click", (event) => { + // the chip itself carries no click behaviour today, but never assume that stays true + event.stopPropagation(); + if (!armed) { + armed = true; + button.dataset.armed = "true"; + paint(true); + resetTimer = window.setTimeout(disarm, 4000); + return; + } + disarm(); + button.disabled = true; + delete chipEl.dataset.tone; + chipEl.title = ""; + void deleteSubtitle(subtitleId).then((outcome) => { + if (outcome.kind === "error") { + button.disabled = false; + chipEl.dataset.tone = "fault"; + chipEl.title = `delete failed — ${outcome.detail}`; + return; + } + onDeleted(); + }); + }); + button.addEventListener("blur", () => { + if (armed) { + disarm(); + } + }); + return button; } /** One missing-language chip: the same amber "wanted, not yet on disk" ramp §4.2 already uses. */ @@ -1220,7 +1292,7 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS function paintChips() { line.replaceChildren(); for (const subtitle of subtitles) { - line.append(subtitleChip(subtitle)); + line.append(subtitleChip(subtitle, refresh)); } for (const gap of missing) { line.append(missingSubtitleChip(gap)); @@ -2028,6 +2100,21 @@ function starIcon(): SVGSVGElement { return svg; } +/** The drawn × on a delete affordance — no glyph standing in for an icon. */ +function closeIcon(): SVGSVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 12 12"); + svg.setAttribute("class", "close-icon"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "1.5"); + path.setAttribute("stroke-linecap", "round"); + svg.append(path); + return svg; +} + /** External links read as quiet controls; they leave the app entirely. */ function externalLink(label: string, href: string): HTMLAnchorElement { const link = document.createElement("a"); diff --git a/web/src/style.css b/web/src/style.css index c45f211..6c35ad6 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1492,6 +1492,53 @@ body { border-style: dashed; } +/* a delete that failed reads the same fault red the rest of the app uses, + right on the chip it happened to — no separate note element for it */ +.chip[data-tone="fault"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +/* the delete affordance nested in a present-subtitle chip (issue #223) */ +.chip-delete { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + margin-inline-start: var(--space-1); + padding: 0; + background: none; + border: 0; + border-radius: var(--radius); + color: var(--ink-faint); + cursor: pointer; + transition: + color 150ms var(--ease-out), + background 150ms var(--ease-out); +} + +.chip-delete:hover, +.chip-delete:focus-visible { + color: var(--signal-fault); + background: oklch(from var(--signal-fault) l c h / 15%); +} + +.chip-delete[data-armed="true"] { + color: var(--signal-fault); + background: oklch(from var(--signal-fault) l c h / 15%); +} + +.chip-delete:disabled { + cursor: default; + opacity: 0.5; +} + +.close-icon { + width: 0.625rem; + height: 0.625rem; +} + /* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */ /* the panel opens under the file's own row, sharing the release deck's diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts index 667ac40..3673fb6 100644 --- a/web/src/subtitles.ts +++ b/web/src/subtitles.ts @@ -258,6 +258,27 @@ export async function translateSubtitle( } } +/* ---- delete (§15, issue #223) ------------------------------------------ */ + +export type SubtitleDeleteOutcome = { kind: "done" } | { kind: "error"; detail: string }; + +/** + * Deletes the sidecar and its row (§15: replacement is delete-then-fetch). + * The daemon also clears the language's satisfaction, so the reconcile loop + * picks the gap back up on its next tick — nothing to do here for that part. + */ +export async function deleteSubtitle(subtitleId: number): Promise { + try { + const response = await fetch(`/api/subtitles/${subtitleId}`, { method: "DELETE" }); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "done" }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + /** * The slice of `/api/settings/subtitles` a manual action needs: which * languages the operator cares about, and which translation engines this