feat(design): the download, notification and Scheduler action lists (C1) — the rows' "…", the band's and the strip's "…" (E28 S3e3)
A download row keeps play (pause / resume / retry in the queue) and the save link; the editors, sharing, re-download, the copies and cancel / delete / remove moved into its "…" (useDownloadActions). A link inside the row keeps the browser's own right-click menu. A notification row keeps its link and its unread mark and shows its dismiss on hover; an activity notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a rule only between two non-empty groups. Find in feed also drops the tag, search and age filters that could hide the video it looks for.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Ban,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
PlayCircle,
|
||||
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;
|
||||
// the rest — the editors, sharing, 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(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(
|
||||
[...(a.play ? [play(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",
|
||||
label: t("downloads.actions.share"),
|
||||
icon: icon(Share2),
|
||||
disabled: sharingId !== null,
|
||||
onSelect: () => void quickShare(job),
|
||||
},
|
||||
{
|
||||
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 });
|
||||
|
||||
return { items, run };
|
||||
}
|
||||
Reference in New Issue
Block a user