From be156fc52bf82f0a39bd126d038c6c00bff506de Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 00:07:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20E31=20S4b=20=E2=80=94=20the=20admin?= =?UTF-8?q?=20pages=20behind=20one=20rail=20row,=20Configuration=20as=20a?= =?UTF-8?q?=20sectioned=20list,=20Statistics=20as=20tiles=20and=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/app/sysconfig.py | 19 +- backend/tests/test_sysconfig_registry.py | 25 +- .../ConfigPanel.choice.dom.test.tsx | 1 + .../components/ConfigPanel.field.dom.test.tsx | 1 + .../ConfigPanel.sections.dom.test.tsx | 92 +++++++ frontend/src/components/ConfigPanel.tsx | 247 +++++++++--------- frontend/src/components/NavSidebar.tsx | 25 +- frontend/src/components/Stats.tsx | 110 ++++---- .../src/components/ui/NavRail.dom.test.tsx | 87 +++++- frontend/src/components/ui/NavRail.tsx | 116 +++++++- frontend/src/components/ui/README.md | 14 +- .../src/components/ui/StatTiles.dom.test.tsx | 46 ++++ frontend/src/components/ui/StatTiles.tsx | 85 ++++++ frontend/src/components/ui/Tabs.tsx | 79 +++--- frontend/src/components/ui/form.tsx | 2 +- .../ui/pressableSelectable.contract.test.ts | 5 +- frontend/src/i18n/locales/en/config.json | 8 +- frontend/src/i18n/locales/en/nav.json | 2 + frontend/src/i18n/locales/hu/config.json | 8 +- frontend/src/i18n/locales/hu/nav.json | 2 + frontend/src/lib/api/admin.ts | 2 + 21 files changed, 753 insertions(+), 223 deletions(-) create mode 100644 frontend/src/components/ConfigPanel.sections.dom.test.tsx create mode 100644 frontend/src/components/ui/StatTiles.dom.test.tsx create mode 100644 frontend/src/components/ui/StatTiles.tsx diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index d8e3f407..a1ddc43b 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -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()} diff --git a/backend/tests/test_sysconfig_registry.py b/backend/tests/test_sysconfig_registry.py index 7a25e5d6..d933ec30 100644 --- a/backend/tests/test_sysconfig_registry.py +++ b/backend/tests/test_sysconfig_registry.py @@ -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" diff --git a/frontend/src/components/ConfigPanel.choice.dom.test.tsx b/frontend/src/components/ConfigPanel.choice.dom.test.tsx index d22dab5f..cc7b8ff1 100644 --- a/frontend/src/components/ConfigPanel.choice.dom.test.tsx +++ b/frontend/src/components/ConfigPanel.choice.dom.test.tsx @@ -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), diff --git a/frontend/src/components/ConfigPanel.field.dom.test.tsx b/frontend/src/components/ConfigPanel.field.dom.test.tsx index 9d697748..49e1c407 100644 --- a/frontend/src/components/ConfigPanel.field.dom.test.tsx +++ b/frontend/src/components/ConfigPanel.field.dom.test.tsx @@ -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, }), }, diff --git a/frontend/src/components/ConfigPanel.sections.dom.test.tsx b/frontend/src/components/ConfigPanel.sections.dom.test.tsx new file mode 100644 index 00000000..388634cf --- /dev/null +++ b/frontend/src/components/ConfigPanel.sections.dom.test.tsx @@ -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(); + 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(); + 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( + + + , + ); + +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(); + }); +}); diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index ddbdf971..bc43693f 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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 ; } - 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. */} - -
+
+

{t("config.intro")}

+ {/* The groups as a side list under their headings, the fields beside it: Settings' own + layout, one pattern for both settings pages (E31). */} +
-
- -
-

{t("config.intro")}

- {activeGroup && ( -
-
- {(data.groups[activeGroup] ?? []).map((item) => ( - setDraft((d) => ({ ...d, [item.key]: v }))} - onResetField={() => { - if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); - else setDraft((d) => ({ ...d, [item.key]: "" })); - }} - /> - ))} -
+ {activeGroup && ( +
+
+ {(data.groups[activeGroup] ?? []).map((item) => ( + setDraft((d) => ({ ...d, [item.key]: v }))} + onResetField={() => { + if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); + else setDraft((d) => ({ ...d, [item.key]: "" })); + }} + /> + ))} +
- {activeGroup === "email" && ( -
- {/* The one place an operator can see that mail is off. Sending is fail-soft, so a + {activeGroup === "email" && ( +
+ {/* 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 && {t("config.emailOff")}} - - - - {dirty &&

{t("config.testDirty")}

} -
- )} + {!me.email_configured && {t("config.emailOff")}} + + + + {dirty &&

{t("config.testDirty")}

} +
+ )} - {activeGroup === "issues" && ( -
- - - - {dirty &&

{t("config.testDirty")}

} -
- )} + {activeGroup === "issues" && ( +
+ + + + {dirty &&

{t("config.testDirty")}

} +
+ )} - {activeGroup === "plex" && ( -
- - - - {dirty &&

{t("config.testDirty")}

} - {plexResult && ( -
-

- {t("config.plexConnected", { - name: plexResult.server_name, - version: plexResult.version ?? "", - })} -

- {plexResult.media_check?.checked && - (plexResult.media_check.ok ? ( -

{t("config.plexMediaOk")}

- ) : ( -

- {t("config.plexMediaMissing", { - path: plexResult.media_check.local_path ?? "", - })} -

- ))} -

{t("config.plexPickLibraries")}

-
- {plexResult.sections.map((s) => ( - togglePlexLib(s.key)} - className="text-sm" - > - {s.title} - - {s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")} - {s.count != null ? ` · ${s.count}` : ""} - - - ))} + {activeGroup === "plex" && ( +
+ + + + {dirty &&

{t("config.testDirty")}

} + {plexResult && ( +
+

+ {t("config.plexConnected", { + name: plexResult.server_name, + version: plexResult.version ?? "", + })} +

+ {plexResult.media_check?.checked && + (plexResult.media_check.ok ? ( +

{t("config.plexMediaOk")}

+ ) : ( +

+ {t("config.plexMediaMissing", { + path: plexResult.media_check.local_path ?? "", + })} +

+ ))} +

{t("config.plexPickLibraries")}

+
+ {plexResult.sections.map((s) => ( + togglePlexLib(s.key)} + className="text-sm" + > + {s.title} + + {s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")} + {s.count != null ? ` · ${s.count}` : ""} + + + ))} +
+

{t("config.plexPickHint")}

-

{t("config.plexPickHint")}

-
- )} -
- )} -
- )} + )} +
+ )} +
+ )} +
{userItems.map(renderItem)} - {systemItems.length > 0 && } - {systemItems.map(renderItem)} + {systemItems.length > 0 && ( + <> + + {/* 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. */} + ({ + id: p, + icon, + label, + href: pageHref(p), + active: page === p, + onSelect: () => setPage(p), + }))} + /> + + )}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index ab825c4d..ad70f22d 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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 }) { <>
{s ? ( -
- - {s.channels_total} - - + + - {`${s.channels_recent_synced}/${s.channels_total}`} - - - {`${s.channels_deep_done}/${s.channels_deep_requested}`} - + value={`${formatNumber(s.channels_recent_synced, lang)}/${formatNumber(s.channels_total, lang)}`} + /> + {s.deep_pending_count > 0 && ( - - {formatEta(t, s.deep_eta_seconds)} - + value={formatEta(t, s.deep_eta_seconds)} + /> )} - - {formatNumber(s.my_videos, lang)} - - - {formatNumber(s.quota_remaining_today, lang)} - -
+ + + ) : status.isError ? ( status.refetch()}> {t("common.loadError")} @@ -148,31 +156,39 @@ function Overview({ me }: { me: Me }) {
{usage.data ? ( <> -
- - {formatNumber(usage.data.today, lang)} - - - {formatNumber(usage.data.last_7d, lang)} - - - {formatNumber(usage.data.last_30d, lang)} - - - {formatNumber(usage.data.all_time, lang)} - -
+ + + + + + {Object.keys(usage.data.by_action).length > 0 && ( -
-
{t("stats.sync.byAction")}
- {Object.entries(usage.data.by_action) - .sort((a, b) => b[1] - a[1]) - .map(([action, units]) => ( -
- {quotaActionLabel(t, action)} - {formatNumber(units, lang)} -
- ))} +
+ + {t("stats.sync.byAction")} + + formatNumber(n, lang)} + rows={Object.entries(usage.data.by_action).map(([action, units]) => ({ + id: action, + label: quotaActionLabel(t, action), + value: units, + }))} + />
)} diff --git a/frontend/src/components/ui/NavRail.dom.test.tsx b/frontend/src/components/ui/NavRail.dom.test.tsx index 0ed36d31..c5dac0fc 100644 --- a/frontend/src/components/ui/NavRail.dom.test.tsx +++ b/frontend/src/components/ui/NavRail.dom.test.tsx @@ -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(/]/); }); }); + +// 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( + , + ); + 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( + , + ); + 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( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Administration" })); + const link = () => + screen.getByRole("dialog", { hidden: true }).querySelector("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(); + }); +}); diff --git a/frontend/src/components/ui/NavRail.tsx b/frontend/src/components/ui/NavRail.tsx index 04b65eb8..17446264 100644 --- a/frontend/src/components/ui/NavRail.tsx +++ b/frontend/src/components/ui/NavRail.tsx @@ -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({ ); } + +/** 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 ( + <> + + + {active && ( + + + +
+ {label} +
+ +
+ + ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 7c85e7d9..370e65de 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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 ` + + {/* 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 && ( +
+ {heading} +
+ )} + +
); })}
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index 1ff23784..3102c346 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -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 ( = { "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"], diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index 242582dd..4ae176cf 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -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", diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json index f847f2bd..c2c0802d 100644 --- a/frontend/src/i18n/locales/en/nav.json +++ b/frontend/src/i18n/locales/en/nav.json @@ -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" } diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 75cff01b..01f20190 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index bb5e07fb..767bb022 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -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" } diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 7441b2ce..edeae591 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -64,6 +64,8 @@ export interface ConfigItem { } export interface SystemConfigData { groups: Record; + /** 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; }