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.
276 lines
9.5 KiB
TypeScript
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 };
|
|
}
|