diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py
index 1486059..83bb496 100644
--- a/backend/app/routes/downloads.py
+++ b/backend/app/routes/downloads.py
@@ -279,6 +279,29 @@ def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db))
return quota.usage(db, user.id)
+# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
+_INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
+
+
+@router.get("/index")
+def my_download_index(
+ user: User = Depends(require_human), db: Session = Depends(get_db)
+) -> dict:
+ """source_ref -> best active status, for the feed's per-card "downloaded / queued" badge.
+ Small + cheap so the feed can poll it without loading the full job list."""
+ rows = db.execute(
+ select(DownloadJob.source_ref, DownloadJob.status).where(
+ DownloadJob.user_id == user.id,
+ DownloadJob.status.in_(("queued", "running", "paused", "done")),
+ )
+ ).all()
+ out: dict[str, str] = {}
+ for ref, status in rows:
+ if _INDEX_RANK.get(status, 0) > _INDEX_RANK.get(out.get(ref, ""), 0):
+ out[ref] = status
+ return out
+
+
@router.get("/shared")
def shared_with_me(
user: User = Depends(require_human), db: Session = Depends(get_db)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e05e41e..b33ee87 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -57,6 +57,8 @@ import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import Messages from "./components/Messages";
+import DownloadCenter from "./components/DownloadCenter";
+import { setNavigator } from "./lib/nav";
import ChatDock from "./components/ChatDock";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
@@ -279,6 +281,8 @@ export default function App() {
}
go();
}
+ // Expose the current setPage to decoupled callers (download toast "View", etc.).
+ useEffect(() => setNavigator(setPage));
function setSidebarLayout(next: SidebarLayout) {
setSidebarLayoutState(next);
@@ -728,6 +732,8 @@ export default function App() {
+ ) : page === "downloads" && !meQuery.data!.is_demo ? (
+
) : page === "settings" ? (
(["me"]);
+ const isDemo = !!me?.is_demo;
+
+ const indexQ = useQuery({
+ queryKey: ["download-index"],
+ queryFn: api.downloadIndex,
+ enabled: !isDemo,
+ staleTime: 10000,
+ });
+ const [open, setOpen] = useState(false);
+
+ if (isDemo) return null;
+
+ const status = indexQ.data?.[videoId];
+ const downloaded = status === "done";
+ const inQueue = status === "queued" || status === "running" || status === "paused";
+ const label = downloaded
+ ? t("downloads.button.downloaded")
+ : inQueue
+ ? t("downloads.button.queued")
+ : t("downloads.button.label");
+
+ return (
+ <>
+
+ {open && (
+ setOpen(false)} />
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx
new file mode 100644
index 0000000..c71c3e4
--- /dev/null
+++ b/frontend/src/components/DownloadCenter.tsx
@@ -0,0 +1,511 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Ban,
+ Download,
+ Pause,
+ Play,
+ Plus,
+ Settings2,
+ Share2,
+ Pencil,
+ Trash2,
+} from "lucide-react";
+import clsx from "clsx";
+import Tabs, { usePersistedTab } from "./Tabs";
+import Modal from "./Modal";
+import DownloadDialog from "./DownloadDialog";
+import ProfileEditor from "./ProfileEditor";
+import { useConfirm } from "./ConfirmProvider";
+import { useLiveQuery } from "../lib/useLiveQuery";
+import { api, type DownloadJob, type Me } from "../lib/api";
+import { notify } from "../lib/notifications";
+import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
+
+const inputCls =
+ "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
+const GiB = 1024 ** 3;
+
+const STATUS_CLS: Record = {
+ queued: "text-muted",
+ running: "text-accent",
+ paused: "text-amber-400",
+ done: "text-emerald-400",
+ error: "text-red-400",
+ canceled: "text-muted",
+};
+
+function StatusPill({ job }: { job: DownloadJob }) {
+ const { t } = useTranslation();
+ const key = job.expired ? "expired" : job.status;
+ return (
+
+ {t(`downloads.status.${key}`)}
+
+ );
+}
+
+function Thumb({ job }: { job: DownloadJob }) {
+ const url = job.asset?.thumbnail_url;
+ return (
+
+ {url ?

: null}
+
+ );
+}
+
+function jobTitle(job: DownloadJob): string {
+ return job.display_name || job.asset?.title || job.source_ref;
+}
+
+function IconBtn({
+ onClick,
+ title,
+ danger,
+ children,
+}: {
+ onClick: () => void;
+ title: string;
+ danger?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function DownloadRow({
+ job,
+ children,
+}: {
+ job: DownloadJob;
+ children?: React.ReactNode;
+}) {
+ const { t } = useTranslation();
+ const running = job.status === "running";
+ return (
+
+
+
+
{jobTitle(job)}
+
+ {job.asset?.uploader || job.source_ref}
+
+
+
+ {job.asset?.size_bytes ? · {formatBytes(job.asset.size_bytes)} : null}
+ {job.asset?.duration_s ? · {formatDuration(job.asset.duration_s)} : null}
+ {job.error ? · {job.error} : null}
+
+ {running && (
+
+
+
+ {job.progress}%
+ {job.speed_bps ? {formatSpeed(job.speed_bps)} : null}
+ {job.eta_s ? {formatEta(job.eta_s)} : null}
+
+
+ )}
+
+
{children}
+
+ );
+}
+
+// --- Rename + Share small modals ------------------------------------------------------------
+
+function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const [name, setName] = useState(jobTitle(job));
+ const save = useMutation({
+ mutationFn: () => api.renameDownload(job.id, name.trim()),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["downloads"] });
+ onClose();
+ },
+ });
+ return (
+
+
+ setName(e.target.value)} className={inputCls} autoFocus />
+ {t("downloads.rename.hint")}
+
+
+
+
+ );
+}
+
+function ShareModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
+ const { t } = useTranslation();
+ const [email, setEmail] = useState("");
+ const share = useMutation({
+ mutationFn: () => api.shareDownload(job.id, email.trim()),
+ onSuccess: (r) => {
+ notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
+ onClose();
+ },
+ });
+ return (
+
+
+ setEmail(e.target.value)}
+ placeholder={t("downloads.share.placeholder")}
+ className={inputCls}
+ autoFocus
+ />
+
+
+
+
+ );
+}
+
+// --- Usage bar ------------------------------------------------------------------------------
+
+function UsageBar() {
+ const { t } = useTranslation();
+ const usage = useQuery({ queryKey: ["download-usage"], queryFn: api.downloadUsage });
+ const u = usage.data;
+ if (!u) return null;
+ const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
+ return (
+
+
+ {t("downloads.usage.title")}
+
+ {formatBytes(u.footprint_bytes)}
+ {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
+ {u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
+
+
+ {!u.unlimited && (
+
+
90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
+
+ )}
+
+ );
+}
+
+// --- Admin system tab -----------------------------------------------------------------------
+
+function QuotaModal({ userId, email, onClose }: { userId: number; email: string; onClose: () => void }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const q = useQuery({ queryKey: ["dl-quota", userId], queryFn: () => api.adminGetDownloadQuota(userId) });
+ const [form, setForm] = useState<{ gb: number; jobs: number; conc: number; unlimited: boolean } | null>(null);
+ const cur = q.data;
+ const state = form ?? (cur ? { gb: Math.round((cur.max_bytes / GiB) * 10) / 10, jobs: cur.max_jobs, conc: cur.max_concurrent, unlimited: cur.unlimited } : null);
+ const done = () => {
+ qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
+ qc.invalidateQueries({ queryKey: ["admin-storage"] });
+ onClose();
+ };
+ const save = useMutation({
+ mutationFn: () =>
+ api.adminSetDownloadQuota(userId, {
+ max_bytes: Math.round((state!.gb) * GiB),
+ max_jobs: state!.jobs,
+ max_concurrent: state!.conc,
+ unlimited: state!.unlimited,
+ }),
+ onSuccess: done,
+ });
+ const reset = useMutation({ mutationFn: () => api.adminResetDownloadQuota(userId), onSuccess: done });
+ if (!state) return null;
+ const upd = (p: Partial
) => setForm({ ...state, ...p });
+ return (
+
+
+
+ );
+}
+
+function AdminSystem() {
+ const { t } = useTranslation();
+ const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
+ const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 });
+ const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
+ const s = storage.data;
+ return (
+
+ {s && (
+
+ {[
+ [t("downloads.admin.readyFiles"), String(s.ready_files)],
+ [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
+ [t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")],
+ ].map(([label, val]) => (
+
+ ))}
+
+ )}
+
+ {s && s.per_user.length > 0 && (
+
+
{t("downloads.admin.perUser")}
+
+ {s.per_user.map((u) => (
+
+ {u.email}
+ {formatBytes(u.footprint_bytes)}
+
+
+ ))}
+
+
+ )}
+
+
{t("downloads.tabs.system")}
+
+ {(jobs.data ?? []).map((j) => (
+
+ {jobTitle(j)}
+ {j.user_email}
+
+ {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
+
+ ))}
+ {jobs.data && jobs.data.length === 0 &&
{t("downloads.empty.admin")}
}
+
+
+ {quotaFor &&
setQuotaFor(null)} />}
+
+ );
+}
+
+// --- Main -----------------------------------------------------------------------------------
+
+export default function DownloadCenter({ me }: { me: Me }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const confirm = useConfirm();
+ const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue");
+ const [addUrl, setAddUrl] = useState("");
+ const [addSource, setAddSource] = useState(null);
+ const [manage, setManage] = useState(false);
+ const [renameJob, setRenameJob] = useState(null);
+ const [shareJob, setShareJob] = useState(null);
+
+ const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
+ const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
+ const jobs = jobsQ.data ?? [];
+ const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
+ const library = jobs.filter((j) => j.status === "done");
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ["downloads"] });
+ qc.invalidateQueries({ queryKey: ["download-index"] });
+ qc.invalidateQueries({ queryKey: ["download-usage"] });
+ };
+ const act = useMutation({
+ mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
+ op === "pause" ? api.pauseDownload(id)
+ : op === "resume" ? api.resumeDownload(id)
+ : op === "cancel" ? api.cancelDownload(id)
+ : api.deleteDownload(id),
+ onSuccess: invalidate,
+ });
+
+ const confirmThen = async (title: string, body: string, fn: () => void) => {
+ if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
+ };
+
+ const tabs = [
+ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
+ { id: "done", label: t("downloads.tabs.done"), badge: library.length },
+ { id: "shared", label: t("downloads.tabs.shared") },
+ ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
+ ];
+
+ const saveBtn = (job: DownloadJob) => (
+
+
+
+ );
+
+ return (
+
+
+
{t("downloads.page.title")}
+
+
+
{t("downloads.page.subtitle")}
+
+
+
+ {tab === "queue" && (
+ <>
+
+
setAddUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
+ placeholder={t("downloads.page.addUrl")}
+ className={inputCls}
+ />
+
+
+
+ {queue.map((job) => (
+
+ {(job.status === "queued" || job.status === "running") && (
+ act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}>
+
+
+ )}
+ {(job.status === "paused" || job.status === "error") && (
+ act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}>
+
+
+ )}
+ confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))}
+ title={t("downloads.actions.cancel")}
+ danger
+ >
+
+
+
+ ))}
+ {queue.length === 0 &&
{t("downloads.empty.queue")}
}
+
+ >
+ )}
+
+ {tab === "done" && (
+ <>
+
+
+ {library.map((job) => (
+
+ {saveBtn(job)}
+ setRenameJob(job)} title={t("downloads.actions.rename")}>
+
+
+ setShareJob(job)} title={t("downloads.actions.share")}>
+
+
+ confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))}
+ title={t("downloads.actions.delete")}
+ danger
+ >
+
+
+
+ ))}
+ {library.length === 0 &&
{t("downloads.empty.done")}
}
+
+ >
+ )}
+
+ {tab === "shared" && (
+
+ {(sharedQ.data ?? []).map((job) => (
+
+ {job.can_download && saveBtn(job)}
+
+ ))}
+ {sharedQ.data && sharedQ.data.length === 0 && (
+
{t("downloads.empty.shared")}
+ )}
+
+ )}
+
+ {tab === "system" && me.role === "admin" &&
}
+
+ {addSource && (
+
{
+ setAddSource(null);
+ setAddUrl("");
+ }}
+ />
+ )}
+ {manage && setManage(false)} />}
+ {renameJob && setRenameJob(null)} />}
+ {shareJob && setShareJob(null)} />}
+
+ );
+}
diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx
new file mode 100644
index 0000000..1f27474
--- /dev/null
+++ b/frontend/src/components/DownloadDialog.tsx
@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import Modal from "./Modal";
+import ProfileEditor from "./ProfileEditor";
+import { api } from "../lib/api";
+import { notify } from "../lib/notifications";
+import { navigateTo } from "../lib/nav";
+
+const inputCls =
+ "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
+
+// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
+// the Download Center. Enqueues via the API and points the user at the Download Center.
+export default function DownloadDialog({
+ source,
+ title,
+ onClose,
+}: {
+ source: string;
+ title?: string | null;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
+ const [profileId, setProfileId] = useState(null);
+ const [name, setName] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [manage, setManage] = useState(false);
+
+ const profiles = profilesQ.data ?? [];
+ const chosen = profileId ?? profiles[0]?.id ?? null;
+
+ const submit = async () => {
+ if (!chosen || busy) return;
+ setBusy(true);
+ try {
+ await api.enqueueDownload({
+ source,
+ profile_id: chosen,
+ display_name: name.trim() || undefined,
+ });
+ qc.invalidateQueries({ queryKey: ["downloads"] });
+ qc.invalidateQueries({ queryKey: ["download-index"] });
+ qc.invalidateQueries({ queryKey: ["download-usage"] });
+ notify({
+ level: "success",
+ message: t("downloads.dialog.added"),
+ action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
+ });
+ onClose();
+ } catch {
+ // Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {title && {title}
}
+
+
+
+
+
+
+ setName(e.target.value)}
+ placeholder={t("downloads.dialog.namePlaceholder")}
+ className={`${inputCls} mb-5`}
+ />
+
+
+
+
+
+
+ {manage && (
+ {
+ setManage(false);
+ profilesQ.refetch();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 2dc8024..69fa319 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -89,7 +89,9 @@ export default function Header({
? t("inbox.navLabel")
: page === "messages"
? t("messages.navLabel")
- : t("header.channelManager")}
+ : page === "downloads"
+ ? t("downloads.navLabel")
+ : t("header.channelManager")}
)}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index db5dde3..56a635b 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -8,6 +8,7 @@ import {
Bell,
ChevronLeft,
ChevronRight,
+ Download,
Home,
Info,
ListVideo,
@@ -147,6 +148,13 @@ export default function NavSidebar({
{ intervalMs: 30000, enabled: !me.is_demo }
);
const msgUnread = msgUnreadQuery.data?.count ?? 0;
+ // Download center badge = items still working (queued/running/paused). Reuses the same
+ // lightweight per-user index the feed cards poll, so no extra request shape.
+ const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, {
+ intervalMs: 30000,
+ enabled: !me.is_demo,
+ });
+ const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
@@ -159,6 +167,11 @@ export default function NavSidebar({
...(me.is_demo
? []
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
+ // Download center — YouTube → server → your device. Hidden for the shared demo account
+ // (spends server disk + needs a real identity).
+ ...(me.is_demo
+ ? []
+ : [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]),
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
];
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 973ada7..f8456d9 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
+import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
@@ -630,6 +631,13 @@ export default function PlayerModal({
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
+ {!navigated && (
+
+ )}
{!navigated && (
+
=> req("/api/me"),
accounts: (): Promise => req("/api/me/accounts"),
@@ -914,6 +1007,57 @@ export const api = {
updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
+
+ // --- download center ---
+ downloadProfiles: (): Promise => req("/api/downloads/profiles"),
+ createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise =>
+ req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
+ updateDownloadProfile: (
+ id: number,
+ patch: { name?: string; spec?: DownloadSpec }
+ ): Promise =>
+ req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
+ deleteDownloadProfile: (id: number) =>
+ req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
+
+ enqueueDownload: (body: {
+ source: string;
+ profile_id?: number;
+ spec?: DownloadSpec;
+ display_name?: string;
+ }): Promise =>
+ req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
+ downloads: (): Promise => req("/api/downloads"),
+ downloadsShared: (): Promise => req("/api/downloads/shared"),
+ downloadUsage: (): Promise => req("/api/downloads/usage"),
+ downloadIndex: (): Promise> => req("/api/downloads/index"),
+ pauseDownload: (id: number): Promise =>
+ req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
+ resumeDownload: (id: number): Promise =>
+ req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
+ cancelDownload: (id: number): Promise =>
+ req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
+ deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
+ renameDownload: (id: number, display_name: string): Promise =>
+ req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }),
+ shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
+ req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
+ unshareDownload: (id: number, email: string) =>
+ req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
+ downloadFileUrl: (id: number): string => `/api/downloads/${id}/file`,
+
+ // admin
+ adminDownloads: (): Promise => req("/api/admin/downloads"),
+ adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"),
+ adminGetDownloadQuota: (userId: number): Promise =>
+ req(`/api/admin/downloads/quota/${userId}`),
+ adminSetDownloadQuota: (
+ userId: number,
+ patch: Partial>
+ ): Promise =>
+ req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
+ adminResetDownloadQuota: (userId: number): Promise =>
+ req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
};
export { HttpError };
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index e0bb0d4..04b7700 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -5,6 +5,26 @@ export function relativeTime(iso: string | null): string {
return relativeFromMs(new Date(iso).getTime());
}
+/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
+export function formatBytes(bytes: number | null | undefined): string {
+ const n = Number(bytes);
+ if (!n || n < 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let i = 0;
+ let v = n;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
+}
+
+/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
+export function formatSpeed(bps: number | null | undefined): string {
+ if (!bps) return "";
+ return `${formatBytes(bps)}/s`;
+}
+
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
* client-side notifications whose timestamps are already numbers). One implementation, one
* set of `time.*` strings — shared instead of re-implemented per component. */
diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts
new file mode 100644
index 0000000..e0bb440
--- /dev/null
+++ b/frontend/src/lib/nav.ts
@@ -0,0 +1,14 @@
+import type { Page } from "./urlState";
+
+// Tiny decoupled navigator so deep components (a feed card's download toast, the Download
+// Center) can switch pages without threading setPage through the whole tree. App registers
+// its setPage on mount; navigateTo is a no-op until then.
+let _navigate: ((p: Page) => void) | null = null;
+
+export function setNavigator(fn: (p: Page) => void): void {
+ _navigate = fn;
+}
+
+export function navigateTo(p: Page): void {
+ _navigate?.(p);
+}
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts
index 36b0e11..9265993 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -100,6 +100,7 @@ export const PAGES = [
"users",
"notifications",
"messages",
+ "downloads",
] as const;
export type Page = (typeof PAGES)[number];