diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index eab9b47b..f8eaa900 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -14,6 +14,7 @@ import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; import { useWizard } from "./WizardProvider"; import { usePrefs } from "./PrefsProvider"; +import Tabs from "./Tabs"; import { useMe } from "../lib/useMe"; // The Settings page edits server-persisted preferences. Each change applies locally for instant @@ -61,25 +62,18 @@ export default function SettingsPanel() {
{/* Vertical tab rail — never wraps; active is a clear accent pill. */} - + ({ + id: tabItem.id, + label: t(`settings.tabs.${tabItem.id}`), + icon: tabItem.icon, + testId: `settings-tab-${tabItem.id}`, + }))} + /> {/* Render only the active tab so the panel sizes to its actual content. (Stacking every tab in one grid cell made the whole panel as tall as the tallest tab — diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 55ffec50..9bb4c9d0 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -10,6 +10,7 @@ import { LS, useAccountPersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; import { LoadingState, StateMessage } from "./QueryState"; import FilterChipGroup from "./FilterChipGroup"; +import Tabs from "./Tabs"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; @@ -31,18 +32,14 @@ export default function Stats() {
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */} {isAdmin && ( -
- {(["overview", "system"] as StatsTab[]).map((id) => ( - - ))} -
+ ({ + id, + label: t(`stats.tabs.${id}`), + }))} + /> )} {tab === "system" && isAdmin ? : } diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 833dd8a5..b2480f47 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,13 +1,17 @@ +import { type LucideIcon } from "lucide-react"; import { useAccountPersistedState } from "../lib/storage"; -// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…). -// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were, -// per the app's "persist UI state across reload" convention. +// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings +// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives +// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted +// to localStorage so a reload keeps the user where they were. export interface TabDef { id: string; label: string; 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 } /** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't @@ -18,28 +22,38 @@ export default function Tabs({ tabs, active, onChange, - className = "flex flex-wrap gap-1 mb-4", + orientation = "horizontal", + className, }: { tabs: TabDef[]; active: string; onChange: (id: string) => void; - // Override the row wrapper — e.g. the channel manager embeds the rail in a justify-between header - // and drops the default bottom margin. Omit for the standalone-row default. + // "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row. + orientation?: "horizontal" | "vertical"; + // Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between + // header, or the settings rail adds a width + right border. Omit for the orientation default. className?: string; }) { + const vertical = orientation === "vertical"; + const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4"); return ( -
+
{tabs.map((tb) => { const on = tb.id === active; + const Icon = tb.icon; return (