feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray
- 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.
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user