feat(downloads): M5 — Download Center frontend

- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
  usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
  Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
  per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
  useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
  admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page

tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
This commit is contained in:
npeter83 2026-07-03 01:52:58 +02:00
parent 51158ad9cf
commit 7d1ed24fea
17 changed files with 1551 additions and 1 deletions

View file

@ -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)

View file

@ -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() {
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
</div>
) : page === "downloads" && !meQuery.data!.is_demo ? (
<DownloadCenter me={meQuery.data!} />
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}

View file

@ -0,0 +1,66 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react";
import clsx from "clsx";
import DownloadDialog from "./DownloadDialog";
import { api, type Me } from "../lib/api";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
// (downloads spend server disk + need a real identity). Reflects the video's current state from
// the shared per-user download index (one polled query shared across every card).
export default function DownloadButton({
videoId,
title,
className,
}: {
videoId: string;
title?: string | null;
className?: string;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const me = qc.getQueryData<Me>(["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 (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title={label}
className={clsx(
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
(downloaded || inQueue) && "text-accent"
)}
>
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
</button>
{open && (
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
)}
</>
);
}

View file

@ -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<string, string> = {
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 (
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${key}`)}
</span>
);
}
function Thumb({ job }: { job: DownloadJob }) {
const url = job.asset?.thumbnail_url;
return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null}
</div>
);
}
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 (
<button
onClick={onClick}
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
danger ? "hover:text-red-400" : "hover:text-fg"
)}
>
{children}
</button>
);
}
function DownloadRow({
job,
children,
}: {
job: DownloadJob;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const running = job.status === "running";
return (
<div className="flex gap-3 p-2.5 rounded-xl bg-card/40 hover:bg-card transition">
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1">{jobTitle(job)}</div>
<div className="text-xs text-muted truncate mt-0.5">
{job.asset?.uploader || job.source_ref}
</div>
<div className="flex items-center gap-2 mt-1 text-xs">
<StatusPill job={job} />
{job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null}
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
</div>
{running && (
<div className="mt-1.5">
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div className="h-full bg-accent transition-all" style={{ width: `${job.progress}%` }} />
</div>
<div className="text-[11px] text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
</div>
</div>
)}
</div>
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
</div>
);
}
// --- 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 (
<Modal title={t("downloads.rename.title")} onClose={onClose}>
<label className="block text-sm font-medium mb-1">{t("downloads.rename.label")}</label>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
<p className="text-xs text-muted mt-2 mb-4">{t("downloads.rename.hint")}</p>
<div className="flex justify-end">
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.rename.save")}
</button>
</div>
</Modal>
);
}
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 (
<Modal title={t("downloads.share.title")} onClose={onClose}>
<label className="block text-sm font-medium mb-1">{t("downloads.share.label")}</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("downloads.share.placeholder")}
className={inputCls}
autoFocus
/>
<div className="flex justify-end mt-4">
<button
onClick={() => share.mutate()}
disabled={!email.trim() || share.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.share.submit")}
</button>
</div>
</Modal>
);
}
// --- 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 (
<div className="rounded-xl bg-card/40 p-3 mb-4">
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{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 })}
</span>
</div>
{!u.unlimited && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div className={clsx("h-full transition-all", pct > 90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
}
// --- 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<typeof state>) => setForm({ ...state, ...p });
return (
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
{t("downloads.admin.maxBytes")}
<input type="number" value={state.gb} onChange={(e) => upd({ gb: Number(e.target.value) })} className={inputCls} disabled={state.unlimited} />
</label>
<label className="text-sm">
{t("downloads.admin.maxJobs")}
<input type="number" value={state.jobs} onChange={(e) => upd({ jobs: Number(e.target.value) })} className={inputCls} />
</label>
<label className="text-sm">
{t("downloads.admin.maxConcurrent")}
<input type="number" value={state.conc} onChange={(e) => upd({ conc: Number(e.target.value) })} className={inputCls} />
</label>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={state.unlimited} onChange={(e) => upd({ unlimited: e.target.checked })} />
{t("downloads.admin.unlimited")}
</label>
<div className="flex justify-between pt-1">
<button onClick={() => reset.mutate()} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
{t("downloads.admin.reset")}
</button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition">
{t("downloads.admin.save")}
</button>
</div>
</div>
</Modal>
);
}
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 (
<div>
{s && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
{[
[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]) => (
<div key={label} className="rounded-xl bg-card/40 p-3">
<div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div>
</div>
))}
</div>
)}
{s && s.per_user.length > 0 && (
<div className="mb-6">
<div className="text-sm font-medium mb-2">{t("downloads.admin.perUser")}</div>
<div className="space-y-1">
{s.per_user.map((u) => (
<div key={u.user_id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40">
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
title={t("downloads.admin.editQuota")}
>
<Settings2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
<div className="space-y-1.5">
{(jobs.data ?? []).map((j) => (
<div key={j.id} className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40">
<span className="flex-1 truncate">{jobTitle(j)}</span>
<span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} />
<span className="text-muted w-16 text-right">{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}</span>
</div>
))}
{jobs.data && jobs.data.length === 0 && <div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>}
</div>
{quotaFor && <QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />}
</div>
);
}
// --- 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<string | null>(null);
const [manage, setManage] = useState(false);
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(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) => (
<a
href={api.downloadFileUrl(job.id)}
title={t("downloads.actions.download")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
<Download className="w-4 h-4" />
</a>
);
return (
<div className="p-4 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
onClick={() => setManage(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
<input
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
>
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
</button>
</div>
<div className="space-y-2">
{queue.map((job) => (
<DownloadRow key={job.id} job={job}>
{(job.status === "queued" || job.status === "running") && (
<IconBtn onClick={() => act.mutate({ id: job.id, op: "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" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}>
<Play className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() => confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))}
title={t("downloads.actions.cancel")}
danger
>
<Ban className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.queue")}</div>}
</div>
</>
)}
{tab === "done" && (
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => (
<DownloadRow key={job.id} job={job}>
{saveBtn(job)}
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() => confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{library.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.done")}</div>}
</div>
</>
)}
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => (
<DownloadRow key={job.id} job={job}>
{job.can_download && saveBtn(job)}
</DownloadRow>
))}
{sharedQ.data && sharedQ.data.length === 0 && (
<div className="text-sm text-muted py-10 text-center">{t("downloads.empty.shared")}</div>
)}
</div>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
{addSource && (
<DownloadDialog
source={addSource}
onClose={() => {
setAddSource(null);
setAddUrl("");
}}
/>
)}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
</div>
);
}

View file

@ -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<number | null>(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 (
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.profile")}</label>
<select
value={chosen ?? ""}
onChange={(e) => setProfileId(Number(e.target.value))}
className={inputCls}
>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setManage(true)}
className="text-xs text-accent hover:underline mt-1 mb-4"
>
{t("downloads.dialog.manage")}
</button>
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.dialog.namePlaceholder")}
className={`${inputCls} mb-5`}
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.actions.cancel")}
</button>
<button
onClick={submit}
disabled={busy || !chosen}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.dialog.submit")}
</button>
</div>
{manage && (
<ProfileEditor
onClose={() => {
setManage(false);
profilesQ.refetch();
}}
/>
)}
</Modal>
);
}

View file

@ -89,6 +89,8 @@ export default function Header({
? t("inbox.navLabel")
: page === "messages"
? t("messages.navLabel")
: page === "downloads"
? t("downloads.navLabel")
: t("header.channelManager")}
</div>
)}

View file

@ -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") },
];

View file

@ -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 && (
<DownloadButton
videoId={active.id}
title={active.title}
className="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 && (
<button
onClick={() => setWatched(!watched)}

View file

@ -0,0 +1,215 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
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 HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
const BLANK: DownloadSpec = {
mode: "av",
max_height: 1080,
container: "mp4",
audio_format: "m4a",
vcodec: null,
embed_subs: false,
embed_chapters: true,
embed_thumbnail: true,
sponsorblock: false,
};
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
);
}
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
const profiles = profilesQ.data ?? [];
const builtins = profiles.filter((p) => p.is_builtin);
const mine = profiles.filter((p) => !p.is_builtin);
const [editing, setEditing] = useState<DownloadProfile | null>(null);
const [name, setName] = useState("");
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] });
const startNew = () => {
setEditing(null);
setName("");
setSpec(BLANK);
setFormOpen(true);
};
const startEdit = (p: DownloadProfile) => {
setEditing(p);
setName(p.name);
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
};
const save = useMutation({
mutationFn: () =>
editing
? api.updateDownloadProfile(editing.id, { name, spec })
: api.createDownloadProfile({ name, spec }),
onSuccess: () => {
invalidate();
setFormOpen(false);
},
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
});
const onDelete = async (p: DownloadProfile) => {
if (await confirm({ title: t("downloads.profiles.delete"), message: t("downloads.profiles.deleteConfirm", { name: p.name }), confirmLabel: t("downloads.profiles.delete"), danger: true })) {
del.mutate(p.id);
}
};
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
const set = (k: keyof DownloadSpec) => (e: React.ChangeEvent<HTMLSelectElement>) =>
patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial<DownloadSpec>);
return (
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
{/* Existing formats */}
<div className="space-y-1.5 mb-4">
<div className="text-xs uppercase tracking-wide text-muted">{t("downloads.profiles.builtin")}</div>
{builtins.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60">
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{p.name}</span>
</div>
))}
{mine.length > 0 && (
<div className="text-xs uppercase tracking-wide text-muted pt-2">{t("downloads.profiles.yours")}</div>
)}
{mine.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60">
<span className="flex-1 truncate">{p.name}</span>
<button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => onDelete(p)} title={t("downloads.profiles.delete")} className="p-1 rounded hover:bg-surface text-muted hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
{!formOpen ? (
<button
onClick={startNew}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
>
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
</button>
) : (
<div className="rounded-xl border border-border p-4 space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} className={inputCls} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.mode")}</label>
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
<option value="av">{t("downloads.profiles.modeAv")}</option>
<option value="v">{t("downloads.profiles.modeV")}</option>
<option value="a">{t("downloads.profiles.modeA")}</option>
</select>
</div>
{spec.mode !== "a" ? (
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.quality")}</label>
<select
value={spec.max_height ?? ""}
onChange={(e) => patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })}
className={inputCls}
>
{HEIGHTS.map((h) => (
<option key={h ?? "best"} value={h ?? ""}>
{h ? `${h}p` : t("downloads.profiles.best")}
</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.audioFormat")}</label>
<select value={spec.audio_format ?? "m4a"} onChange={set("audio_format")} className={inputCls}>
<option value="m4a">M4A</option>
<option value="mp3">MP3</option>
<option value="opus">Opus</option>
</select>
</div>
)}
{spec.mode !== "a" && (
<>
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.container")}</label>
<select value={spec.container ?? "mp4"} onChange={set("container")} className={inputCls}>
<option value="mp4">MP4</option>
<option value="mkv">MKV</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.vcodec")}</label>
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
<option value="">{t("downloads.profiles.any")}</option>
<option value="h264">H.264</option>
<option value="vp9">VP9</option>
<option value="av1">AV1</option>
</select>
</div>
</>
)}
</div>
<div className="space-y-1.5 pt-1">
{spec.mode !== "a" && (
<Check label={t("downloads.profiles.embedSubs")} checked={spec.embed_subs} onChange={(v) => patch({ embed_subs: v })} />
)}
<Check label={t("downloads.profiles.embedChapters")} checked={spec.embed_chapters} onChange={(v) => patch({ embed_chapters: v })} />
<Check label={t("downloads.profiles.embedThumbnail")} checked={spec.embed_thumbnail} onChange={(v) => patch({ embed_thumbnail: v })} />
<Check label={t("downloads.profiles.sponsorblock")} checked={spec.sponsorblock} onChange={(v) => patch({ sponsorblock: v })} />
</div>
<div className="flex justify-end gap-2 pt-1">
<button onClick={() => setFormOpen(false)} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
{t("downloads.actions.cancel")}
</button>
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
</button>
</div>
</div>
)}
</Modal>
);
}

View file

@ -11,6 +11,7 @@ import {
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -67,6 +68,7 @@ function Actions({
<Bookmark className="w-4 h-4" />
</button>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<button
onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}

View file

@ -0,0 +1,136 @@
{
"navLabel": "Downloads",
"button": {
"label": "Herunterladen",
"queued": "In Warteschlange",
"downloaded": "Heruntergeladen"
},
"dialog": {
"title": "Video herunterladen",
"profile": "Format",
"nameLabel": "Dateiname (optional)",
"namePlaceholder": "Leer lassen, um den Videotitel zu verwenden",
"submit": "Zu Downloads hinzufügen",
"added": "Zu Downloads hinzugefügt",
"view": "Öffnen",
"manage": "Formate verwalten"
},
"page": {
"title": "Downloads",
"subtitle": "Videos auf den Server laden und dann auf dein Gerät speichern.",
"addUrl": "YouTube-Link oder Video-ID einfügen…",
"add": "Hinzufügen",
"manage": "Formate verwalten"
},
"tabs": {
"queue": "Warteschlange",
"done": "Bibliothek",
"shared": "Mit mir geteilt",
"system": "System"
},
"status": {
"queued": "Wartet",
"running": "Lädt",
"paused": "Pausiert",
"done": "Fertig",
"error": "Fehlgeschlagen",
"canceled": "Abgebrochen",
"expired": "Abgelaufen"
},
"actions": {
"pause": "Pause",
"resume": "Fortsetzen",
"cancel": "Abbrechen",
"delete": "Entfernen",
"retry": "Erneut",
"download": "Auf mein Gerät speichern",
"rename": "Umbenennen",
"share": "Teilen"
},
"empty": {
"queue": "Die Warteschlange ist leer.",
"done": "Noch keine Downloads.",
"shared": "Es wurde nichts mit dir geteilt.",
"admin": "Auf dieser Instanz gibt es noch keine Downloads."
},
"usage": {
"title": "Dein Speicher",
"items": "{{used}} / {{max}} Elemente",
"unlimited": "Unbegrenzt"
},
"rename": {
"title": "Download umbenennen",
"label": "Anzeigename",
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
"save": "Speichern"
},
"share": {
"title": "Download teilen",
"label": "E-Mail des Empfängers",
"placeholder": "user@example.com",
"submit": "Teilen",
"done": "Geteilt mit {{email}}"
},
"confirm": {
"cancelTitle": "Download abbrechen?",
"cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.",
"deleteTitle": "Download entfernen?",
"deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben."
},
"profiles": {
"manage": "Formate verwalten",
"title": "Download-Formate",
"builtin": "Integriert",
"yours": "Deine Formate",
"new": "Neues Format",
"name": "Name",
"namePlaceholder": "Mein Format",
"mode": "Inhalt",
"modeAv": "Video + Audio",
"modeV": "Nur Video",
"modeA": "Nur Audio",
"quality": "Max. Qualität",
"best": "Beste",
"container": "Container",
"audioFormat": "Audioformat",
"vcodec": "Video-Codec",
"any": "Beliebig",
"embedSubs": "Untertitel einbetten",
"embedChapters": "Kapitel einbetten",
"embedThumbnail": "Vorschaubild einbetten",
"sponsorblock": "Sponsor-Segmente überspringen (SponsorBlock)",
"save": "Speichern",
"create": "Erstellen",
"delete": "Löschen",
"deleteConfirm": "Das Format „{{name}}“ löschen?"
},
"admin": {
"storageTitle": "Speicher",
"readyFiles": "Fertige Dateien",
"totalSize": "Gesamtgröße",
"cap": "Cache-Limit",
"noCap": "kein Limit",
"perUser": "Speicher pro Nutzer",
"user": "Nutzer",
"footprint": "Belegung",
"editQuota": "Kontingent bearbeiten",
"quotaTitle": "Download-Kontingent — {{email}}",
"maxBytes": "Speicherlimit (GB)",
"maxJobs": "Max. Downloads",
"maxConcurrent": "Max. gleichzeitig",
"unlimited": "Unbegrenzt (Speicherlimit umgehen)",
"reset": "Auf Standard zurücksetzen",
"save": "Speichern",
"custom": "individuell",
"default": "Standard"
},
"cols": {
"title": "Titel",
"channel": "Kanal",
"format": "Format",
"size": "Größe",
"status": "Status",
"added": "Hinzugefügt",
"user": "Nutzer"
}
}

View file

@ -0,0 +1,136 @@
{
"navLabel": "Downloads",
"button": {
"label": "Download",
"queued": "In queue",
"downloaded": "Downloaded"
},
"dialog": {
"title": "Download video",
"profile": "Format",
"nameLabel": "File name (optional)",
"namePlaceholder": "Leave blank to use the video title",
"submit": "Add to downloads",
"added": "Added to downloads",
"view": "View",
"manage": "Manage formats"
},
"page": {
"title": "Downloads",
"subtitle": "Download videos to the server, then save them to your device.",
"addUrl": "Paste a YouTube link or video id…",
"add": "Add",
"manage": "Manage formats"
},
"tabs": {
"queue": "Queue",
"done": "Library",
"shared": "Shared with me",
"system": "System"
},
"status": {
"queued": "Queued",
"running": "Downloading",
"paused": "Paused",
"done": "Ready",
"error": "Failed",
"canceled": "Canceled",
"expired": "Expired"
},
"actions": {
"pause": "Pause",
"resume": "Resume",
"cancel": "Cancel",
"delete": "Remove",
"retry": "Retry",
"download": "Save to my device",
"rename": "Rename",
"share": "Share"
},
"empty": {
"queue": "Nothing in the queue.",
"done": "No downloads yet.",
"shared": "Nothing has been shared with you.",
"admin": "No downloads on this instance yet."
},
"usage": {
"title": "Your storage",
"items": "{{used}} / {{max}} items",
"unlimited": "Unlimited"
},
"rename": {
"title": "Rename download",
"label": "Display name",
"hint": "Only changes the name you see and download with — the stored file keeps its own name.",
"save": "Save"
},
"share": {
"title": "Share download",
"label": "Recipient email",
"placeholder": "user@example.com",
"submit": "Share",
"done": "Shared with {{email}}"
},
"confirm": {
"cancelTitle": "Cancel download?",
"cancelBody": "This stops the download and removes it from your queue.",
"deleteTitle": "Remove download?",
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires."
},
"profiles": {
"manage": "Manage formats",
"title": "Download formats",
"builtin": "Built-in",
"yours": "Your formats",
"new": "New format",
"name": "Name",
"namePlaceholder": "My format",
"mode": "Content",
"modeAv": "Video + audio",
"modeV": "Video only",
"modeA": "Audio only",
"quality": "Max quality",
"best": "Best",
"container": "Container",
"audioFormat": "Audio format",
"vcodec": "Video codec",
"any": "Any",
"embedSubs": "Embed subtitles",
"embedChapters": "Embed chapters",
"embedThumbnail": "Embed thumbnail",
"sponsorblock": "Skip sponsor segments (SponsorBlock)",
"save": "Save",
"create": "Create",
"delete": "Delete",
"deleteConfirm": "Delete the format “{{name}}”?"
},
"admin": {
"storageTitle": "Storage",
"readyFiles": "Ready files",
"totalSize": "Total size",
"cap": "Cache cap",
"noCap": "no cap",
"perUser": "Per-user footprint",
"user": "User",
"footprint": "Footprint",
"editQuota": "Edit quota",
"quotaTitle": "Download quota — {{email}}",
"maxBytes": "Storage limit (GB)",
"maxJobs": "Max downloads",
"maxConcurrent": "Max concurrent",
"unlimited": "Unlimited (bypass storage limit)",
"reset": "Reset to default",
"save": "Save",
"custom": "custom",
"default": "default"
},
"cols": {
"title": "Title",
"channel": "Channel",
"format": "Format",
"size": "Size",
"status": "Status",
"added": "Added",
"user": "User"
}
}

View file

@ -0,0 +1,136 @@
{
"navLabel": "Letöltések",
"button": {
"label": "Letöltés",
"queued": "Sorban",
"downloaded": "Letöltve"
},
"dialog": {
"title": "Videó letöltése",
"profile": "Formátum",
"nameLabel": "Fájlnév (opcionális)",
"namePlaceholder": "Üresen hagyva a videó címét használja",
"submit": "Hozzáadás a letöltésekhez",
"added": "Hozzáadva a letöltésekhez",
"view": "Megnyitás",
"manage": "Formátumok kezelése"
},
"page": {
"title": "Letöltések",
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
"add": "Hozzáad",
"manage": "Formátumok kezelése"
},
"tabs": {
"queue": "Sor",
"done": "Könyvtár",
"shared": "Velem megosztva",
"system": "Rendszer"
},
"status": {
"queued": "Várakozik",
"running": "Letöltés",
"paused": "Szüneteltetve",
"done": "Kész",
"error": "Sikertelen",
"canceled": "Megszakítva",
"expired": "Lejárt"
},
"actions": {
"pause": "Szünet",
"resume": "Folytatás",
"cancel": "Megszakítás",
"delete": "Eltávolítás",
"retry": "Újra",
"download": "Mentés a gépemre",
"rename": "Átnevezés",
"share": "Megosztás"
},
"empty": {
"queue": "A sor üres.",
"done": "Még nincs letöltés.",
"shared": "Még nem osztottak meg veled semmit.",
"admin": "Ezen a példányon még nincs letöltés."
},
"usage": {
"title": "Tárhelyed",
"items": "{{used}} / {{max}} elem",
"unlimited": "Korlátlan"
},
"rename": {
"title": "Letöltés átnevezése",
"label": "Megjelenített név",
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
"save": "Mentés"
},
"share": {
"title": "Letöltés megosztása",
"label": "Címzett e-mail címe",
"placeholder": "user@example.com",
"submit": "Megosztás",
"done": "Megosztva vele: {{email}}"
},
"confirm": {
"cancelTitle": "Megszakítod a letöltést?",
"cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.",
"deleteTitle": "Eltávolítod a letöltést?",
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára."
},
"profiles": {
"manage": "Formátumok kezelése",
"title": "Letöltési formátumok",
"builtin": "Beépített",
"yours": "Saját formátumaid",
"new": "Új formátum",
"name": "Név",
"namePlaceholder": "Saját formátum",
"mode": "Tartalom",
"modeAv": "Videó + hang",
"modeV": "Csak videó",
"modeA": "Csak hang",
"quality": "Max. minőség",
"best": "Legjobb",
"container": "Konténer",
"audioFormat": "Hangformátum",
"vcodec": "Videó codec",
"any": "Bármelyik",
"embedSubs": "Feliratok beágyazása",
"embedChapters": "Fejezetek beágyazása",
"embedThumbnail": "Bélyegkép beágyazása",
"sponsorblock": "Szponzorált szakaszok kihagyása (SponsorBlock)",
"save": "Mentés",
"create": "Létrehozás",
"delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?"
},
"admin": {
"storageTitle": "Tárhely",
"readyFiles": "Kész fájlok",
"totalSize": "Teljes méret",
"cap": "Gyorsítótár-korlát",
"noCap": "nincs korlát",
"perUser": "Felhasználónkénti tárhelyhasználat",
"user": "Felhasználó",
"footprint": "Használat",
"editQuota": "Kvóta szerkesztése",
"quotaTitle": "Letöltési kvóta — {{email}}",
"maxBytes": "Tárhelykorlát (GB)",
"maxJobs": "Max. letöltés",
"maxConcurrent": "Max. egyidejű",
"unlimited": "Korlátlan (tárhelykorlát kikapcsolása)",
"reset": "Visszaállítás alapértékre",
"save": "Mentés",
"custom": "egyedi",
"default": "alapértelmezett"
},
"cols": {
"title": "Cím",
"channel": "Csatorna",
"format": "Formátum",
"size": "Méret",
"status": "Állapot",
"added": "Hozzáadva",
"user": "Felhasználó"
}
}

View file

@ -623,6 +623,99 @@ export interface AdminUserRow {
created_at: string | null;
}
// --- download center ---
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
max_height: number | null;
container: string | null;
audio_format: string | null;
vcodec: string | null;
embed_subs: boolean;
embed_chapters: boolean;
embed_thumbnail: boolean;
sponsorblock: boolean;
}
export interface DownloadProfile {
id: number;
name: string;
spec: DownloadSpec;
is_builtin: boolean;
sort_order: number;
}
export interface DownloadAsset {
id: number;
status: string;
title: string | null;
uploader: string | null;
upload_date: string | null;
duration_s: number | null;
size_bytes: number | null;
width: number | null;
height: number | null;
container: string | null;
thumbnail_url: string | null;
expires_at: string | null;
}
export type DownloadStatus =
| "queued"
| "running"
| "paused"
| "done"
| "error"
| "canceled";
export interface DownloadJob {
id: number;
status: DownloadStatus;
progress: number;
speed_bps: number | null;
eta_s: number | null;
phase: string | null;
error: string | null;
queue_pos: number;
created_at: string | null;
source_kind: string;
source_ref: string;
profile_id: number | null;
spec: DownloadSpec;
display_name: string | null;
can_download: boolean;
expired: boolean;
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;
}
export interface DownloadUsage {
footprint_bytes: number;
active_jobs: number;
running_jobs: number;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
}
export interface AdminStorage {
ready_files: number;
total_bytes: number;
total_assets: number;
total_cap_bytes: number;
per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
}
export interface AdminDownloadQuota {
user_id: number;
custom: boolean;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
}
export const api = {
me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => 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<DownloadProfile[]> => req("/api/downloads/profiles"),
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
updateDownloadProfile: (
id: number,
patch: { name?: string; spec?: DownloadSpec }
): Promise<DownloadProfile> =>
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<DownloadJob> =>
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
resumeDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
cancelDownload: (id: number): Promise<DownloadJob> =>
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<DownloadJob> =>
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<DownloadJob[]> => req("/api/admin/downloads"),
adminDownloadStorage: (): Promise<AdminStorage> => req("/api/admin/downloads/storage"),
adminGetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`),
adminSetDownloadQuota: (
userId: number,
patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
};
export { HttpError };

View file

@ -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. */

14
frontend/src/lib/nav.ts Normal file
View file

@ -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);
}

View file

@ -100,6 +100,7 @@ export const PAGES = [
"users",
"notifications",
"messages",
"downloads",
] as const;
export type Page = (typeof PAGES)[number];