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:
@@ -45,8 +45,8 @@ class ConfigSpec:
|
||||
|
||||
|
||||
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
|
||||
# off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the
|
||||
# admin page's tab order (first appearance of each group), so keep a group's keys together.
|
||||
# off env into the admin UI; the admin page renders it automatically from this list. A group's keys
|
||||
# stay together, in the order its section lists them; a NEW group also needs its place in SECTIONS.
|
||||
SPECS: tuple[ConfigSpec, ...] = (
|
||||
# --- Access / registration ---
|
||||
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
||||
@@ -219,6 +219,16 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
|
||||
)
|
||||
|
||||
# The admin page's sections (E31, the owner's pick): sixteen groups as tabs wrapped to three rows at 133%
|
||||
# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in
|
||||
# exactly one section (test-held), so a new group cannot fall off the page.
|
||||
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("access", ("access", "google", "email")),
|
||||
("youtube", ("youtube", "quota", "backfill", "shorts", "batch", "explore")),
|
||||
("media", ("downloads", "plex", "tmdb")),
|
||||
("system", ("maintenance", "audit", "diagnostics", "issues")),
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||
|
||||
|
||||
@@ -429,4 +439,7 @@ def describe(db: Session) -> dict:
|
||||
item["value"] = get(db, s.key)
|
||||
item["default"] = _default(s)
|
||||
groups.setdefault(s.group, []).append(item)
|
||||
return {"groups": groups, "secrets_manageable": secrets_manageable()}
|
||||
sections = [
|
||||
{"id": sid, "groups": [g for g in members if g in groups]} for sid, members in SECTIONS
|
||||
]
|
||||
return {"groups": groups, "sections": sections, "secrets_manageable": secrets_manageable()}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
|
||||
"""The sysconfig registry: a group's keys stay together, every group has its section, and a closed-set
|
||||
knob is a CHOICE.
|
||||
|
||||
The admin Configuration page used to keep its own hand-written group order, which had already drifted
|
||||
(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
|
||||
tab order: one run of keys per group.
|
||||
(`explore` was missing, so its tab sorted last). The page now follows the registry: SECTIONS orders the
|
||||
groups under their headings (E31), and a group missing there would fall off the page — so each group
|
||||
of SPECS sits in exactly one section.
|
||||
|
||||
A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
|
||||
|
||||
@@ -22,7 +24,7 @@ from app.config import Settings, choices_of
|
||||
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
|
||||
|
||||
|
||||
def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
|
||||
def test_each_group_is_one_run_of_keys():
|
||||
runs = [
|
||||
s.group
|
||||
for i, s in enumerate(sysconfig.SPECS)
|
||||
@@ -32,6 +34,13 @@ def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
|
||||
assert runs[:2] == ["access", "email"]
|
||||
|
||||
|
||||
def test_every_group_sits_in_exactly_one_section():
|
||||
listed = [g for _, members in sysconfig.SECTIONS for g in members]
|
||||
assert len(listed) == len(set(listed)), listed
|
||||
assert set(listed) == {s.group for s in sysconfig.SPECS}
|
||||
assert [sid for sid, _ in sysconfig.SECTIONS] == ["access", "youtube", "media", "system"]
|
||||
|
||||
|
||||
def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
|
||||
assert CHOICE_KEYS == {
|
||||
"search_source",
|
||||
@@ -85,6 +94,14 @@ def test_describe_hands_the_picker_its_options():
|
||||
assert items["quota_daily_budget"]["choices"] == []
|
||||
|
||||
|
||||
def test_describe_lists_every_group_under_its_section_in_order():
|
||||
d = sysconfig.describe(_FakeDb())
|
||||
assert [s["id"] for s in d["sections"]] == ["access", "youtube", "media", "system"]
|
||||
assert d["sections"][0]["groups"] == ["access", "google", "email"]
|
||||
listed = [g for s in d["sections"] for g in s["groups"]]
|
||||
assert sorted(listed) == sorted(d["groups"])
|
||||
|
||||
|
||||
def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
|
||||
sysconfig.set_value(db, "search_source", "API")
|
||||
assert sysconfig.get_str(db, "search_source") == "api"
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
choice("log_level", LEVELS, "INFO"),
|
||||
],
|
||||
},
|
||||
sections: [{ id: "s", groups: ["g"] }],
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
setConfig: (key: string, value: unknown) => setConfig(key, value),
|
||||
|
||||
@@ -46,6 +46,7 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
groups: {
|
||||
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
|
||||
},
|
||||
sections: [{ id: "s", groups: ["g"] }],
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E31 (the owner's pick): Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
|
||||
// are a side list under four headings, as Settings has. The registry owns the order: the groups follow
|
||||
// its sections, not the order the groups object happens to arrive in.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ConfigItem } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const text = (key: string, group: string): ConfigItem => ({
|
||||
key,
|
||||
type: "str",
|
||||
group,
|
||||
secret: false,
|
||||
min: null,
|
||||
max: null,
|
||||
is_set: false,
|
||||
allow_empty: true,
|
||||
choices: [],
|
||||
value: "",
|
||||
default: "",
|
||||
});
|
||||
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminConfig: () =>
|
||||
Promise.resolve({
|
||||
groups: { b: [text("kb", "b")], c: [text("kc", "c")], a: [text("ka", "a")] },
|
||||
sections: [
|
||||
{ id: "one", groups: ["a", "b"] },
|
||||
{ id: "two", groups: ["c"] },
|
||||
],
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
|
||||
|
||||
import ConfigPanel from "./ConfigPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderPanel = () =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConfigPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("Configuration sections (E31)", () => {
|
||||
it("lists the groups in the registry's sections, each heading once above its run", async () => {
|
||||
renderPanel();
|
||||
const list = await screen.findByRole("tablist");
|
||||
expect(list.getAttribute("aria-orientation")).toBe("vertical");
|
||||
// The list's own lines, top to bottom: headings and tabs.
|
||||
const lines = Array.from(list.children).map((el) =>
|
||||
el.getAttribute("role") === "tab" ? `tab:${el.textContent}` : `head:${el.textContent}`,
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
"head:config.sections.one",
|
||||
"tab:config.groups.a",
|
||||
"tab:config.groups.b",
|
||||
"head:config.sections.two",
|
||||
"tab:config.groups.c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens on the first group and switches to the one picked", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("config.fields.ka.label")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "config.groups.c" }));
|
||||
expect(screen.getByText("config.fields.kc.label")).toBeTruthy();
|
||||
expect(screen.queryByText("config.fields.ka.label")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
NavRail,
|
||||
NavRailAction,
|
||||
NavRailDivider,
|
||||
NavRailGroup,
|
||||
NavRailItem,
|
||||
NavRailList,
|
||||
RAIL_ROW,
|
||||
@@ -199,8 +200,28 @@ export default function NavSidebar({
|
||||
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
|
||||
<NavRailList ref={listFade.ref} style={listFade.style}>
|
||||
{userItems.map(renderItem)}
|
||||
{systemItems.length > 0 && <NavRailDivider />}
|
||||
{systemItems.map(renderItem)}
|
||||
{systemItems.length > 0 && (
|
||||
<>
|
||||
<NavRailDivider />
|
||||
{/* The admin pages, one row opening a panel of them (E31): five rows of their own sat
|
||||
below the fold at 133% zoom, behind a fade alone. */}
|
||||
<NavRailGroup
|
||||
testId="nav-admin"
|
||||
icon={Shield}
|
||||
label={t("nav.admin")}
|
||||
shortLabel={t("nav.rail.admin")}
|
||||
showLabel={railLabels}
|
||||
pages={systemItems.map(({ page: p, icon, label }) => ({
|
||||
id: p,
|
||||
icon,
|
||||
label,
|
||||
href: pageHref(p),
|
||||
active: page === p,
|
||||
onSelect: () => setPage(p),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavRailList>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
|
||||
@@ -15,7 +15,9 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import { Section } from "./ui/form";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { Button } from "./ui/Button";
|
||||
@@ -108,34 +110,40 @@ function Overview({ me }: { me: Me }) {
|
||||
<>
|
||||
<Section title={t("stats.sync.myStatus")}>
|
||||
{s ? (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
||||
{s.channels_total}
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
<StatTiles>
|
||||
<StatTile
|
||||
label={t("stats.sync.channels")}
|
||||
hint={t("stats.sync.channelsHint")}
|
||||
value={formatNumber(s.channels_total, lang)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.sync.recentSynced")}
|
||||
hint={t("stats.sync.recentSyncedHint")}
|
||||
>
|
||||
{`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
||||
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||
</SettingRow>
|
||||
value={`${formatNumber(s.channels_recent_synced, lang)}/${formatNumber(s.channels_total, lang)}`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.sync.fullHistory")}
|
||||
hint={t("stats.sync.fullHistoryHint")}
|
||||
value={`${formatNumber(s.channels_deep_done, lang)}/${formatNumber(s.channels_deep_requested, lang)}`}
|
||||
/>
|
||||
{s.deep_pending_count > 0 && (
|
||||
<SettingRow
|
||||
<StatTile
|
||||
label={t("stats.sync.fullHistoryEta")}
|
||||
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||
>
|
||||
{formatEta(t, s.deep_eta_seconds)}
|
||||
</SettingRow>
|
||||
value={formatEta(t, s.deep_eta_seconds)}
|
||||
/>
|
||||
)}
|
||||
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||
{formatNumber(s.my_videos, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||
{formatNumber(s.quota_remaining_today, lang)}
|
||||
</SettingRow>
|
||||
</div>
|
||||
<StatTile
|
||||
label={t("stats.sync.myVideos")}
|
||||
hint={t("stats.sync.myVideosHint")}
|
||||
value={formatNumber(s.my_videos, lang)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.sync.quotaLeft")}
|
||||
hint={t("stats.sync.quotaLeftHint")}
|
||||
value={formatNumber(s.quota_remaining_today, lang)}
|
||||
/>
|
||||
</StatTiles>
|
||||
) : status.isError ? (
|
||||
<StateMessage tone="error" onRetry={() => status.refetch()}>
|
||||
{t("common.loadError")}
|
||||
@@ -148,31 +156,39 @@ function Overview({ me }: { me: Me }) {
|
||||
<Section title={t("stats.sync.apiUsage")}>
|
||||
{usage.data ? (
|
||||
<>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||
{formatNumber(usage.data.today, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.last7d")}>
|
||||
{formatNumber(usage.data.last_7d, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.last30d")}>
|
||||
{formatNumber(usage.data.last_30d, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.allTime")}>
|
||||
{formatNumber(usage.data.all_time, lang)}
|
||||
</SettingRow>
|
||||
</div>
|
||||
<StatTiles>
|
||||
<StatTile
|
||||
label={t("stats.sync.today")}
|
||||
hint={t("stats.sync.todayHint")}
|
||||
value={formatNumber(usage.data.today, lang)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.sync.last7d")}
|
||||
value={formatNumber(usage.data.last_7d, lang)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.sync.last30d")}
|
||||
value={formatNumber(usage.data.last_30d, lang)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.sync.allTime")}
|
||||
value={formatNumber(usage.data.all_time, lang)}
|
||||
/>
|
||||
</StatTiles>
|
||||
{Object.keys(usage.data.by_action).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
||||
<div className="uppercase tracking-wide">{t("stats.sync.byAction")}</div>
|
||||
{Object.entries(usage.data.by_action)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([action, units]) => (
|
||||
<div key={action} className="flex justify-between gap-2">
|
||||
<span>{quotaActionLabel(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4">
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("stats.sync.byAction")}
|
||||
</SectionLabel>
|
||||
<StatBars
|
||||
label={t("stats.sync.byAction")}
|
||||
format={(n) => formatNumber(n, lang)}
|
||||
rows={Object.entries(usage.data.by_action).map(([action, units]) => ({
|
||||
id: action,
|
||||
label: quotaActionLabel(t, action),
|
||||
value: units,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { NavRailAction, NavRailItem } from "./NavRail";
|
||||
import { Activity, Bell, Shield, Users } from "lucide-react";
|
||||
import { NavRailAction, NavRailGroup, NavRailItem } from "./NavRail";
|
||||
|
||||
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
|
||||
|
||||
@@ -123,3 +123,86 @@ describe("NavRailAction", () => {
|
||||
expect(src).not.toMatch(/<IconButton[\s>]/);
|
||||
});
|
||||
});
|
||||
|
||||
// E31 (the owner's pick): the five admin rows sat below the fold at 133% zoom behind a fade alone, so
|
||||
// they are one "Admin" row opening a panel of them. The panel's pages stay links, as the rail's are.
|
||||
describe("NavRailGroup", () => {
|
||||
const pages = (active: string | null, onSelect = vi.fn()) =>
|
||||
[
|
||||
{ id: "scheduler", icon: Activity, label: "Scheduler" },
|
||||
{ id: "users", icon: Users, label: "Users" },
|
||||
].map((p) => ({ ...p, href: `/?page=${p.id}`, active: p.id === active, onSelect }));
|
||||
|
||||
it("is one row, current while any of its pages is, opening a panel of links", () => {
|
||||
render(
|
||||
<NavRailGroup
|
||||
icon={Shield}
|
||||
label="Administration"
|
||||
shortLabel="Admin"
|
||||
showLabel={false}
|
||||
pages={pages("users")}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Administration" });
|
||||
expect(row.getAttribute("aria-current")).toBe("page");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
fireEvent.click(row);
|
||||
// jsdom never positions the panel, so it stays visibility:hidden and nameless to the role query.
|
||||
const panel = screen.getByRole("dialog", { hidden: true });
|
||||
expect(panel.getAttribute("aria-label")).toBe("Administration");
|
||||
const links = Array.from(panel.querySelectorAll("a"));
|
||||
expect(links.map((a) => [a.textContent, a.getAttribute("href")])).toEqual([
|
||||
["Scheduler", "/?page=scheduler"],
|
||||
["Users", "/?page=users"],
|
||||
]);
|
||||
expect(links.map((a) => a.getAttribute("aria-current"))).toEqual([null, "page"]);
|
||||
});
|
||||
|
||||
it("is not current while none of its pages is", () => {
|
||||
render(
|
||||
<NavRailGroup
|
||||
icon={Shield}
|
||||
label="Administration"
|
||||
shortLabel="Admin"
|
||||
showLabel
|
||||
pages={pages(null)}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Admin" });
|
||||
expect(row.getAttribute("aria-current")).toBeNull();
|
||||
expect(row.className).not.toContain("bg-accent/15");
|
||||
});
|
||||
|
||||
it("a plain click goes to the page and closes the panel; a middle or Ctrl click is the browser's", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<NavRailGroup
|
||||
icon={Shield}
|
||||
label="Administration"
|
||||
shortLabel="Admin"
|
||||
showLabel={false}
|
||||
pages={pages(null, onSelect)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||
const link = () =>
|
||||
screen.getByRole("dialog", { hidden: true }).querySelector<HTMLAnchorElement>("a")!;
|
||||
const leftToBrowser: boolean[] = [];
|
||||
const record = (e: Event) => {
|
||||
leftToBrowser.push(!e.defaultPrevented);
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", record);
|
||||
try {
|
||||
fireEvent.click(link(), { button: 1 });
|
||||
fireEvent.click(link(), { button: 0, ctrlKey: true });
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
fireEvent.click(link(), { button: 0 });
|
||||
} finally {
|
||||
window.removeEventListener("click", record);
|
||||
}
|
||||
expect(leftToBrowser).toEqual([true, true, false]);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog", { hidden: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
import { useState, type CSSProperties, type ReactNode, type Ref } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -171,3 +173,115 @@ export function NavRailAction({
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page of a `NavRailGroup`'s panel. */
|
||||
export interface NavRailGroupPage {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
/** The page's full name, written out in the panel. */
|
||||
label: string;
|
||||
href: string;
|
||||
active: boolean;
|
||||
/** The in-app navigation, run only for a plain left click. */
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
/** One rail row standing for a set of rare pages (E31, the owner's pick: the admin pages), opening a
|
||||
* panel of them to its right. The rail fits a 133–150% zoom without a scroll nobody sees: five admin
|
||||
* rows sat below the fold behind a fade alone. The row is "you are here" while any of its pages is.
|
||||
* The panel's pages are real links, as the rail's own rows are (a middle-click opens a tab), so it is a
|
||||
* `dialog` popover with its own Tab order, not a roving menu of buttons. */
|
||||
export function NavRailGroup({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortLabel,
|
||||
showLabel,
|
||||
pages,
|
||||
testId,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
/** The group's full name: the tooltip, the row's and the panel's accessible name, the panel heading. */
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
showLabel: boolean;
|
||||
pages: readonly NavRailGroupPage[];
|
||||
testId?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "right-start",
|
||||
offset: 8,
|
||||
});
|
||||
const active = pages.some((p) => p.active);
|
||||
return (
|
||||
<>
|
||||
<Tooltip hint={label} side="right" always openDelay={350}>
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
data-testid={testId}
|
||||
aria-label={showLabel ? shortLabel : label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
RAIL_ROW,
|
||||
showLabel ? "py-1.5" : "h-9",
|
||||
active ? "bg-accent/15 text-accent" : "text-muted state-layer hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-1.5 bottom-1.5 -left-1 w-0.75 rounded-r-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<Icon className={RAIL_GLYPH} aria-hidden="true" />
|
||||
{showLabel && (
|
||||
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
|
||||
{shortLabel}
|
||||
</span>
|
||||
)}
|
||||
</Pressable>
|
||||
</Tooltip>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className={`${SURFACE.popover} w-56 p-1.5 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div className="px-2 pt-1 pb-1.5 text-2xs font-semibold tracking-wide text-muted uppercase">
|
||||
{label}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pages.map(({ id, icon: PageIcon, label: name, href, active: here, onSelect }) => (
|
||||
<li key={id}>
|
||||
<a
|
||||
data-testid={`${testId ?? "group"}-${id}`}
|
||||
href={href}
|
||||
aria-current={here ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
onSelect();
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition touch-target",
|
||||
here ? "bg-accent/15 text-accent" : "text-fg state-layer",
|
||||
)}
|
||||
>
|
||||
<PageIcon
|
||||
className={clsx(RAIL_GLYPH, "shrink-0", !here && "text-muted")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ domain-free.
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
| area | components |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E31 (the owner's pick for the Statistics overview): a figure's name sits by its number in a tile, and a
|
||||
// breakdown is horizontal bars, largest first, so what spends the most is seen before a number is read.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { StatBars, StatTile, StatTiles } from "./StatTiles";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("StatTile", () => {
|
||||
it("holds its value and its name together", () => {
|
||||
render(
|
||||
<StatTiles>
|
||||
<StatTile testId="t" label="Channels" value="398" />
|
||||
</StatTiles>,
|
||||
);
|
||||
expect(screen.getByTestId("t").textContent).toBe("398Channels");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatBars", () => {
|
||||
const rows = [
|
||||
{ id: "b", label: "Delete playlist", value: 100 },
|
||||
{ id: "z", label: "Live search", value: 0 },
|
||||
{ id: "a", label: "Sync playlists", value: 400 },
|
||||
];
|
||||
|
||||
it("lists the largest first, each bar its share of the largest, each number formatted", () => {
|
||||
render(<StatBars label="By action" rows={rows} format={(n) => `${n} u`} />);
|
||||
const items = Array.from(screen.getByRole("list", { name: "By action" }).children);
|
||||
expect(items.map((li) => li.textContent)).toEqual([
|
||||
"Sync playlists400 u",
|
||||
"Delete playlist100 u",
|
||||
"Live search0 u",
|
||||
]);
|
||||
const widths = items.map((li) => li.querySelector<HTMLElement>("[data-bar]")!.style.width);
|
||||
expect(widths.slice(0, 2)).toEqual(["100%", "25%"]);
|
||||
});
|
||||
|
||||
it("draws no bar for a zero", () => {
|
||||
render(<StatBars label="By action" rows={rows} format={String} />);
|
||||
const zero = screen.getByText("Live search").closest("li")!;
|
||||
expect(zero.querySelector<HTMLElement>("[data-bar]")!.style.width).toBe("0px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HintLabel } from "./form";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// E31 (the owner's pick for the Statistics overview): label…value rows stretched across a wide column
|
||||
// made the eye travel from a name to its number. A tile keeps the two together, a row of them is read at
|
||||
// a glance, and a breakdown is a set of horizontal bars, so what spends the most is seen before a number
|
||||
// is read.
|
||||
|
||||
/** A row of number tiles, as many to a line as fit. */
|
||||
export function StatTiles({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-2">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One figure: its value large, its name under it (the name's hint on a resting pointer). */
|
||||
export function StatTile({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
/** Already formatted for the reader's language. */
|
||||
value: ReactNode;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid={testId} className={`${SURFACE.card} flex flex-col gap-0.5 px-3 py-2.5`}>
|
||||
<span className="text-xl leading-tight font-semibold tabular-nums">{value}</span>
|
||||
<span className="text-xs text-muted">
|
||||
<HintLabel hint={hint}>{label}</HintLabel>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatBar {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** A breakdown as horizontal bars, largest first, each as long as its share of the largest. */
|
||||
export function StatBars({
|
||||
rows,
|
||||
format,
|
||||
label,
|
||||
}: {
|
||||
rows: readonly StatBar[];
|
||||
/** The number as the reader's language writes it. */
|
||||
format: (n: number) => string;
|
||||
/** The list's accessible name. */
|
||||
label: string;
|
||||
}) {
|
||||
const sorted = [...rows].sort((a, b) => b.value - a.value);
|
||||
const max = Math.max(1, ...sorted.map((r) => r.value));
|
||||
return (
|
||||
// One grid for the whole list (each row a subgrid), so the name column is as wide as the longest
|
||||
// name and every bar starts at the same line.
|
||||
<ul
|
||||
aria-label={label}
|
||||
className="grid grid-cols-[minmax(0,max-content)_minmax(6rem,1fr)_auto] gap-x-3 gap-y-1.5"
|
||||
>
|
||||
{sorted.map((r) => (
|
||||
<li key={r.id} className="col-span-3 grid grid-cols-subgrid items-center text-sm">
|
||||
<span className="truncate text-muted" title={r.label}>
|
||||
{r.label}
|
||||
</span>
|
||||
<span aria-hidden="true" className="h-2 overflow-hidden rounded-full bg-border/40">
|
||||
<span
|
||||
data-bar
|
||||
className="block h-full rounded-full bg-accent"
|
||||
// A zero draws no bar: a sliver would claim some use.
|
||||
style={{ width: r.value > 0 ? `${Math.max(2, (r.value / max) * 100)}%` : "0" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="tabular-nums">{format(r.value)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useRef, type KeyboardEvent } from "react";
|
||||
import { Fragment, useId, useRef, type KeyboardEvent } from "react";
|
||||
import { panelId, tabId } from "./tabPanel";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import CountBadge from "./CountBadge";
|
||||
@@ -24,6 +24,9 @@ export interface TabDef {
|
||||
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
||||
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
|
||||
testId?: string; // optional data-testid, to preserve existing e2e hooks
|
||||
/** The vertical rail's heading this tab sits under (E31: Configuration's sixteen groups under four).
|
||||
* Drawn above the first tab of each run; tabs of one heading stay together. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
@@ -87,40 +90,52 @@ export default function Tabs({
|
||||
onKeyDown={onKeyDown}
|
||||
className={wrapCls}
|
||||
>
|
||||
{tabs.map((tb) => {
|
||||
{tabs.map((tb, i) => {
|
||||
const on = tb.id === active;
|
||||
const Icon = tb.icon;
|
||||
const heading = vertical && tb.group && tb.group !== tabs[i - 1]?.group ? tb.group : null;
|
||||
return (
|
||||
<button
|
||||
key={tb.id}
|
||||
ref={(node) => {
|
||||
if (node) refs.current.set(tb.id, node);
|
||||
else refs.current.delete(tb.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={tabId(base, tb.id)}
|
||||
aria-selected={on}
|
||||
aria-controls={idBase ? panelId(idBase) : undefined}
|
||||
tabIndex={tb.id === stop ? 0 : -1}
|
||||
data-testid={tb.testId}
|
||||
onClick={() => onChange(tb.id)}
|
||||
className={
|
||||
tone === "media"
|
||||
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||
}`
|
||||
: `pill touch-target items-center rounded-lg px-3 text-sm ${
|
||||
vertical
|
||||
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
|
||||
: "inline-flex gap-1.5 py-1.5"
|
||||
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
|
||||
}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
{tb.label}
|
||||
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
|
||||
</button>
|
||||
<Fragment key={tb.id}>
|
||||
{/* A heading, not a tab: no role a tab list would have to own, its text still read. Only
|
||||
on the side rail; the phone's scrolling row has no room for it. */}
|
||||
{heading && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="hidden px-3 pt-3 pb-1 text-2xs font-semibold tracking-wide text-muted uppercase first:pt-1 sm:block"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) refs.current.set(tb.id, node);
|
||||
else refs.current.delete(tb.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={tabId(base, tb.id)}
|
||||
aria-selected={on}
|
||||
aria-controls={idBase ? panelId(idBase) : undefined}
|
||||
tabIndex={tb.id === stop ? 0 : -1}
|
||||
data-testid={tb.testId}
|
||||
onClick={() => onChange(tb.id)}
|
||||
className={
|
||||
tone === "media"
|
||||
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||
}`
|
||||
: `pill touch-target items-center rounded-lg px-3 text-sm ${
|
||||
vertical
|
||||
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
|
||||
: "inline-flex gap-1.5 py-1.5"
|
||||
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
|
||||
}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
{tb.label}
|
||||
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function Section({
|
||||
|
||||
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
|
||||
* No-op styling when there's no hint, so it's safe to use unconditionally. */
|
||||
function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
|
||||
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
<span
|
||||
|
||||
@@ -46,7 +46,10 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"ui/ActionMenu.tsx": [1, "a menu row"],
|
||||
"ui/BackToTop.tsx": [1, "a control"],
|
||||
"ui/CommandPalette.tsx": [1, "a command row"],
|
||||
"ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"],
|
||||
"ui/NavRail.tsx": [
|
||||
2,
|
||||
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link (E31)",
|
||||
],
|
||||
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||
"ui/SidePanel.tsx": [2, "panel controls"],
|
||||
"ui/tableHeader.tsx": [1, "a column's sort header"],
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.",
|
||||
"loading": "Loading configuration…",
|
||||
"groups": {
|
||||
"sections": {
|
||||
"access": "Access",
|
||||
"youtube": "YouTube",
|
||||
"media": "Media",
|
||||
"system": "System"
|
||||
},
|
||||
"groups": {
|
||||
"access": "Registration",
|
||||
"email": "Email / SMTP",
|
||||
"youtube": "YouTube API",
|
||||
"google": "Google sign-in",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"primary": "Primary navigation",
|
||||
"admin": "Administration",
|
||||
"skipToContent": "Skip to content",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
@@ -20,6 +21,7 @@
|
||||
"config": "Config",
|
||||
"users": "Users",
|
||||
"audit": "Audit",
|
||||
"admin": "Admin",
|
||||
"logs": "Logs",
|
||||
"settings": "Settings"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.",
|
||||
"loading": "Konfiguráció betöltése…",
|
||||
"groups": {
|
||||
"sections": {
|
||||
"access": "Hozzáférés",
|
||||
"youtube": "YouTube",
|
||||
"media": "Média",
|
||||
"system": "Rendszer"
|
||||
},
|
||||
"groups": {
|
||||
"access": "Regisztráció",
|
||||
"email": "E-mail / SMTP",
|
||||
"youtube": "YouTube API",
|
||||
"google": "Google bejelentkezés",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"primary": "Fő navigáció",
|
||||
"admin": "Adminisztráció",
|
||||
"skipToContent": "Ugrás a tartalomra",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
@@ -20,6 +21,7 @@
|
||||
"config": "Konfig",
|
||||
"users": "Felhasználók",
|
||||
"audit": "Eseménynapló",
|
||||
"admin": "Admin",
|
||||
"logs": "Napló",
|
||||
"settings": "Beállítások"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface ConfigItem {
|
||||
}
|
||||
export interface SystemConfigData {
|
||||
groups: Record<string, ConfigItem[]>;
|
||||
/** The page's headings, in order, each with its groups in order; every group is in one (E31). */
|
||||
sections: { id: string; groups: string[] }[];
|
||||
secrets_manageable: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user