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:
@@ -1,22 +1,15 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Ban,
|
||||
ClipboardPaste,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Share2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./ui/Tabs";
|
||||
@@ -30,12 +23,9 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
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 { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
@@ -45,12 +35,16 @@ import { AdminSystem } from "./downloadAdmin";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Input } from "./ui/Input";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
|
||||
|
||||
const jobKey = (job: DownloadJob) => job.id;
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// Always land on the queue tab (not a persisted last-tab) — the queue is where new work and
|
||||
// any failures are, so it's the useful default every visit.
|
||||
const [tab, setTab] = useState("queue");
|
||||
@@ -80,56 +74,35 @@ export default function DownloadCenter() {
|
||||
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||
const library = jobs.filter((j) => j.status === "done");
|
||||
|
||||
const invalidate = () => invalidateDownloads(qc);
|
||||
const act = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
op,
|
||||
}: {
|
||||
id: number;
|
||||
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
|
||||
}) =>
|
||||
op === "pause"
|
||||
? api.pauseDownload(id)
|
||||
: op === "resume"
|
||||
? api.resumeDownload(id)
|
||||
: op === "cancel"
|
||||
? api.cancelDownload(id)
|
||||
: op === "redownload"
|
||||
? api.redownloadDownload(id)
|
||||
: api.deleteDownload(id),
|
||||
onSuccess: invalidate,
|
||||
// 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}`)),
|
||||
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
|
||||
// open menu, and its "…" must still close it. The tab is the one on screen.
|
||||
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
|
||||
const actions = useDownloadActions({
|
||||
onPlay: setPlayJob,
|
||||
onEdit: setEditJob,
|
||||
onEditDetails: setMetaJob,
|
||||
onManageShares: setShareJob,
|
||||
});
|
||||
|
||||
// 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-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||
// double-click 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, via the manage-share button.
|
||||
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 menuTarget = rowMenu.target;
|
||||
const listTab: DownloadTab | null =
|
||||
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
|
||||
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
|
||||
// The row's menu triggers and its "…".
|
||||
const rowMenuProps = (job: DownloadJob) => {
|
||||
const mine = rowMenu.open && rowMenu.target?.id === job.id;
|
||||
return {
|
||||
rowProps: rowMenu.triggerProps(job),
|
||||
more: (
|
||||
<MoreButton
|
||||
label={t("downloads.rowMenu")}
|
||||
size="md"
|
||||
expanded={mine}
|
||||
menuProps={rowMenu.buttonProps(job)}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||
@@ -226,18 +199,24 @@ export default function DownloadCenter() {
|
||||
</div>
|
||||
<NavGrid className="space-y-2">
|
||||
{queue.map((job, i) => (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
|
||||
{(job.status === "queued" || job.status === "running") && (
|
||||
<DownloadRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
navIndex={i}
|
||||
onShowError={setErrorJob}
|
||||
{...rowMenuProps(job)}
|
||||
>
|
||||
{/* The queue's primary action stays in sight; cancel moved into the "…". */}
|
||||
{job.status === "queued" || job.status === "running" ? (
|
||||
<IconBtn
|
||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
||||
onClick={() => actions.run(job, "pause")}
|
||||
title={t("downloads.actions.pause")}
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{(job.status === "paused" || job.status === "error") && (
|
||||
) : (
|
||||
<IconBtn
|
||||
onClick={() => act.mutate({ id: job.id, op: "resume" })}
|
||||
onClick={() => actions.run(job, "resume")}
|
||||
title={t(
|
||||
job.status === "error"
|
||||
? "downloads.actions.retry"
|
||||
@@ -247,20 +226,6 @@ export default function DownloadCenter() {
|
||||
<Play className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.cancelTitle"),
|
||||
t("downloads.confirm.cancelBody"),
|
||||
t("downloads.confirm.cancelAction"),
|
||||
() => act.mutate({ id: job.id, op: "cancel" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.cancel")}
|
||||
danger
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
{queue.length === 0 &&
|
||||
@@ -283,75 +248,20 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
<UsageBar />
|
||||
<NavGrid className="space-y-2">
|
||||
{library.map((job, i) => {
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||
{a.play && (
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.editDetails && (
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{library.map((job, i) => (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||
{libraryRowActions(job).play && (
|
||||
<IconBtn
|
||||
onClick={() => setShareJob(job)}
|
||||
title={t("downloads.actions.shareManage")}
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{a.redownload && (
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
t("downloads.confirm.redownloadAction"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
t("downloads.actions.delete"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
{saveBtn(job)}
|
||||
</DownloadRow>
|
||||
))}
|
||||
{library.length === 0 &&
|
||||
(jobsQ.isError && !jobsQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||
@@ -374,7 +284,7 @@ export default function DownloadCenter() {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||
{a.play && (
|
||||
<IconBtn
|
||||
open
|
||||
@@ -385,25 +295,6 @@ export default function DownloadCenter() {
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.save && saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
t("downloads.actions.removeShared"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
@@ -424,6 +315,8 @@ export default function DownloadCenter() {
|
||||
|
||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{addSource && (
|
||||
<DownloadDialog
|
||||
@@ -443,7 +336,7 @@ export default function DownloadCenter() {
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
job={errorJob}
|
||||
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
|
||||
onRetry={() => actions.run(errorJob, "resume")}
|
||||
onClose={() => setErrorJob(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user