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).
This commit is contained in:
+90
-3
@@ -117,6 +117,7 @@ import {
|
|||||||
import { armedDelete, settingsMain } from "./settings";
|
import { armedDelete, settingsMain } from "./settings";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import {
|
import {
|
||||||
|
deleteSubtitle,
|
||||||
type EpisodeSubtitleStatus,
|
type EpisodeSubtitleStatus,
|
||||||
formatCandidateFlags,
|
formatCandidateFlags,
|
||||||
formatDownloads,
|
formatDownloads,
|
||||||
@@ -856,8 +857,8 @@ function countsChip(
|
|||||||
* case of "has a subtitle", not the clean one, and §15 wants it readable at
|
* 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.
|
* a glance rather than by hovering for the origin word.
|
||||||
*/
|
*/
|
||||||
function subtitleChip(subtitle: Subtitle): HTMLSpanElement {
|
function subtitleChip(subtitle: Subtitle, onDeleted: () => void): HTMLSpanElement {
|
||||||
return chip(subtitleChipLabel(subtitle), (span) => {
|
const chipEl = chip(subtitleChipLabel(subtitle), (span) => {
|
||||||
if (!subtitle.forced) {
|
if (!subtitle.forced) {
|
||||||
span.dataset.verdict = "eligible";
|
span.dataset.verdict = "eligible";
|
||||||
}
|
}
|
||||||
@@ -865,6 +866,77 @@ function subtitleChip(subtitle: Subtitle): HTMLSpanElement {
|
|||||||
span.dataset.mt = "true";
|
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. */
|
/** 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() {
|
function paintChips() {
|
||||||
line.replaceChildren();
|
line.replaceChildren();
|
||||||
for (const subtitle of subtitles) {
|
for (const subtitle of subtitles) {
|
||||||
line.append(subtitleChip(subtitle));
|
line.append(subtitleChip(subtitle, refresh));
|
||||||
}
|
}
|
||||||
for (const gap of missing) {
|
for (const gap of missing) {
|
||||||
line.append(missingSubtitleChip(gap));
|
line.append(missingSubtitleChip(gap));
|
||||||
@@ -2028,6 +2100,21 @@ function starIcon(): SVGSVGElement {
|
|||||||
return svg;
|
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. */
|
/** External links read as quiet controls; they leave the app entirely. */
|
||||||
function externalLink(label: string, href: string): HTMLAnchorElement {
|
function externalLink(label: string, href: string): HTMLAnchorElement {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -1492,6 +1492,53 @@ body {
|
|||||||
border-style: dashed;
|
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) ---------------------- */
|
/* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */
|
||||||
|
|
||||||
/* the panel opens under the file's own row, sharing the release deck's
|
/* the panel opens under the file's own row, sharing the release deck's
|
||||||
|
|||||||
@@ -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<SubtitleDeleteOutcome> {
|
||||||
|
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
|
* The slice of `/api/settings/subtitles` a manual action needs: which
|
||||||
* languages the operator cares about, and which translation engines this
|
* languages the operator cares about, and which translation engines this
|
||||||
|
|||||||
Reference in New Issue
Block a user