navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the download share-link copy 'often does neither'). Add lib/clipboard.ts copyText(): a gesture-safe execCommand path FIRST (before any await can spend the transient user activation), async Clipboard API as fallback. Migrate all five copy call sites to it.
754 lines
31 KiB
TypeScript
754 lines
31 KiB
TypeScript
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Ban,
|
|
Copy,
|
|
Download,
|
|
Link2,
|
|
Pause,
|
|
Play,
|
|
Plus,
|
|
Scissors,
|
|
Settings2,
|
|
Share2,
|
|
Pencil,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import clsx from "clsx";
|
|
import Tabs, { usePersistedTab } from "./Tabs";
|
|
import { PageToolbar } from "./PageShell";
|
|
import Modal from "./Modal";
|
|
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
|
const VideoEditor = lazy(() => import("./VideoEditor"));
|
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { useMe } from "../lib/useMe";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { invalidateDownloads } from "../lib/downloads";
|
|
import { notify } from "../lib/notifications";
|
|
import { copyText } from "../lib/clipboard";
|
|
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
|
|
import { inputCls } from "./ui/form";
|
|
|
|
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",
|
|
};
|
|
|
|
// 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 <span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>;
|
|
}
|
|
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 }) {
|
|
// Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources.
|
|
const url = job.asset?.thumbnail_url || job.poster_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;
|
|
}
|
|
|
|
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
|
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
|
|
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
|
|
return {
|
|
name: job.display_uploader || job.asset?.uploader || job.source_ref,
|
|
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
|
};
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
|
function displayUrl(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
|
function SourceRef({ url }: { url: string }) {
|
|
const { t } = useTranslation();
|
|
const copy = async () => {
|
|
if (await copyText(url)) {
|
|
notify({ level: "success", message: t("downloads.source.copied") });
|
|
} else {
|
|
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
|
}
|
|
};
|
|
return (
|
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
|
<Link2 className="w-3 h-3 shrink-0" />
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={`${t("downloads.source.open")} — ${url}`}
|
|
className="truncate hover:text-accent hover:underline"
|
|
>
|
|
{displayUrl(url)}
|
|
</a>
|
|
<button
|
|
onClick={copy}
|
|
aria-label={t("downloads.source.copy")}
|
|
title={t("downloads.source.copy")}
|
|
className="shrink-0 p-0.5 rounded hover:bg-surface hover:text-fg transition"
|
|
>
|
|
<Copy className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
|
function ChannelLine({ job }: { job: DownloadJob }) {
|
|
const { name, url } = jobChannel(job);
|
|
if (url) {
|
|
return (
|
|
<div className="text-xs mt-0.5 truncate">
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={url}
|
|
className="text-muted hover:text-accent hover:underline"
|
|
>
|
|
{name}
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
|
}
|
|
|
|
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 glass-card glass-hover transition">
|
|
<Thumb job={job} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
|
{job.job_kind === "edit" && (
|
|
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
|
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
|
</span>
|
|
)}
|
|
<span className="truncate">{jobTitle(job)}</span>
|
|
</div>
|
|
<ChannelLine job={job} />
|
|
<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>
|
|
{job.source_url && <SourceRef url={job.source_url} />}
|
|
{job.extra_links?.map((url) => <SourceRef key={url} url={url} />)}
|
|
{running && (
|
|
<div className="mt-1.5">
|
|
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
|
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
|
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<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 ------------------------------------------------------------
|
|
|
|
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
|
// All are display-only overrides — the on-disk file is never renamed.
|
|
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
|
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
|
const [channelUrl, setChannelUrl] = useState(job.display_uploader_url || job.asset?.uploader_url || "");
|
|
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
|
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
|
|
|
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
|
const addLink = () => setLinks((ls) => [...ls, ""]);
|
|
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
api.updateDownloadMeta(job.id, {
|
|
display_name: name.trim(),
|
|
display_uploader: channel.trim(),
|
|
display_uploader_url: channelUrl.trim(),
|
|
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
|
onClose();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
|
<div className="space-y-3">
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
|
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
|
<input value={channel} onChange={(e) => setChannel(e.target.value)} className={inputCls} />
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
|
<input
|
|
value={channelUrl}
|
|
onChange={(e) => setChannelUrl(e.target.value)}
|
|
placeholder="https://…"
|
|
className={inputCls}
|
|
/>
|
|
</label>
|
|
<div>
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
|
<div className="space-y-2">
|
|
{links.map((l, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<input
|
|
value={l}
|
|
onChange={(e) => setLink(i, e.target.value)}
|
|
placeholder="https://…"
|
|
className={inputCls}
|
|
/>
|
|
<IconBtn onClick={() => removeLink(i)} title={t("downloads.edit.removeLink")} danger>
|
|
<Trash2 className="w-4 h-4" />
|
|
</IconBtn>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={addLink}
|
|
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
|
>
|
|
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-muted">{t("downloads.edit.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.edit.save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// --- Usage bar ------------------------------------------------------------------------------
|
|
|
|
function UsageBar() {
|
|
const { t } = useTranslation();
|
|
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
|
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
|
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
|
const usage = useLiveQuery(["download-usage"], api.downloadUsage, { intervalMs: 2000 });
|
|
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 glass-card 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>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="relative w-full sm:w-72">
|
|
<input
|
|
value={q}
|
|
onChange={(e) => {
|
|
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 && (
|
|
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
|
|
{filtered.map((u) => (
|
|
<button
|
|
key={u.id}
|
|
onClick={() => {
|
|
onPick({ id: u.id, email: u.email });
|
|
setQ("");
|
|
setOpen(false);
|
|
}}
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
|
>
|
|
<span className="truncate">{u.display_name || u.email}</span>
|
|
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 glass-card p-3">
|
|
<div className="text-xs text-muted">{label}</div>
|
|
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{s && (
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
|
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
|
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
|
<QuotaUserPicker onPick={setQuotaFor} />
|
|
</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>
|
|
))}
|
|
{s.per_user.length === 0 && (
|
|
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</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() {
|
|
const me = useMe();
|
|
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 [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
|
const [editJob, setEditJob] = 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 = () => invalidateDownloads(qc);
|
|
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") }] : []),
|
|
];
|
|
// A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin
|
|
// account) — that would render a blank page with no active tab. Fall back to the default.
|
|
useEffect(() => {
|
|
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tab, me.role]);
|
|
|
|
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 (
|
|
<>
|
|
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
|
|
<PageToolbar>
|
|
<div className="px-4 pt-3 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} />
|
|
</div>
|
|
</PageToolbar>
|
|
|
|
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
|
|
{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)}
|
|
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
|
<Scissors className="w-4 h-4" />
|
|
</IconBtn>
|
|
)}
|
|
<IconBtn onClick={() => setMetaJob(job)} title={t("downloads.actions.editDetails")}>
|
|
<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)}
|
|
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
|
<Scissors className="w-4 h-4" />
|
|
</IconBtn>
|
|
)}
|
|
<IconBtn
|
|
onClick={() =>
|
|
confirmThen(
|
|
t("downloads.confirm.removeSharedTitle"),
|
|
t("downloads.confirm.removeSharedBody"),
|
|
() => removeShared.mutate(job.id)
|
|
)
|
|
}
|
|
title={t("downloads.actions.removeShared")}
|
|
danger
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</IconBtn>
|
|
</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 />}
|
|
|
|
<Suspense fallback={null}>
|
|
{addSource && (
|
|
<DownloadDialog
|
|
source={addSource}
|
|
onClose={() => {
|
|
setAddSource(null);
|
|
setAddUrl("");
|
|
}}
|
|
/>
|
|
)}
|
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
|
</Suspense>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|