feat(ui): E31 S4b — the admin pages behind one rail row, Configuration as a sectioned list, Statistics as tiles and bars

- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a
  middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll
- sysconfig.SECTIONS orders the config groups under four headings (every group in exactly
  one, test-held); the page shows them as Settings' side list (Tabs `group` headings);
  the lone registration group reads "Registration" under "Access"
- StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the
  per-operation quota use as horizontal bars (a zero draws none)
This commit is contained in:
2026-10-02 00:07:00 +02:00
parent 25905aa786
commit be156fc52b
21 changed files with 753 additions and 223 deletions
+126 -121
View File
@@ -11,7 +11,6 @@ import { useMe } from "../lib/useMe";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
@@ -176,146 +175,152 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />;
}
const groupKeys = Object.keys(data.groups);
// The groups in the registry's sections, in order (E31: sixteen tabs wrapped to three rows at 133%
// zoom, so they are a side list under four headings, as Settings has).
const groupKeys = data.sections.flatMap((s) => s.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
const groupTabs = groupKeys.map((g) => ({
id: g,
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
}));
const groupTabs = data.sections.flatMap((s) =>
s.groups.map((g) => ({
id: g,
group: t(`config.sections.${s.id}`, s.id),
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
testId: `config-tab-${g}`,
})),
);
return (
<>
{/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it
took three lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pb-24 pt-3 max-w-4xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{/* The groups as a side list under their headings, the fields beside it: Settings' own
layout, one pattern for both settings pages (E31). */}
<div className={`${SURFACE.panel} mb-4 flex flex-col overflow-hidden sm:flex-row`}>
<Tabs
idBase={panel.idBase}
orientation="vertical"
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-52 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
tabs={groupTabs}
active={activeGroup ?? ""}
onChange={setTab}
/>
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{activeGroup && (
<div {...panel.panelProps(activeGroup ?? "")} className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
{activeGroup && (
<div {...panel.panelProps(activeGroup ?? "")} className="min-w-0 flex-1 p-4">
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
half-filled config costs verification links, resets and access-request notices
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
>
{t("config.testEmail")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
>
{t("config.testEmail")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
>
{t("config.issuesTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
>
{t("config.issuesTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
>
{t("config.plexTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-2xs text-success mb-2">{t("config.plexMediaOk")}</p>
) : (
<p className="text-2xs text-danger mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
</p>
))}
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<CheckRow
key={s.key}
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="text-sm"
>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</CheckRow>
))}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
>
{t("config.plexTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-2xs text-success mb-2">{t("config.plexMediaOk")}</p>
) : (
<p className="text-2xs text-danger mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
</p>
))}
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<CheckRow
key={s.key}
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="text-sm"
>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</CheckRow>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
)}
</div>
)}
</div>
)}
)}
</div>
)}
</div>
)}
</div>
<DraftSaveBar
variant="floating"