import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Download, Pause, Play, Plus, Scissors, 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 VideoEditor from "./VideoEditor"; import ShareDialog from "./ShareDialog"; import { useConfirm } from "./ConfirmProvider"; import { useLiveQuery } from "../lib/useLiveQuery"; import { api, type DownloadJob, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; const inputCls = "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"; const GiB = 1024 ** 3; const STATUS_CLS: Record = { queued: "text-muted", running: "text-accent", paused: "text-amber-400", done: "text-emerald-400", error: "text-red-400", canceled: "text-muted", }; // Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse). // "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar. const DOWNLOAD_PHASES = ["video", "audio", "editing"]; const PHASE_KEYS = [ "video", "audio", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing", ]; function StatusPill({ job }: { job: DownloadJob }) { const { t } = useTranslation(); // While downloading, show the concrete phase (video / audio / merging) so the user isn't // confused by the bar resetting between the separate video and audio streams. if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { return {t(`downloads.phase.${job.phase}`)}; } const key = job.expired ? "expired" : job.status; return ( {t(`downloads.status.${key}`)} ); } function Thumb({ job }: { job: DownloadJob }) { const url = job.asset?.thumbnail_url; return (
{url ? : null}
); } function jobTitle(job: DownloadJob): string { return job.display_name || job.asset?.title || job.source_ref; } function IconBtn({ onClick, title, danger, children, }: { onClick: () => void; title: string; danger?: boolean; children: React.ReactNode; }) { return ( ); } function DownloadRow({ job, children, }: { job: DownloadJob; children?: React.ReactNode; }) { const { t } = useTranslation(); const running = job.status === "running"; return (
{job.job_kind === "edit" && ( {t("downloads.clipBadge")} )} {jobTitle(job)}
{job.asset?.uploader || job.source_ref}
{job.asset?.size_bytes ? · {formatBytes(job.asset.size_bytes)} : null} {job.asset?.duration_s ? · {formatDuration(job.asset.duration_s)} : null} {job.error ? · {job.error} : null}
{running && (
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
) : ( <>
{job.progress}% {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} {job.eta_s ? {formatEta(job.eta_s)} : null}
)}
)}
{children}
); } // --- Rename + Share small modals ------------------------------------------------------------ function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const [name, setName] = useState(jobTitle(job)); const save = useMutation({ mutationFn: () => api.renameDownload(job.id, name.trim()), onSuccess: () => { qc.invalidateQueries({ queryKey: ["downloads"] }); onClose(); }, }); return ( setName(e.target.value)} className={inputCls} autoFocus />

{t("downloads.rename.hint")}

); } // --- Usage bar ------------------------------------------------------------------------------ function UsageBar() { const { t } = useTranslation(); const usage = useQuery({ queryKey: ["download-usage"], queryFn: api.downloadUsage }); const u = usage.data; if (!u) return null; const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); return (
{t("downloads.usage.title")} {formatBytes(u.footprint_bytes)} {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} {u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
{!u.unlimited && (
90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
)}
); } // --- Admin system tab ----------------------------------------------------------------------- function QuotaModal({ userId, email, onClose }: { userId: number; email: string; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const q = useQuery({ queryKey: ["dl-quota", userId], queryFn: () => api.adminGetDownloadQuota(userId) }); const [form, setForm] = useState<{ gb: number; jobs: number; conc: number; unlimited: boolean } | null>(null); const cur = q.data; const state = form ?? (cur ? { gb: Math.round((cur.max_bytes / GiB) * 10) / 10, jobs: cur.max_jobs, conc: cur.max_concurrent, unlimited: cur.unlimited } : null); const done = () => { qc.invalidateQueries({ queryKey: ["dl-quota", userId] }); qc.invalidateQueries({ queryKey: ["admin-storage"] }); onClose(); }; const save = useMutation({ mutationFn: () => api.adminSetDownloadQuota(userId, { max_bytes: Math.round((state!.gb) * GiB), max_jobs: state!.jobs, max_concurrent: state!.conc, unlimited: state!.unlimited, }), onSuccess: done, }); const reset = useMutation({ mutationFn: () => api.adminResetDownloadQuota(userId), onSuccess: done }); if (!state) return null; const upd = (p: Partial) => setForm({ ...state, ...p }); return (
); } // Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the // footprint list below only shows users who already have files). function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) { const { t } = useTranslation(); const [q, setQ] = useState(""); const [open, setOpen] = useState(false); const users = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); const list = (users.data ?? []).filter((u) => !u.is_demo); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); const base = s ? list.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s)) : list; return base.slice(0, 8); }, [q, list]); return (
{ setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} placeholder={t("downloads.admin.quotaPickPlaceholder")} className={inputCls} /> {open && filtered.length > 0 && (
{filtered.map((u) => ( ))}
)}
); } function AdminSystem() { const { t } = useTranslation(); const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 }); const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 }); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); const s = storage.data; return (
{s && (
{[ [t("downloads.admin.readyFiles"), String(s.ready_files)], [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)], [t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")], ].map(([label, val]) => (
{label}
{val}
))}
)} {s && (
{t("downloads.admin.perUser")}
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
{s.per_user.map((u) => (
{u.email} {formatBytes(u.footprint_bytes)}
))} {s.per_user.length === 0 && (
{t("downloads.admin.noFootprint")}
)}
)}
{t("downloads.tabs.system")}
{(jobs.data ?? []).map((j) => (
{jobTitle(j)} {j.user_email} {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
))} {jobs.data && jobs.data.length === 0 &&
{t("downloads.empty.admin")}
}
{quotaFor && setQuotaFor(null)} />}
); } // --- Main ----------------------------------------------------------------------------------- export default function DownloadCenter({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue"); const [addUrl, setAddUrl] = useState(""); const [addSource, setAddSource] = useState(null); const [manage, setManage] = useState(false); const [renameJob, setRenameJob] = useState(null); const [shareJob, setShareJob] = useState(null); const [editJob, setEditJob] = useState(null); const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 }); const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" }); const jobs = jobsQ.data ?? []; const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status)); const library = jobs.filter((j) => j.status === "done"); const invalidate = () => { qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: ["download-index"] }); qc.invalidateQueries({ queryKey: ["download-usage"] }); }; const act = useMutation({ mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) => op === "pause" ? api.pauseDownload(id) : op === "resume" ? api.resumeDownload(id) : op === "cancel" ? api.cancelDownload(id) : api.deleteDownload(id), onSuccess: invalidate, }); // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched). const removeShared = useMutation({ mutationFn: (id: number) => api.removeSharedDownload(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads-shared"] }), }); const confirmThen = async (title: string, body: string, fn: () => void) => { if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); }; const tabs = [ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "done", label: t("downloads.tabs.done"), badge: library.length }, { id: "shared", label: t("downloads.tabs.shared") }, ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ]; const saveBtn = (job: DownloadJob) => ( ); return (

{t("downloads.page.title")}

{t("downloads.page.subtitle")}

{tab === "queue" && ( <>
setAddUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} placeholder={t("downloads.page.addUrl")} className={inputCls} />
{queue.map((job) => ( {(job.status === "queued" || job.status === "running") && ( act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}> )} {(job.status === "paused" || job.status === "error") && ( act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}> )} confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))} title={t("downloads.actions.cancel")} danger > ))} {queue.length === 0 &&
{t("downloads.empty.queue")}
}
)} {tab === "done" && ( <>
{library.map((job) => ( {saveBtn(job)} {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( setEditJob(job)} title={t("downloads.actions.edit")}> )} setRenameJob(job)} title={t("downloads.actions.rename")}> setShareJob(job)} title={t("downloads.actions.share")}> confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))} title={t("downloads.actions.delete")} danger > ))} {library.length === 0 &&
{t("downloads.empty.done")}
}
)} {tab === "shared" && (
{(sharedQ.data ?? []).map((job) => ( {job.can_download && saveBtn(job)} {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( setEditJob(job)} title={t("downloads.actions.edit")}> )} confirmThen( t("downloads.confirm.removeSharedTitle"), t("downloads.confirm.removeSharedBody"), () => removeShared.mutate(job.id) ) } title={t("downloads.actions.removeShared")} danger > ))} {sharedQ.data && sharedQ.data.length === 0 && (
{t("downloads.empty.shared")}
)}
)} {tab === "system" && me.role === "admin" && } {addSource && ( { setAddSource(null); setAddUrl(""); }} /> )} {manage && setManage(false)} />} {renameJob && setRenameJob(null)} />} {shareJob && setShareJob(null)} />} {editJob && setEditJob(null)} />}
); }