feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5)
This commit is contained in:
+4
-2
@@ -187,10 +187,12 @@ Also in force:
|
|||||||
dissolves as it slides under. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
|
dissolves as it slides under. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
|
||||||
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
|
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
|
||||||
are the content) and not on buttons (it becomes a dated gloss).
|
are the content) and not on buttons (it becomes a dated gloss).
|
||||||
- **Nav rail (B7):** a `NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
|
- **Nav rail (B7):** the `ui/NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
|
||||||
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
|
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
|
||||||
height, never width, so each module has a short rail label that fits, and the full name stays in the
|
height, never width, so each module has a short rail label that fits, and the full name stays in the
|
||||||
tooltip. No wide padding around the icons.
|
tooltip. No wide padding around the icons. The switch is Settings → Appearance → "Labels in the side
|
||||||
|
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
|
||||||
|
unread count is part of the name, not only drawn.
|
||||||
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
|
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
|
||||||
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
|
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
|
||||||
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
|
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function StartDialog() {
|
|||||||
onChange={setLevel}
|
onChange={setLevel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
|
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
|
||||||
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
||||||
<p>{t(`recorder.start.what.${level}`)}</p>
|
<p>{t(`recorder.start.what.${level}`)}</p>
|
||||||
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useNavigationActions } from "./navigationContext";
|
|||||||
import { usePlexActions } from "./plexContext";
|
import { usePlexActions } from "./plexContext";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// The Home surface (E24) — "Mit nézzek most?".
|
// The Home surface (E24) — "Mit nézzek most?".
|
||||||
//
|
//
|
||||||
@@ -257,7 +258,7 @@ function ShelfTile({
|
|||||||
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
||||||
// alive to all three input paths.
|
// alive to all three input paths.
|
||||||
aria-disabled={!card.next}
|
aria-disabled={!card.next}
|
||||||
className="block w-full rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
|
className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`}
|
||||||
>
|
>
|
||||||
<span className="block aspect-video bg-card overflow-hidden">
|
<span className="block aspect-video bg-card overflow-hidden">
|
||||||
{card.thumb ? (
|
{card.thumb ? (
|
||||||
@@ -305,7 +306,7 @@ function Hero({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
{...pinMenu.triggerProps(card)}
|
{...pinMenu.triggerProps(card)}
|
||||||
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
|
className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onClick={() => onPlay(card)}
|
onClick={() => onPlay(card)}
|
||||||
@@ -743,7 +744,7 @@ export default function Home() {
|
|||||||
) : (
|
) : (
|
||||||
// Honest: nothing in the library fits what was asked. Offering something that does not fit
|
// Honest: nothing in the library fits what was asked. Offering something that does not fit
|
||||||
// would make the one control on this page meaningless.
|
// would make the one control on this page meaningless.
|
||||||
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
|
<section className={`${SURFACE.panel} mb-6 p-4`}>
|
||||||
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
|
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
|
||||||
a re-run of something that failed. Nothing failed — the library simply has no answer
|
a re-run of something that failed. Nothing failed — the library simply has no answer
|
||||||
this short, and the honest offer is to ask a different question. */}
|
this short, and the honest offer is to ask a different question. */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
|||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import { Textarea, Input } from "./ui/Input";
|
import { Textarea, Input } from "./ui/Input";
|
||||||
import { Checkbox } from "./ui/NativeControls";
|
import { Checkbox } from "./ui/NativeControls";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
|
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
|
||||||
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
||||||
@@ -627,7 +628,7 @@ function Attachments({
|
|||||||
const logRow = (source: Source, icon: React.ReactNode) => {
|
const logRow = (source: Source, icon: React.ReactNode) => {
|
||||||
const [kept, total] = counts[source];
|
const [kept, total] = counts[source];
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5">
|
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
|
||||||
{icon}
|
{icon}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@@ -657,7 +658,7 @@ function Attachments({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4">
|
<div className={`${SURFACE.card} flex flex-col gap-3 min-w-0 p-3 md:p-4`}>
|
||||||
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
|
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
|
||||||
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
||||||
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
||||||
@@ -669,7 +670,7 @@ function Attachments({
|
|||||||
{shots.map((shot, i) => (
|
{shots.map((shot, i) => (
|
||||||
<div
|
<div
|
||||||
key={shot.id}
|
key={shot.id}
|
||||||
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2"
|
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={api.debugScreenshotUrl(session.id, shot.id)}
|
src={api.debugScreenshotUrl(session.id, shot.id)}
|
||||||
|
|||||||
@@ -11,17 +11,24 @@ import * as e2ee from "../lib/e2ee";
|
|||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
import {
|
||||||
|
moduleDef,
|
||||||
|
moduleLabelKey,
|
||||||
|
moduleOrder,
|
||||||
|
moduleRailKey,
|
||||||
|
SYSTEM_PAGES,
|
||||||
|
} from "../lib/modules";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import AvatarImg from "./ui/Avatar";
|
import AvatarImg from "./ui/Avatar";
|
||||||
import Tooltip from "./ui/Tooltip";
|
|
||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import { NavRail, NavRailDivider, NavRailItem, RAIL_ROW } from "./ui/NavRail";
|
||||||
|
import { usePrefs } from "./prefsContext";
|
||||||
|
|
||||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||||
@@ -157,65 +164,38 @@ export default function NavSidebar({
|
|||||||
.filter((p) => sys.has(p))
|
.filter((p) => sys.has(p))
|
||||||
.map((p) => ({ page: p, ...META(p) }));
|
.map((p) => ({ page: p, ...META(p) }));
|
||||||
|
|
||||||
const rowBase =
|
const { railLabels } = usePrefs();
|
||||||
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
|
||||||
const name = displayName(me);
|
const name = displayName(me);
|
||||||
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
|
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
|
||||||
const roleLabel = t(`nav.role.${roleKey(me)}`);
|
const roleLabel = t(`nav.role.${roleKey(me)}`);
|
||||||
|
|
||||||
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
|
const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
|
||||||
const active = page === p;
|
<NavRailItem
|
||||||
// On the active row the background is the accent colour, so a same-accent badge would be
|
key={p}
|
||||||
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
|
testId={`nav-${p}`}
|
||||||
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
|
icon={icon}
|
||||||
const show = !!badge && badge > 0;
|
label={label}
|
||||||
return (
|
shortLabel={t(moduleRailKey[p])}
|
||||||
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
|
showLabel={railLabels}
|
||||||
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
|
active={page === p}
|
||||||
<Pressable
|
badge={badge}
|
||||||
data-testid={`nav-${p}`}
|
onClick={() => setPage(p)}
|
||||||
onClick={() => setPage(p)}
|
/>
|
||||||
aria-label={label}
|
);
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
className={`${rowBase} justify-center px-0 relative is-current`}
|
|
||||||
>
|
|
||||||
<span className="relative shrink-0">
|
|
||||||
<Icon className="w-4.5 h-4.5" />
|
|
||||||
{/* A numeric badge centred on a circle at the icon corner; the ring matches the row
|
|
||||||
background so it reads cleanly whether the row is active or not. */}
|
|
||||||
{show && (
|
|
||||||
<span
|
|
||||||
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
|
|
||||||
active ? "ring-accent" : "ring-bg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{badge > 9 ? "9+" : badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Pressable>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows.
|
// Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
|
||||||
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
|
// labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
|
||||||
<div className="relative shrink-0 w-14">
|
// against its right edge.
|
||||||
<nav
|
<div className="relative shrink-0 w-15">
|
||||||
className={`${SURFACE.panel} absolute top-0 bottom-0 left-0 z-rail flex flex-col py-3 my-3 w-14 px-2`}
|
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
|
||||||
aria-label={t("nav.primary")}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={listFade.ref}
|
ref={listFade.ref}
|
||||||
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
|
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
|
||||||
style={listFade.style}
|
style={listFade.style}
|
||||||
>
|
>
|
||||||
{userItems.map(renderItem)}
|
{userItems.map(renderItem)}
|
||||||
{systemItems.length > 0 && (
|
{systemItems.length > 0 && <NavRailDivider />}
|
||||||
// A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
|
|
||||||
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
|
|
||||||
)}
|
|
||||||
{systemItems.map(renderItem)}
|
{systemItems.map(renderItem)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,17 +219,15 @@ export default function NavSidebar({
|
|||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-5 h-5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
|
<NavRailItem
|
||||||
<Pressable
|
testId="nav-settings"
|
||||||
data-testid="nav-settings"
|
icon={Settings}
|
||||||
onClick={() => setPage("settings")}
|
label={t("header.account.settings")}
|
||||||
aria-label={t("header.account.settings")}
|
shortLabel={t(moduleRailKey.settings)}
|
||||||
aria-current={page === "settings" ? "page" : undefined}
|
showLabel={railLabels}
|
||||||
className={`${rowBase} justify-center px-0 is-current`}
|
active={page === "settings"}
|
||||||
>
|
onClick={() => setPage("settings")}
|
||||||
<Settings className="w-4.5 h-4.5 shrink-0" />
|
/>
|
||||||
</Pressable>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -257,7 +235,7 @@ export default function NavSidebar({
|
|||||||
{...acctPop.getReferenceProps()}
|
{...acctPop.getReferenceProps()}
|
||||||
title={`${name} · ${roleLabel}`}
|
title={`${name} · ${roleLabel}`}
|
||||||
aria-label={`${name} · ${roleLabel}`}
|
aria-label={`${name} · ${roleLabel}`}
|
||||||
className={`${rowBase} justify-center px-0 text-muted hover:text-fg hover:bg-card`}
|
className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
|
||||||
>
|
>
|
||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<AvatarImg
|
<AvatarImg
|
||||||
@@ -353,7 +331,7 @@ export default function NavSidebar({
|
|||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</NavRail>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -801,7 +801,7 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={descPop}
|
popover={descPop}
|
||||||
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
|
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
||||||
>
|
>
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||||
{t("player.description")}
|
{t("player.description")}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type EditablePrefs = {
|
|||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
performanceMode: boolean;
|
performanceMode: boolean;
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
|
railLabels: boolean;
|
||||||
notifications: NotifSettings;
|
notifications: NotifSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
? prefs.performanceMode
|
? prefs.performanceMode
|
||||||
: getAccountRaw(PERF_KEY) === "1",
|
: getAccountRaw(PERF_KEY) === "1",
|
||||||
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||||
|
// B7: off by default — the icon rail with name tooltips is the house look.
|
||||||
|
railLabels: prefs?.railLabels === true,
|
||||||
notifications: prefs?.notifications
|
notifications: prefs?.notifications
|
||||||
? { ...getNotifSettings(), ...prefs.notifications }
|
? { ...getNotifSettings(), ...prefs.notifications }
|
||||||
: getNotifSettings(),
|
: getNotifSettings(),
|
||||||
@@ -58,6 +61,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
|
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
|
||||||
const [perf, setPerf] = useState(seed.performanceMode);
|
const [perf, setPerf] = useState(seed.performanceMode);
|
||||||
const [hints, setHints] = useState(seed.hints);
|
const [hints, setHints] = useState(seed.hints);
|
||||||
|
const [railLabels, setRailLabels] = useState(seed.railLabels);
|
||||||
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
|
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
|
||||||
// The last server-known prefs: the seed, then each successful save. The auto-save effect compares
|
// The last server-known prefs: the seed, then each successful save. The auto-save effect compares
|
||||||
// against it, so the seed itself is never echoed back as a save. A ref (drives no UI).
|
// against it, so the seed itself is never echoed back as a save. A ref (drives no UI).
|
||||||
@@ -88,8 +92,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
|
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
|
||||||
// saved straight back (`/me` merges, so a full payload is fine).
|
// saved straight back (`/me` merges, so a full payload is fine).
|
||||||
const editablePrefs = useMemo<EditablePrefs>(
|
const editablePrefs = useMemo<EditablePrefs>(
|
||||||
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
|
() => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }),
|
||||||
[theme, perf, hints, notif],
|
[theme, perf, hints, railLabels, notif],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.clearTimeout(saveTimer.current);
|
window.clearTimeout(saveTimer.current);
|
||||||
@@ -135,8 +139,20 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<PrefsController>(
|
const value = useMemo<PrefsController>(
|
||||||
() => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }),
|
() => ({
|
||||||
[theme, setTheme, perf, hints, notif, saveState],
|
theme,
|
||||||
|
setTheme,
|
||||||
|
perf,
|
||||||
|
setPerf,
|
||||||
|
hints,
|
||||||
|
setHints,
|
||||||
|
railLabels,
|
||||||
|
setRailLabels,
|
||||||
|
notif,
|
||||||
|
setNotif,
|
||||||
|
saveState,
|
||||||
|
}),
|
||||||
|
[theme, setTheme, perf, hints, railLabels, notif, saveState],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
|
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface PrefsController {
|
|||||||
setPerf: (v: boolean) => void;
|
setPerf: (v: boolean) => void;
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
setHints: (v: boolean) => void;
|
setHints: (v: boolean) => void;
|
||||||
|
railLabels: boolean;
|
||||||
|
setRailLabels: (v: boolean) => void;
|
||||||
notif: NotifSettings;
|
notif: NotifSettings;
|
||||||
setNotif: (n: NotifSettings) => void;
|
setNotif: (n: NotifSettings) => void;
|
||||||
saveState: PrefsSaveState;
|
saveState: PrefsSaveState;
|
||||||
@@ -139,7 +141,7 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
|
|||||||
function Appearance({ prefs }: { prefs: PrefsController }) {
|
function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
||||||
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
|
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
|
||||||
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
|
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
|
||||||
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
|
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
|
||||||
// values come from navigationInit, which is what reads them back at boot.
|
// values come from navigationInit, which is what reads them back at boot.
|
||||||
@@ -215,6 +217,16 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
>
|
>
|
||||||
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
|
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("settings.appearance.railLabels")}
|
||||||
|
hint={t("settings.appearance.railLabelsHint")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
label={t("settings.appearance.railLabels")}
|
||||||
|
checked={railLabels}
|
||||||
|
onChange={setRailLabels}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("settings.appearance.startup")}>
|
<Section title={t("settings.appearance.startup")}>
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ export default function WatchPage() {
|
|||||||
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||||
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||||
the surface (not the black video box) so it looks intentional without artwork. */
|
the surface (not the black video box) so it looks intentional without artwork. */
|
||||||
<div className="rounded-xl border border-border bg-surface p-4">
|
<div className={`${SURFACE.card} p-4`}>
|
||||||
<audio
|
<audio
|
||||||
ref={setMedia}
|
ref={setMedia}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ function BlockedPanel({
|
|||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
>
|
>
|
||||||
{gated && (
|
{gated && (
|
||||||
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
|
<div className={`${SURFACE.card} px-3.5 py-3 flex flex-col gap-2`}>
|
||||||
<Gate done={emailVerified === true} k="email" />
|
<Gate done={emailVerified === true} k="email" />
|
||||||
<Gate done={approved === true} k="approval" />
|
<Gate done={approved === true} k="approval" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const PrefsContext = createContext<PrefsController>({
|
|||||||
setPerf: () => {},
|
setPerf: () => {},
|
||||||
hints: true,
|
hints: true,
|
||||||
setHints: () => {},
|
setHints: () => {},
|
||||||
|
railLabels: false,
|
||||||
|
setRailLabels: () => {},
|
||||||
notif: getNotifSettings(),
|
notif: getNotifSettings(),
|
||||||
setNotif: () => {},
|
setNotif: () => {},
|
||||||
saveState: "idle",
|
saveState: "idle",
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E28 S3d (B7): the nav rail's row. Icon-only, its name is the module's full name (with an unread
|
||||||
|
// count, which was only ever drawn); with the labels on, the name is the short label the user SEES
|
||||||
|
// (WCAG 2.5.3), the full one staying in the tooltip. "You are here" is aria-current plus the tint.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { NavRailItem } from "./NavRail";
|
||||||
|
|
||||||
|
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("NavRailItem", () => {
|
||||||
|
it("icon-only: the full name, the count in it, and the current page marked", () => {
|
||||||
|
render(
|
||||||
|
<NavRailItem
|
||||||
|
icon={Bell}
|
||||||
|
label="Notifications"
|
||||||
|
shortLabel="Alerts"
|
||||||
|
showLabel={false}
|
||||||
|
active
|
||||||
|
badge={3}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = screen.getByRole("button", { name: "Notifications (3)" });
|
||||||
|
expect(row.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(row.className).toContain("bg-accent/15");
|
||||||
|
expect(row.textContent).not.toContain("Alerts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labelled: the name is the visible short label", () => {
|
||||||
|
render(
|
||||||
|
<NavRailItem
|
||||||
|
icon={Bell}
|
||||||
|
label="Notifications"
|
||||||
|
shortLabel="Alerts"
|
||||||
|
showLabel
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = screen.getByRole("button", { name: "Alerts" });
|
||||||
|
expect(row.getAttribute("aria-current")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Pressable } from "./Pressable";
|
||||||
|
import { SURFACE } from "./surfaces";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
|
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
|
||||||
|
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
|
||||||
|
// edge bar, calmer than a solid fill beside the page's own solid accents. Labels under the icons are
|
||||||
|
// a per-user setting (off by default); each module has a SHORT rail label that fits, and the full name
|
||||||
|
// is the tooltip.
|
||||||
|
|
||||||
|
/** The rail's column: the glass panel, the rail's width and its padding. */
|
||||||
|
export function NavRail({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
/** The landmark's accessible name ("Primary navigation"). */
|
||||||
|
label: string;
|
||||||
|
/** Placement only (position, height); the look and width are the primitive's. */
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label={label}
|
||||||
|
className={clsx(SURFACE.panel, "flex w-15 flex-col px-1 py-2.5", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, thick centred rule: the "new section" cue on an icon rail (the system modules). */
|
||||||
|
export function NavRailDivider() {
|
||||||
|
return <div className="mx-auto my-1.5 w-7 shrink-0 border-t-2 border-border" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shape of one rail row, for a row that is not a page (the account button). */
|
||||||
|
export const RAIL_ROW =
|
||||||
|
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition";
|
||||||
|
|
||||||
|
export function NavRailItem({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
shortLabel,
|
||||||
|
showLabel,
|
||||||
|
active = false,
|
||||||
|
badge,
|
||||||
|
onClick,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** The full name: the tooltip, and the accessible name while the label is hidden. */
|
||||||
|
label: string;
|
||||||
|
/** What fits under the icon when labels are on. */
|
||||||
|
shortLabel: string;
|
||||||
|
showLabel: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
/** An unread / in-progress count on the icon's corner (0 hides it). */
|
||||||
|
badge?: number;
|
||||||
|
onClick: () => void;
|
||||||
|
testId?: string;
|
||||||
|
}) {
|
||||||
|
const shown = !!badge && badge > 0;
|
||||||
|
return (
|
||||||
|
<Tooltip hint={label} side="right" always openDelay={350}>
|
||||||
|
<Pressable
|
||||||
|
data-testid={testId}
|
||||||
|
onClick={onClick}
|
||||||
|
// With the label visible the row's name IS that text (WCAG 2.5.3: the spoken name contains the
|
||||||
|
// visible one); the full name stays in the tooltip. Icon-only, the full name is the name.
|
||||||
|
// The count rides in the name too, so "Messages, 3" is heard and not only seen.
|
||||||
|
aria-label={showLabel ? undefined : shown ? `${label} (${badge})` : 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative shrink-0">
|
||||||
|
<Icon className="size-4.5" aria-hidden="true" />
|
||||||
|
{shown && (
|
||||||
|
<span className="absolute -top-2 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 text-3xs leading-none font-bold text-accent-fg ring-2 ring-bg">
|
||||||
|
{badge > 9 ? "9+" : badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showLabel && (
|
||||||
|
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
|
||||||
|
{shortLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ domain-free.
|
|||||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
|
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `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
|
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`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
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`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||||
|
|||||||
@@ -4,5 +4,22 @@
|
|||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"demo": "Demo"
|
"demo": "Demo"
|
||||||
|
},
|
||||||
|
"rail": {
|
||||||
|
"home": "Home",
|
||||||
|
"feed": "Feed",
|
||||||
|
"channels": "Channels",
|
||||||
|
"playlists": "Playlists",
|
||||||
|
"plex": "Plex",
|
||||||
|
"notifications": "Alerts",
|
||||||
|
"messages": "Messages",
|
||||||
|
"downloads": "Downloads",
|
||||||
|
"stats": "Stats",
|
||||||
|
"scheduler": "Scheduler",
|
||||||
|
"config": "Config",
|
||||||
|
"users": "Users",
|
||||||
|
"audit": "Audit",
|
||||||
|
"logs": "Logs",
|
||||||
|
"settings": "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
||||||
"showHints": "Show hints",
|
"showHints": "Show hints",
|
||||||
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
||||||
|
"railLabels": "Labels in the side menu",
|
||||||
|
"railLabelsHint": "A short name under each icon in the left menu. The menu stays as narrow; it only grows taller.",
|
||||||
"startup": "Startup",
|
"startup": "Startup",
|
||||||
"startOnHome": "Start on Home",
|
"startOnHome": "Start on Home",
|
||||||
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
|
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
|
||||||
|
|||||||
@@ -4,5 +4,22 @@
|
|||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"user": "Felhasználó",
|
"user": "Felhasználó",
|
||||||
"demo": "Demó"
|
"demo": "Demó"
|
||||||
|
},
|
||||||
|
"rail": {
|
||||||
|
"home": "Kezdőlap",
|
||||||
|
"feed": "Feed",
|
||||||
|
"channels": "Csatornák",
|
||||||
|
"playlists": "Listák",
|
||||||
|
"plex": "Plex",
|
||||||
|
"notifications": "Értesítés",
|
||||||
|
"messages": "Üzenetek",
|
||||||
|
"downloads": "Letöltések",
|
||||||
|
"stats": "Statisztika",
|
||||||
|
"scheduler": "Ütemező",
|
||||||
|
"config": "Konfig",
|
||||||
|
"users": "Fiókok",
|
||||||
|
"audit": "Audit",
|
||||||
|
"logs": "Napló",
|
||||||
|
"settings": "Beállítások"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
|
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
|
||||||
"showHints": "Tippek megjelenítése",
|
"showHints": "Tippek megjelenítése",
|
||||||
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
||||||
|
"railLabels": "Feliratok az oldalsó menüben",
|
||||||
|
"railLabelsHint": "Rövid név minden ikon alatt a bal oldali menüben. A menü ugyanolyan keskeny marad, csak magasabb lesz.",
|
||||||
"startup": "Indulás",
|
"startup": "Indulás",
|
||||||
"startOnHome": "Indítás a kezdőlapon",
|
"startOnHome": "Indítás a kezdőlapon",
|
||||||
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
|
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
|
||||||
|
|||||||
+6
-14
@@ -563,8 +563,7 @@ body {
|
|||||||
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
|
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
|
||||||
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
|
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
|
||||||
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
|
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
|
||||||
.pill,
|
.pill {
|
||||||
.is-current {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
transition:
|
transition:
|
||||||
color 0.15s ease,
|
color 0.15s ease,
|
||||||
@@ -576,25 +575,18 @@ body {
|
|||||||
/* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`),
|
/* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`),
|
||||||
whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
|
whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
|
||||||
site also adds a border-width utility. */
|
site also adds a border-width utility. */
|
||||||
.pill:not(.glass-card),
|
.pill:not(.glass-card) {
|
||||||
.is-current {
|
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
.pill:hover,
|
.pill:hover {
|
||||||
.is-current:hover {
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
.pill:hover {
|
.pill:hover {
|
||||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
}
|
}
|
||||||
.is-current:hover {
|
/* selected — SOLID accent fill (the dominant idiom). (The nav rail's "you are here" is ui/NavRail's
|
||||||
background-color: var(--card);
|
tint + edge bar since E28 S3d; its old `.is-current` recipe is gone.) */
|
||||||
}
|
.pill[aria-pressed="true"] {
|
||||||
/* selected / current — SOLID accent fill (the dominant idiom). The `:not([aria-current="false"])`
|
|
||||||
guard matters: `.is-current` is a shared primitive and a consumer using the common
|
|
||||||
`aria-current={x ? "true" : "false"}` idiom would otherwise paint a NON-current row as current. */
|
|
||||||
.pill[aria-pressed="true"],
|
|
||||||
.is-current[aria-current]:not([aria-current="false"]) {
|
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export type ModuleDef = {
|
|||||||
id: Page;
|
id: Page;
|
||||||
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
// The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem
|
||||||
|
// wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey`
|
||||||
|
// stays the tooltip and the accessible name.
|
||||||
|
railKey: string;
|
||||||
// i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's
|
// i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's
|
||||||
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
|
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
|
||||||
titleKey?: string;
|
titleKey?: string;
|
||||||
@@ -51,20 +55,45 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
|
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
|
||||||
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
|
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
|
||||||
// already uses, so the two are apart by shape and not just by position.
|
// already uses, so the two are apart by shape and not just by position.
|
||||||
{ id: "home", labelKey: "home.navLabel", titleKey: "home.title", icon: Home },
|
{
|
||||||
{ id: "feed", labelKey: "header.account.feed", icon: Youtube },
|
id: "home",
|
||||||
|
labelKey: "home.navLabel",
|
||||||
|
railKey: "nav.rail.home",
|
||||||
|
titleKey: "home.title",
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: Youtube },
|
||||||
{
|
{
|
||||||
id: "channels",
|
id: "channels",
|
||||||
labelKey: "header.account.channels",
|
labelKey: "header.account.channels",
|
||||||
|
railKey: "nav.rail.channels",
|
||||||
titleKey: "header.channelManager",
|
titleKey: "header.channelManager",
|
||||||
icon: Tv,
|
icon: Tv,
|
||||||
},
|
},
|
||||||
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
|
{
|
||||||
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
|
id: "playlists",
|
||||||
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
|
labelKey: "header.account.playlists",
|
||||||
|
railKey: "nav.rail.playlists",
|
||||||
|
icon: ListVideo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plex",
|
||||||
|
labelKey: "plex.navLabel",
|
||||||
|
railKey: "nav.rail.plex",
|
||||||
|
icon: Clapperboard,
|
||||||
|
gate: (me) => me.plex_enabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
labelKey: "inbox.navLabel",
|
||||||
|
railKey: "nav.rail.notifications",
|
||||||
|
icon: Bell,
|
||||||
|
hasBadge: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "messages",
|
id: "messages",
|
||||||
labelKey: "messages.navLabel",
|
labelKey: "messages.navLabel",
|
||||||
|
railKey: "nav.rail.messages",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
hasBadge: true,
|
hasBadge: true,
|
||||||
gate: notDemo,
|
gate: notDemo,
|
||||||
@@ -72,14 +101,22 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: "downloads",
|
id: "downloads",
|
||||||
labelKey: "downloads.navLabel",
|
labelKey: "downloads.navLabel",
|
||||||
|
railKey: "nav.rail.downloads",
|
||||||
icon: Download,
|
icon: Download,
|
||||||
hasBadge: true,
|
hasBadge: true,
|
||||||
gate: notDemo,
|
gate: notDemo,
|
||||||
},
|
},
|
||||||
{ id: "stats", labelKey: "header.account.stats", titleKey: "header.usageStats", icon: BarChart3 },
|
{
|
||||||
|
id: "stats",
|
||||||
|
labelKey: "header.account.stats",
|
||||||
|
railKey: "nav.rail.stats",
|
||||||
|
titleKey: "header.usageStats",
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "scheduler",
|
id: "scheduler",
|
||||||
labelKey: "header.account.scheduler",
|
labelKey: "header.account.scheduler",
|
||||||
|
railKey: "nav.rail.scheduler",
|
||||||
titleKey: "header.scheduler",
|
titleKey: "header.scheduler",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
system: true,
|
system: true,
|
||||||
@@ -88,6 +125,7 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: "config",
|
id: "config",
|
||||||
labelKey: "header.account.config",
|
labelKey: "header.account.config",
|
||||||
|
railKey: "nav.rail.config",
|
||||||
titleKey: "header.configuration",
|
titleKey: "header.configuration",
|
||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
system: true,
|
system: true,
|
||||||
@@ -96,6 +134,7 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
labelKey: "header.account.users",
|
labelKey: "header.account.users",
|
||||||
|
railKey: "nav.rail.users",
|
||||||
titleKey: "header.users",
|
titleKey: "header.users",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
system: true,
|
system: true,
|
||||||
@@ -104,6 +143,7 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: "audit",
|
id: "audit",
|
||||||
labelKey: "header.account.audit",
|
labelKey: "header.account.audit",
|
||||||
|
railKey: "nav.rail.audit",
|
||||||
titleKey: "audit.title",
|
titleKey: "audit.title",
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
system: true,
|
system: true,
|
||||||
@@ -112,6 +152,7 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: "logs",
|
id: "logs",
|
||||||
labelKey: "header.account.logs",
|
labelKey: "header.account.logs",
|
||||||
|
railKey: "nav.rail.logs",
|
||||||
titleKey: "adminLogs.title",
|
titleKey: "adminLogs.title",
|
||||||
icon: FileSearch,
|
icon: FileSearch,
|
||||||
system: true,
|
system: true,
|
||||||
@@ -124,6 +165,7 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
const SETTINGS_MODULE: ModuleDef = {
|
const SETTINGS_MODULE: ModuleDef = {
|
||||||
id: "settings",
|
id: "settings",
|
||||||
labelKey: "header.account.settings",
|
labelKey: "header.account.settings",
|
||||||
|
railKey: "nav.rail.settings",
|
||||||
titleKey: "settings.title",
|
titleKey: "settings.title",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
};
|
};
|
||||||
@@ -149,6 +191,10 @@ export const moduleLabelKey: Record<Page, string> = Object.fromEntries(
|
|||||||
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
|
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
|
||||||
) as Record<Page, string>;
|
) as Record<Page, string>;
|
||||||
|
|
||||||
|
export const moduleRailKey: Record<Page, string> = Object.fromEntries(
|
||||||
|
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.railKey]),
|
||||||
|
) as Record<Page, string>;
|
||||||
|
|
||||||
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
|
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
|
||||||
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);
|
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user