- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch - ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date filter on one trigger, a pill in a toolbar, r8 in a form - ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput - every raw input swept (~100 sites); inputCls retired; local/input-primitive - useReturnFocus: an in-place editor hands the focus back to its trigger on Enter/Escape (the playlist and Plex title renames, the priority number); the Plex playlist rename gains Escape and an accessible name - back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
272 lines
9.2 KiB
TypeScript
272 lines
9.2 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 "./ui/Modal";
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { api } from "../lib/api";
|
|
import { formatBytes } from "../lib/format";
|
|
import { Button } from "./ui/Button";
|
|
import { StatusPill } from "./downloadRow";
|
|
import { jobTitle } from "./downloadTitle";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { Input } from "./ui/Input";
|
|
import { CheckRow } from "./ui/NativeControls";
|
|
|
|
// 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,
|
|
meta: { failTitle: "errors.writeFailed.downloadQuota" },
|
|
});
|
|
const reset = useMutation({
|
|
mutationFn: () => api.adminResetDownloadQuota(userId),
|
|
onSuccess: done,
|
|
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
|
|
});
|
|
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
|
|
className="w-full"
|
|
type="number"
|
|
value={state.gb}
|
|
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
|
disabled={state.unlimited}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
{t("downloads.admin.maxJobs")}
|
|
<Input
|
|
className="w-full"
|
|
type="number"
|
|
value={state.jobs}
|
|
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
{t("downloads.admin.maxConcurrent")}
|
|
<Input
|
|
className="w-full"
|
|
type="number"
|
|
value={state.conc}
|
|
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<CheckRow
|
|
checked={state.unlimited}
|
|
onChange={(checked) => upd({ unlimited: checked })}
|
|
className="text-sm"
|
|
>
|
|
{t("downloads.admin.unlimited")}
|
|
</CheckRow>
|
|
<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
|
|
className="w-full"
|
|
value={q}
|
|
onChange={(e) => {
|
|
setQ(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
|
/>
|
|
{open && filtered.length > 0 && (
|
|
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
|
{filtered.map((u) => (
|
|
<Pressable
|
|
key={u.id}
|
|
layer
|
|
onClick={() => {
|
|
onPick({ id: u.id, email: u.email });
|
|
setQ("");
|
|
setOpen(false);
|
|
}}
|
|
className="w-full px-3 py-2 text-sm 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>}
|
|
</Pressable>
|
|
))}
|
|
</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>
|
|
<IconButton
|
|
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
|
label={t("downloads.admin.editQuota")}
|
|
>
|
|
<Settings2 />
|
|
</IconButton>
|
|
</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>
|
|
);
|
|
}
|