- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key.
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslation } from "react-i18next";
|
|
import clsx from "clsx";
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
FolderOpen,
|
|
Link,
|
|
Loader2,
|
|
RotateCw,
|
|
X,
|
|
} from "lucide-react";
|
|
import type { QueueJob } from "../lib/api";
|
|
import { quickShareLink } from "../lib/downloads";
|
|
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
|
import {
|
|
IN_FLIGHT,
|
|
clearFinished,
|
|
fetchQueue,
|
|
takeUnannounced,
|
|
useFinished,
|
|
useUnannounced,
|
|
} from "../lib/downloadQueue";
|
|
import { navItemAttrs } from "../lib/listNav";
|
|
import { notify } from "../lib/notifications";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { LS } from "../lib/storage";
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { usePref } from "../lib/usePref";
|
|
import { ErrorModal } from "./downloadModals";
|
|
import { jobTitle } from "./downloadTitle";
|
|
import { sourceLabel } from "./fileFolders";
|
|
import { useFilesActions } from "./filesContext";
|
|
import { useDownloadActions } from "./useDownloadActions";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
import { setCornerLift } from "./ui/backToTopState";
|
|
import { Button } from "./ui/Button";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { menuGroups } from "./ui/menuGroups";
|
|
import { MoreButton } from "./ui/MoreButton";
|
|
import NavGrid from "./ui/NavGrid";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { CONTENT_ID } from "./ui/landmarks";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
|
|
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
|
|
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
|
|
// until it is closed. Folds to a pill (remembered per account). A row's right click and "…" are the
|
|
// queue's one menu; a finish is announced once with "Copy link" and "Open" (DESIGN.md "Download
|
|
// done"). It sits under every dialog and player (z-chrome), so it never covers a player's controls.
|
|
|
|
type Fold = "open" | "folded";
|
|
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
|
|
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
|
|
// Byte progress (a bar with a %) vs ffmpeg's post-steps (a pulse): the queue row's split.
|
|
const BYTE_PHASES = ["video", "audio", "editing", "converting"];
|
|
|
|
const keyOf = (job: QueueJob) => job.id;
|
|
|
|
function Progress({ job }: { job: QueueJob }) {
|
|
if (job.phase && !BYTE_PHASES.includes(job.phase))
|
|
return (
|
|
<span className="block h-1 overflow-hidden rounded-full bg-fg/10">
|
|
<span className="block h-full w-full animate-pulse bg-accent/60" />
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="flex items-center gap-2 text-xs text-muted">
|
|
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
|
|
<span
|
|
className="block h-full rounded-full bg-accent transition-all"
|
|
style={{ width: `${job.progress}%` }}
|
|
/>
|
|
</span>
|
|
<span className="w-8 text-right tabular-nums">{job.progress}%</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function DownloadTray() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const { openFile } = useFilesActions();
|
|
const actions = useDownloadActions();
|
|
|
|
// Every page polls it now, so the idle cadence is slow: a job started here refetches at once
|
|
// (invalidateDownloads), and one started on another device shows within a minute.
|
|
const queueQ = useLiveQuery(qk.downloads(), fetchQueue, {
|
|
intervalMs: (data) => (data?.some((j) => IN_FLIGHT.has(j.status)) ? 2000 : 60000),
|
|
});
|
|
const finished = useFinished();
|
|
const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error");
|
|
const activeIds = new Set(active.map((j) => j.id));
|
|
const rows = [...active, ...finished.filter((j) => !activeIds.has(j.id))];
|
|
|
|
// Closed with work still on it, it stays away until that work changes: a new job, or one failing.
|
|
// Remembered (per account) past a reload: an old failure the reader has seen must not reopen it
|
|
// on every page they load.
|
|
const signature = active.map((j) => `${j.id}${j.status === "error" ? "!" : ""}`).join(",");
|
|
const [closedAt, setClosedAt] = usePref<string | null>("downloadTrayClosed", null, {
|
|
localKey: LS.downloadTrayClosed,
|
|
parse: parseClosed,
|
|
});
|
|
// A finish since it was closed is news of its own, whatever else is still on it.
|
|
const shown = rows.length > 0 && (signature !== closedAt || finished.length > 0);
|
|
const [fold, setFold] = usePref<Fold>("downloadTray", "open", {
|
|
localKey: LS.downloadTray,
|
|
parse: parseFold,
|
|
});
|
|
|
|
const [errorJob, setErrorJob] = useState<QueueJob | null>(null);
|
|
const menu = useContextMenu<QueueJob>({ keyOf });
|
|
|
|
// Folding swaps the control under the keyboard for its counterpart: the focus follows it there
|
|
// (the pill, or the fold button), never left on <body> where the pressed one was.
|
|
const pill = useRef<HTMLButtonElement>(null);
|
|
const foldButton = useRef<HTMLButtonElement>(null);
|
|
const focusNext = useRef<Fold | null>(null);
|
|
const toggleFold = (next: Fold) => {
|
|
focusNext.current = next;
|
|
setFold(next);
|
|
};
|
|
useEffect(() => {
|
|
const target = focusNext.current;
|
|
focusNext.current = null;
|
|
if (target === "folded") pill.current?.focus();
|
|
else if (target === "open") foldButton.current?.focus();
|
|
}, [fold]);
|
|
|
|
// --- the done notice ------------------------------------------------------------------------
|
|
const announce = useEffectEvent((job: QueueJob) => {
|
|
const title = jobTitle(job);
|
|
notify({
|
|
level: "success",
|
|
title: t("tray.done.title", { title }),
|
|
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
|
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
|
|
secondaryAction: {
|
|
label: t("tray.done.open"),
|
|
onClick: () => openFile(job.id, job.folder_id),
|
|
},
|
|
});
|
|
});
|
|
const fresh = useUnannounced();
|
|
useEffect(() => {
|
|
// The take clears them, so a second pass (StrictMode, a re-render) announces nothing again.
|
|
if (fresh.length) takeUnannounced().forEach(announce);
|
|
}, [fresh]);
|
|
|
|
// --- Back to top steps up over the tray while it shows -----------------------------------------
|
|
const box = useRef<HTMLDivElement>(null);
|
|
useLayoutEffect(() => {
|
|
const el = box.current;
|
|
if (!el) {
|
|
setCornerLift(0);
|
|
return;
|
|
}
|
|
const measure = () => setCornerLift(el.getBoundingClientRect().height);
|
|
measure();
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(el);
|
|
return () => {
|
|
ro.disconnect();
|
|
setCornerLift(0);
|
|
};
|
|
}, [shown, fold]);
|
|
|
|
if (!shown)
|
|
return errorJob ? (
|
|
<ErrorModal
|
|
job={errorJob}
|
|
onRetry={() => actions.run(errorJob, "resume")}
|
|
onClose={() => setErrorJob(null)}
|
|
/>
|
|
) : null;
|
|
|
|
const counts = {
|
|
running: rows.filter((j) => j.status === "queued" || j.status === "running").length,
|
|
paused: rows.filter((j) => j.status === "paused").length,
|
|
error: rows.filter((j) => j.status === "error").length,
|
|
done: rows.filter((j) => j.status === "done").length,
|
|
};
|
|
const summary = (["running", "paused", "error", "done"] as const)
|
|
.filter((k) => counts[k] > 0)
|
|
.map((k) => t(`tray.count.${k}`, { count: counts[k] }))
|
|
.join(", ");
|
|
const running = rows.filter((j) => j.status === "running");
|
|
const meanProgress = running.length
|
|
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
|
|
: null;
|
|
const StateIcon = counts.running ? Loader2 : counts.error ? AlertTriangle : CheckCircle2;
|
|
const stateIcon = (
|
|
<StateIcon
|
|
aria-hidden
|
|
className={clsx(
|
|
"size-4 shrink-0",
|
|
counts.running ? "animate-spin text-accent" : counts.error ? "text-danger" : "text-success",
|
|
)}
|
|
/>
|
|
);
|
|
|
|
const close = () => {
|
|
clearFinished();
|
|
setClosedAt(signature);
|
|
// The tray goes with the button pressed: the keyboard rests on the page (its <main>).
|
|
document.getElementById(CONTENT_ID)?.focus({ preventScroll: true });
|
|
};
|
|
|
|
const doneItems = (job: QueueJob): ActionMenuItem[] =>
|
|
menuGroups([
|
|
{
|
|
id: "open",
|
|
label: t("tray.done.open"),
|
|
icon: <FolderOpen className="w-4 h-4" />,
|
|
onSelect: () => openFile(job.id, job.folder_id),
|
|
},
|
|
{
|
|
id: "copyLink",
|
|
label: t("tray.done.copyLink"),
|
|
icon: <Link className="w-4 h-4" />,
|
|
onSelect: () => void quickShareLink(qc, job, t),
|
|
},
|
|
]);
|
|
const menuItems = menu.target
|
|
? menu.target.status === "done"
|
|
? doneItems(menu.target)
|
|
: actions.items(menu.target)
|
|
: [];
|
|
|
|
return (
|
|
<div
|
|
ref={box}
|
|
role="region"
|
|
aria-label={t("tray.label")}
|
|
className="absolute right-4 bottom-4 z-chrome flex max-w-[calc(100%-2rem)] flex-col items-end"
|
|
>
|
|
{fold === "folded" ? (
|
|
<Pressable
|
|
ref={pill}
|
|
onClick={() => toggleFold("open")}
|
|
aria-expanded={false}
|
|
title={t("tray.unfold")}
|
|
className={clsx(
|
|
SURFACE.floating,
|
|
"inline-flex items-center gap-2 px-3 py-1.5 text-sm glass-hover",
|
|
)}
|
|
>
|
|
{stateIcon}
|
|
<span className="truncate">{summary}</span>
|
|
{meanProgress !== null && (
|
|
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-fg/10">
|
|
<span
|
|
className="block h-full rounded-full bg-accent"
|
|
style={{ width: `${meanProgress}%` }}
|
|
/>
|
|
</span>
|
|
)}
|
|
</Pressable>
|
|
) : (
|
|
<div
|
|
className={clsx(
|
|
SURFACE.popover,
|
|
"@container/tray w-80 max-w-full overflow-hidden shadow-float",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border/60 py-1 pr-1 pl-3">
|
|
{stateIcon}
|
|
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
|
|
{/* A phone's narrow tray keeps the news and drops the name (the region names it). */}
|
|
<span className="@max-[18rem]/tray:hidden">{t("tray.title")} · </span>
|
|
{summary}
|
|
</span>
|
|
<IconButton
|
|
ref={foldButton}
|
|
label={t("tray.fold")}
|
|
size="sm"
|
|
variant="ghost"
|
|
aria-expanded
|
|
onClick={() => toggleFold("folded")}
|
|
>
|
|
<ChevronDown />
|
|
</IconButton>
|
|
<IconButton label={t("tray.close")} size="sm" variant="ghost" onClick={close}>
|
|
<X />
|
|
</IconButton>
|
|
</div>
|
|
<NavGrid className="max-h-[50dvh] overflow-y-auto py-1">
|
|
{rows.map((job, i) => {
|
|
const title = jobTitle(job);
|
|
const mine = menu.open && menu.target?.id === job.id;
|
|
return (
|
|
<div
|
|
key={job.id}
|
|
{...navItemAttrs(i, title)}
|
|
{...menu.triggerProps(job)}
|
|
data-job-id={job.id}
|
|
className="group flex items-start gap-1 px-3 py-2 focus-visible:bg-fg/5"
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
|
|
{title}
|
|
</span>
|
|
<span className="shrink-0 text-xs text-muted">
|
|
{sourceLabel(job.source_key, t)}
|
|
</span>
|
|
</span>
|
|
<RowState
|
|
job={job}
|
|
onShowError={() => setErrorJob(job)}
|
|
onRetry={() => actions.run(job, "resume")}
|
|
onOpen={() => openFile(job.id, job.folder_id)}
|
|
/>
|
|
</div>
|
|
<MoreButton
|
|
label={t("tray.rowMenu")}
|
|
size="sm"
|
|
expanded={mine}
|
|
menuProps={menu.buttonProps(job)}
|
|
className={mine ? undefined : "reveal"}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</NavGrid>
|
|
</div>
|
|
)}
|
|
<ContextMenu menu={menu} ariaLabel={t("tray.rowMenu")} items={menuItems} />
|
|
{errorJob && (
|
|
<ErrorModal
|
|
job={errorJob}
|
|
onRetry={() => actions.run(errorJob, "resume")}
|
|
onClose={() => setErrorJob(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** What a tray row says under its title: how far it is, that it waits, why it failed, or that it is
|
|
* done — with the one thing to do about it in reach. */
|
|
function RowState({
|
|
job,
|
|
onShowError,
|
|
onRetry,
|
|
onOpen,
|
|
}: {
|
|
job: QueueJob;
|
|
onShowError: () => void;
|
|
onRetry: () => void;
|
|
onOpen: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (job.status === "running") return <Progress job={job} />;
|
|
if (job.status === "error")
|
|
return (
|
|
<span className="flex items-center gap-2 text-xs">
|
|
<AlertTriangle aria-hidden className="size-3.5 shrink-0 text-danger" />
|
|
<Pressable
|
|
onClick={onShowError}
|
|
title={t(failTitleKey(job))}
|
|
className="min-w-0 flex-1 truncate text-left text-danger hover:underline"
|
|
>
|
|
{t(failCopyKeys(job, "short"))}
|
|
</Pressable>
|
|
<Button
|
|
variant="link"
|
|
size="none"
|
|
icon={<RotateCw className="size-3" />}
|
|
onClick={onRetry}
|
|
className="text-xs"
|
|
>
|
|
{t("downloads.actions.retry")}
|
|
</Button>
|
|
</span>
|
|
);
|
|
if (job.status === "done")
|
|
return (
|
|
<span className="flex items-center gap-2 text-xs">
|
|
<CheckCircle2 aria-hidden className="size-3.5 shrink-0 text-success" />
|
|
<span className="flex-1 text-success">{t("tray.done.label")}</span>
|
|
<Button variant="link" size="none" onClick={onOpen} data-nav-open="" className="text-xs">
|
|
{t("tray.done.open")}
|
|
</Button>
|
|
</span>
|
|
);
|
|
return <span className="text-xs text-muted">{t(`downloads.status.${job.status}`)}</span>;
|
|
}
|