Files
siftlode/frontend/src/components/useDownloadActions.tsx
T
peter ae9c248987 fix(downloads): Retry draws the refresh glyph, not Play (E28 S4)
The first UI-review run (Downloads) found a failed row whose Retry wore the
Play glyph in the row and its menu, promising a video. DESIGN.md gains
'a glyph means one thing' and a log row.
2026-09-27 06:35:40 +02:00

276 lines
9.5 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Ban,
Download,
Link2,
Pause,
Pencil,
Play,
PlayCircle,
RefreshCw,
RotateCcw,
Scissors,
Share2,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A download's ONE action list (E28 C1): the row's "…", its right-click, long-press and Shift+F10,
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
// managing the shares, re-download, the copies and the destructive rows — lives here.
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
export function useDownloadActions({
onPlay,
onEdit,
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: JobOp }) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: () => invalidateDownloads(qc),
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a
// double pick from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing".
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const play = (job: DownloadJob): ActionMenuItem => ({
id: "play",
label: t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
});
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.downloadFileUrl(job.id);
a.click();
},
});
const edit = (job: DownloadJob): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(job),
});
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
const running = job.status === "queued" || job.status === "running";
return menuGroups(
[
running
? {
id: "pause",
label: t("downloads.actions.pause"),
icon: icon(Pause),
onSelect: () => act.mutate({ id: job.id, op: "pause" }),
}
: {
id: "resume",
label: t(
job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume",
),
icon: icon(job.status === "error" ? RefreshCw : Play),
onSelect: () => act.mutate({ id: job.id, op: "resume" }),
},
],
copies(job),
[
{
id: "cancel",
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
),
},
],
);
}
function libraryItems(job: DownloadJob): ActionMenuItem[] {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return menuGroups(
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? [play(job)] : []),
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
save(job),
],
[
...(a.edit ? [edit(job)] : []),
...(a.editDetails
? [
{
id: "edit-details",
label: t("downloads.actions.editDetails"),
icon: icon(Pencil),
onSelect: () => onEditDetails(job),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
),
},
]
: []),
],
[
{
id: "share-manage",
label: t("downloads.actions.shareManage"),
icon: icon(Link2),
onSelect: () => onManageShares(job),
},
...copies(job),
],
[
{
id: "delete",
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
t("downloads.actions.delete"),
() => act.mutate({ id: job.id, op: "delete" }),
),
},
],
);
}
function sharedItems(job: DownloadJob): ActionMenuItem[] {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
),
},
],
);
}
/** The job's menu rows on the tab it is listed on. */
const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] =>
tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
/** The library row's visible Share: the same one-click link copy as its menu row. */
const share = (job: DownloadJob) => void quickShare(job);
return { items, run, share };
}