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:
parent
51158ad9cf
commit
7d1ed24fea
17 changed files with 1551 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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!}
|
||||
|
|
|
|||
66
frontend/src/components/DownloadButton.tsx
Normal file
66
frontend/src/components/DownloadButton.tsx
Normal 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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
511
frontend/src/components/DownloadCenter.tsx
Normal file
511
frontend/src/components/DownloadCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
frontend/src/components/DownloadDialog.tsx
Normal file
117
frontend/src/components/DownloadDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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")}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
215
frontend/src/components/ProfileEditor.tsx
Normal file
215
frontend/src/components/ProfileEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
136
frontend/src/i18n/locales/de/downloads.json
Normal file
136
frontend/src/i18n/locales/de/downloads.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
frontend/src/i18n/locales/en/downloads.json
Normal file
136
frontend/src/i18n/locales/en/downloads.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
frontend/src/i18n/locales/hu/downloads.json
Normal file
136
frontend/src/i18n/locales/hu/downloads.json
Normal 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ó"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
14
frontend/src/lib/nav.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ export const PAGES = [
|
|||
"users",
|
||||
"notifications",
|
||||
"messages",
|
||||
"downloads",
|
||||
] as const;
|
||||
|
||||
export type Page = (typeof PAGES)[number];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue