Sweep the rounded-lg solid/ghost/outline action-button family onto the shared primitive: ConfirmProvider, ErrorDialog, DownloadDialog, DownloadCenter, downloadModals, downloadAdmin, ProfileEditor, PersonalViewsModal, SettingsPanel, WatchPage, DraftSaveBar. Deliberately left on their own styles per the agreed scope: login/onboarding rounded-xl CTAs (Welcome/Onboarding/ Setup/KeyGate), glass Plex surfaces, segmented pills/toggles/switches/chips, dropdown triggers, data-driven Banner/tone maps, text-xs dense buttons, and tiny inline p-1/p-1.5 icon controls — none map 1:1 to a variant+size without a visual change, and the repo has no tailwind-merge.
267 lines
9.1 KiB
TypeScript
267 lines
9.1 KiB
TypeScript
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 { Button } from "./ui/Button";
|
|
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 variant="ghost" size="sm" onClick={() => reset.mutate()}>
|
|
{t("downloads.admin.reset")}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => save.mutate()}
|
|
disabled={save.isPending}
|
|
>
|
|
{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>
|
|
);
|
|
}
|