refactor(downloads): split DownloadCenter god-file into row + modals + admin (E4 S5)

Pure code-motion, DownloadCenter 1088->359 L. Group by kind, mirroring the S4
plexCards/plexViews split:

- components/downloadRow.tsx (309 L): the user-list presentation + shared row
  primitives — StatusPill, Thumb, IconBtn, SourceRef, ChannelLine, DownloadRow,
  UsageBar (+ jobTitle/jobChannel/displayUrl helpers, status/phase constants).
- components/downloadModals.tsx (178 L): the per-item user modals — MetaEditModal
  (rename/metadata) and ErrorModal (failure detail + retry).
- components/downloadAdmin.tsx (267 L): the admin "System" tab — AdminSystem plus
  QuotaModal and QuotaUserPicker.

Every extracted piece is prop-driven with no shell-state reach-back, so the shell
just imports them back; the tab logic, mutations and modal state stay in
DownloadCenter. Behaviour-preserving.

Gate green (tsc first try, knip, lint 0-err, 247 vitest). Self-E2E (localdev):
Queue/Library/Shared/System tabs render, UsageBar, admin storage/footprint/job
list, MetaEditModal open/close, 0 console errors.
This commit is contained in:
2026-08-19 21:14:49 +02:00
parent 180ab56379
commit aea99cc71a
4 changed files with 758 additions and 733 deletions
+267
View File
@@ -0,0 +1,267 @@
import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings2 } from "lucide-react";
import Modal from "./Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api } from "../lib/api";
import { formatBytes } from "../lib/format";
import { inputCls } from "./ui/form";
import { StatusPill, jobTitle } from "./downloadRow";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
// list. Split out of DownloadCenter (self-contained, admin-only surface).
const GiB = 1024 ** 3;
function QuotaModal({
userId,
email,
onClose,
}: {
userId: number;
email: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({
queryKey: qk.dlQuota(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: qk.dlQuota(userId) });
qc.invalidateQueries({ queryKey: qk.adminStorage() });
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 btn-primary 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: qk.adminUsers(), 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 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>
);
}
export function AdminSystem() {
const { t } = useTranslation();
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), 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-sm 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>
);
}