style(web): dunkle App-Leiste und Fluent-Seitenleiste

Kopfzeile 48 px dunkel mit Seitentitel statt immer Startseite,
Dashboard-Reiter mit Akzent-Unterstrich; Seitenleiste mit
Auswahlpille, Modul-Kacheln, aufgeklappten Kategorien und gefuelltem
Suchfeld; eingeklappt nur Kacheln. Einheitlicher PageHeader.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:32:37 +02:00
parent 7192be223a
commit b80db49c5e
11 changed files with 389 additions and 150 deletions
@@ -20,9 +20,10 @@ export interface TesseraLogoProps {
* Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen * Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen
* Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom * Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom
* Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss * Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss
* dies auf `false` gesetzt werden. Vorgabe: `true`. * dies auf `false` gesetzt werden; auf immer dunklen Flaechen (App-Leiste,
* Anmelde-Panel) auf `'always'`. Vorgabe: `true`.
*/ */
plateOutline?: boolean; plateOutline?: boolean | 'always';
/** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */ /** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */
className?: string; className?: string;
/** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */ /** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */
@@ -38,7 +39,7 @@ function LogoMark({
ariaLabel, ariaLabel,
}: { }: {
size: number; size: number;
plateOutline: boolean; plateOutline: boolean | 'always';
className?: string; className?: string;
ariaLabel: string; ariaLabel: string;
}) { }) {
@@ -61,7 +62,9 @@ function LogoMark({
fill={BRAND_PLATE} fill={BRAND_PLATE}
strokeWidth={2} strokeWidth={2}
className={ className={
plateOutline plateOutline === 'always'
? 'stroke-white/20'
: plateOutline
? 'stroke-transparent dark:stroke-white/25' ? 'stroke-transparent dark:stroke-white/25'
: 'stroke-transparent' : 'stroke-transparent'
} }
@@ -34,7 +34,7 @@ export function BugReportButton() {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
aria-label={t('button')} aria-label={t('button')}
title={t('button')} title={t('button')}
disabled={capturing} disabled={capturing}
@@ -312,18 +312,20 @@ export function DashboardTabs({
const bar = ( const bar = (
<nav <nav
aria-label={t('tabs.navLabel')} aria-label={t('tabs.navLabel')}
className="flex min-w-0 max-w-full items-center gap-1.5" className="flex h-[var(--header-height)] min-w-0 max-w-full items-center gap-1.5"
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined} title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
> >
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */} {/* Reiter im Fluent-Stil auf der dunklen App-Leiste (Design „Mosaik“):
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]"> volle Leistenhoehe, der aktive Reiter weiss und halbfett mit 2 px
Akzentlinie an der Unterkante der Leiste. */}
<div className="h-[var(--header-height)] min-w-0 max-w-full md:max-w-[min(56vw,720px)]">
<div <div
ref={scrollRef} ref={scrollRef}
role="tablist" role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined} aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow} onScroll={measureOverflow}
style={maskStyle} style={maskStyle}
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="flex h-full items-stretch gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
> >
{orderedTabs.map((tab, index) => { {orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId; const isActive = tab.id === activeDashboardId;
@@ -339,16 +341,22 @@ export function DashboardTabs({
}} }}
data-tab-index={index} data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`} data-testid={`dashboard-tab-${tab.id}`}
className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${ className={`relative flex shrink-0 touch-none items-center transition-colors ${
isActive isActive
? 'bg-card text-foreground shadow-sm' ? 'text-appbar-foreground'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground' : 'text-appbar-muted hover:text-appbar-foreground'
}`} }`}
onPointerDown={(e) => handlePointerDown(e, tab.id)} onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel} onPointerCancel={handlePointerCancel}
> >
{isActive && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-3 bottom-0 h-[2px] rounded-full bg-primary"
/>
)}
{isRenaming ? ( {isRenaming ? (
<input <input
ref={renameInputRef} ref={renameInputRef}
@@ -366,7 +374,7 @@ export function DashboardTabs({
}} }}
maxLength={40} maxLength={40}
aria-label={t('tabs.renameInputLabel')} aria-label={t('tabs.renameInputLabel')}
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground" className="h-7 w-32 rounded-md border border-white/25 bg-white/10 px-2 text-sm text-appbar-foreground outline-none focus:border-primary"
/> />
) : ( ) : (
<button <button
@@ -380,8 +388,9 @@ export function DashboardTabs({
tabIndex={isFocusable ? 0 : -1} tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)} onClick={() => handleTabClick(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)} onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${ data-text={tab.name}
isActive ? 'font-medium' : '' className={`inline-flex h-8 cursor-grab flex-col items-center justify-center whitespace-nowrap rounded-md px-3 text-sm transition-colors hover:bg-appbar-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground after:invisible after:h-0 after:overflow-hidden after:font-semibold after:content-[attr(data-text)] ${
isActive ? 'font-semibold' : ''
}`} }`}
> >
{tab.name} {tab.name}
@@ -394,7 +403,7 @@ export function DashboardTabs({
onClick={() => startRename(tab)} onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')} aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')} title={t('tabs.renameButtonLabel')}
className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="-ml-1.5 rounded p-1 text-appbar-muted hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -419,7 +428,7 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)} onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')} aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')} title={t('tabs.deleteButtonLabel')}
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`} className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-appbar-muted hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground`}
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -453,7 +462,7 @@ export function DashboardTabs({
onClick={onCreate} onClick={onCreate}
aria-label={t('tabs.add')} aria-label={t('tabs.add')}
title={t('tabs.add')} title={t('tabs.add')}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-white/20 text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -510,7 +519,7 @@ export function DashboardTabs({
<button <button
type="button" type="button"
onClick={() => setPendingDeleteId(null)} onClick={() => setPendingDeleteId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
+25 -4
View File
@@ -1,6 +1,7 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { useNavStore } from '@/lib/stores/nav-store';
/** /**
* header.test — Sitzungswaechter im Header (quick-260917-gyd). * header.test — Sitzungswaechter im Header (quick-260917-gyd).
@@ -166,15 +167,35 @@ describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => {
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
}); });
it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => { it('auf anderen Seiten: Name der aktuellen Seite statt „Startseite“ (Design „Mosaik“)', async () => {
mockPathname = '/modules/proxmox'; mockPathname = '/marketplace';
vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' }); vi.stubGlobal('location', { href: '', pathname: '/marketplace', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' }); fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader(); await renderHeader();
expect(screen.getByText('Startseite')).toBeInTheDocument(); expect(screen.getByText('Marktplatz')).toBeInTheDocument();
expect(screen.queryByText('Startseite')).not.toBeInTheDocument();
expect(document.getElementById('header-center-slot')).not.toBeNull(); expect(document.getElementById('header-center-slot')).not.toBeNull();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
}); });
it('auf Modulseiten: Modulname aus der Seitenleiste, sonst der Ersatztitel', async () => {
mockPathname = '/modules/infrastructure/proxmox';
vi.stubGlobal('location', { href: '', pathname: mockPathname, search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
// Noch keine Module geladen: Ersatztitel aus den Uebersetzungen.
expect(screen.getByText('Proxmox')).toBeInTheDocument();
cleanup();
useNavStore.getState().setModules([
{ id: 'm1', slug: 'proxmox', name: 'Proxmox-Monitor', category: 'infrastructure' },
]);
await renderHeader();
expect(screen.getByText('Proxmox-Monitor')).toBeInTheDocument();
useNavStore.getState().setModules([]);
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
}); });
+34 -18
View File
@@ -12,6 +12,7 @@ import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
export function Header() { export function Header() {
const t = useTranslations(); const t = useTranslations();
@@ -95,14 +96,25 @@ export function Header() {
const roleLabel = user ? tHeader(`role.${user.role}`) : ''; const roleLabel = user ? tHeader(`role.${user.role}`) : '';
// Seitentitel in der Mitte (Design „Mosaik“): statt immer „Startseite“
// der Name der aktuellen Seite bzw. des Moduls.
const navModules = useNavStore((s) => s.modules);
const pageTitle = resolvePageTitle(pathname, navModules);
const pageTitleText = pageTitle
? 'key' in pageTitle
? t(pageTitle.key)
: pageTitle.text
: null;
return ( return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm"> <header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
{/* Left: Logo + Mobile hamburger */} {/* Links: Menue (mobil) + Bildmarke. Dunkle App-Leiste wie bei GitHub
<div className="flex shrink-0 items-center gap-3"> bzw. Microsoft 365 — in beiden Erscheinungsbildern dunkel. */}
<div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
onClick={() => setMobileOpen(true)} onClick={() => setMobileOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors md:hidden"
aria-label={t('common.menu')} aria-label={t('common.menu')}
> >
<svg <svg
@@ -122,17 +134,21 @@ export function Header() {
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </button>
<Link
href="/"
className="flex items-center rounded-md px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
>
<TesseraLogo <TesseraLogo
variant="horizontal" variant="horizontal"
size={28} size={26}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen plateOutline="always"
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer className="gap-2.5"
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher // Schriftzug weiss auf der dunklen Leiste (in beiden
// die Vordergrundfarbe, im dunklen steht die Markenfarbe. // Erscheinungsbildern). Unter 640 px nur die Bildmarke: der Platz
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den // gehoert dort den Dashboard-Reitern (260924-h7x).
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x). wordmarkClassName="hidden sm:inline text-[15px] font-semibold tracking-normal text-appbar-foreground"
wordmarkClassName="hidden sm:inline dark:text-primary"
/> />
</Link>
</div> </div>
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x). {/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
@@ -143,15 +159,15 @@ export function Header() {
und Aktionen rechts auch bei schmalem Fenster Platz behalten. */} und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4"> <div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" /> <div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && ( {pageTitleText !== null && (
<span className="text-sm text-muted-foreground truncate"> <span className="truncate text-sm font-semibold text-appbar-foreground">
{tHeader('breadcrumb.home')} {pageTitleText}
</span> </span>
)} )}
</div> </div>
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-1">
<BugReportButton /> <BugReportButton />
<ThemeToggle /> <ThemeToggle />
@@ -160,7 +176,7 @@ export function Header() {
<button <button
type="button" type="button"
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="ml-1 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary text-xs font-semibold text-primary-foreground ring-1 ring-white/15 transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-appbar-foreground"
aria-label={tHeader('userMenu')} aria-label={tHeader('userMenu')}
> >
{user?.hasAvatar && !avatarError ? ( {user?.hasAvatar && !avatarError ? (
@@ -178,7 +194,7 @@ export function Header() {
{/* Dropdown menu */} {/* Dropdown menu */}
{dropdownOpen && ( {dropdownOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-md border border-border bg-card shadow-lg py-2 z-50"> <div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-lg border border-border bg-card py-1.5 text-card-foreground shadow-lg">
{/* User info */} {/* User info */}
<div className="px-4 py-2 border-b border-border"> <div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-foreground truncate"> <p className="text-sm font-medium text-foreground truncate">
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { ModuleTile } from '@/components/modules/module-tile';
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
/** Aktionen rechtsbuendig (Knoepfe, Verweise). */
actions?: ReactNode;
/** Modulkennung: zeigt die App-Kachel des Moduls links neben dem Titel. */
moduleSlug?: string;
className?: string;
}
/**
* Einheitlicher Seitenkopf (Design „Mosaik“, Fluent-Typografie): Titel
* 24/32 px halbfett, optionale Beschreibung 14 px gedaempft, Aktionen rechts.
* Modulseiten tragen ihre App-Kachel (36 px) links vom Titel.
*/
export function PageHeader({ title, description, actions, moduleSlug, className = '' }: PageHeaderProps) {
return (
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
<div className="flex min-w-0 items-center gap-3">
{moduleSlug && <ModuleTile slug={moduleSlug} size={36} />}
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
@@ -11,14 +11,32 @@ export function SidebarSearch({ value, onChange }: SidebarSearchProps) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
return ( return (
<div className="px-3 pb-2"> <div className="relative pb-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
{/* Gefuelltes Suchfeld ohne harten Rahmen (Fluent „filled“); Fokus =
2 px Akzentlinie unten plus Tastaturring. */}
<input <input
type="text" type="search"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={t('search')} placeholder={t('search')}
aria-label={t('search')} aria-label={t('search')}
className="flex h-9 w-full rounded-md border border-border bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent" className="flex h-8 w-full rounded-md border-0 bg-sidebar-hover pl-8 pr-2.5 text-sm text-foreground placeholder:text-muted-foreground shadow-[inset_0_-1px_0_var(--input)] transition-shadow focus:bg-card focus:shadow-[inset_0_-2px_0_var(--primary-strong)] focus:outline-none [&::-webkit-search-cancel-button]:hidden"
/> />
</div> </div>
); );
@@ -147,12 +147,10 @@ describe('Sidebar', () => {
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
render(<Sidebar />); render(<Sidebar />);
// Categories are collapsed by default — wait for category header, then expand // Design „Mosaik“: Kategorien sind anfangs aufgeklappt.
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument(); expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
}); });
await userEvent.click(screen.getByText('Domain Tools'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument(); expect(screen.getByText('Domaincheck')).toBeInTheDocument();
}); });
@@ -165,13 +163,46 @@ describe('Sidebar', () => {
}); });
}); });
it('Kategorien sind aufgeklappt, ein Klick auf die Kopfzeile klappt zu und wieder auf', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const header = screen.getByText('Domain-Tools').closest('button');
expect(header).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(screen.getByText('Domain-Tools'));
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
expect(header).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(screen.getByText('Domain-Tools'));
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
it('Modul-Eintraege tragen die App-Kachel, die Auswahl die Akzent-Pille', async () => {
mockPathname = '/modules/Domain-Tools/domaincheck';
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const link = screen.getByText('Domaincheck').closest('a');
expect(link?.querySelector('[data-module-tile="domaincheck"]')).not.toBeNull();
expect(link?.querySelector('[data-testid="nav-selection-pill"]')).not.toBeNull();
const dashLink = screen.getByText('Dashboard').closest('a');
expect(dashLink?.querySelector('[data-testid="nav-selection-pill"]')).toBeNull();
});
it('re-fetches modules when sidebarRefreshKey changes', async () => { it('re-fetches modules when sidebarRefreshKey changes', async () => {
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
const { rerender } = render(<Sidebar />); const { rerender } = render(<Sidebar />);
// Wait for category header to confirm initial fetch completed // Wait for category header to confirm initial fetch completed
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument(); expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
}); });
const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length; const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
+127 -80
View File
@@ -9,6 +9,20 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search'; import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge'; import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() {
return (
<span
aria-hidden="true"
data-testid="nav-selection-pill"
className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary-strong"
/>
);
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -25,7 +39,11 @@ export function Sidebar() {
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore(); const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]); const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set()); // Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
const categoryLabel = useCategoryLabel();
const setNavModules = useNavStore((s) => s.setModules);
const pathname = usePathname(); const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey); const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -34,25 +52,34 @@ export function Sidebar() {
return pathname.startsWith(href); return pathname.startsWith(href);
}; };
const linkClass = (href: string) => // Design „Mosaik“ (Fluent 2): 36 px hohe Eintraege, 8 px Radius, die
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${ // Auswahl traegt eine neutrale Flaeche, halbfette Schrift und links eine
isActive(href) // 3 x 16 px grosse Pille in der Akzentfarbe (`SelectionPill`).
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' const itemClass = (active: boolean) =>
: 'text-sidebar-foreground hover:bg-muted' `relative flex h-9 items-center gap-3 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
isCollapsed ? 'justify-center px-0' : 'px-2.5'
} ${
active
? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`; }`;
const linkClass = (href: string) => itemClass(isActive(href));
const fetchActiveModules = useCallback(async () => { const fetchActiveModules = useCallback(async () => {
try { try {
const res = await fetch(`${API_URL}/modules/active`, { const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include', credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
setActiveModules(await res.json()); const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
} }
} catch { } catch {
// silently fail // silently fail
} }
}, []); }, [setNavModules]);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch- // Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch // Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
@@ -64,7 +91,7 @@ export function Sidebar() {
}, [fetchActiveModules, sidebarRefreshKey]); }, [fetchActiveModules, sidebarRefreshKey]);
const toggleCategory = (cat: string) => { const toggleCategory = (cat: string) => {
setOpenCategories((prev) => { setClosedCategories((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(cat)) next.delete(cat); if (next.has(cat)) next.delete(cat);
else next.add(cat); else next.add(cat);
@@ -85,7 +112,7 @@ export function Sidebar() {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = []; const result: [string, SidebarModule[]][] = [];
for (const [cat, mods] of categories.entries()) { for (const [cat, mods] of categories.entries()) {
if (cat.toLowerCase().includes(q)) { if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]); result.push([cat, mods]);
} else { } else {
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q)); const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
@@ -93,115 +120,136 @@ export function Sidebar() {
} }
} }
return result; return result;
}, [activeModules, searchQuery]); }, [activeModules, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
{children}
</svg>
);
const sidebarContent = ( const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar"> <div className="flex h-full flex-col bg-sidebar">
<nav className="flex-1 overflow-y-auto p-3"> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-0.5">
<li> <li>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}> <Link href="/" className={linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> {isActive('/') && <SelectionPill />}
<rect x="3" y="3" width="7" height="7" /> {navIcon(
<rect x="14" y="3" width="7" height="7" /> <>
<rect x="14" y="14" width="7" height="7" /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="3" y="14" width="7" height="7" /> <rect x="14" y="3" width="7" height="5" rx="1.5" />
</svg> <rect x="14" y="12" width="7" height="9" rx="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>} {!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')}> <Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> {isActive('/marketplace') && <SelectionPill />}
<circle cx="9" cy="21" r="1" /> {navIcon(
<circle cx="20" cy="21" r="1" /> <>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" /> <path d="M3 9h18l-1.5 10.5a2 2 0 0 1-2 1.5h-11a2 2 0 0 1-2-1.5z" />
</svg> <path d="M8 9V7a4 4 0 0 1 8 0v2" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>} {!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</Link> </Link>
</li> </li>
</ul> </ul>
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && activeModules.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
{activeModules.map((mod) => {
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} />
</Link>
</li>
);
})}
</ul>
)}
{!isCollapsed && ( {!isCollapsed && (
<> <>
<div className="mt-4"> <div className="mt-4 border-t border-sidebar-border pt-3">
<SidebarSearch value={searchQuery} onChange={setSearchQuery} /> <SidebarSearch value={searchQuery} onChange={setSearchQuery} />
</div> </div>
<div className="mt-2"> <div className="mt-1" aria-label={t('categories.label')} role="group">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('categories.label')}
</div>
<div className="mt-1">
{activeModules.length === 0 ? ( {activeModules.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')} {t('categories.empty')}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('noResults')} {t('noResults')}
</div> </div>
) : ( ) : (
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-2">
{filteredCategories.map(([category, mods]) => ( {filteredCategories.map(([category, mods]) => {
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
const open = searchQuery !== '' || !closedCategories.has(category);
return (
<li key={category}> <li key={category}>
<button <button
type="button" type="button"
onClick={() => toggleCategory(category)} onClick={() => toggleCategory(category)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${ aria-expanded={open}
isActive(`/modules/${category}`) className="group flex h-7 w-full items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> <span className="truncate">{categoryLabel(category)}</span>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
</svg>
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
<span className="ml-auto flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">{mods.length}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${openCategories.has(category) ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</span>
</button> </button>
{openCategories.has(category) && ( {open && (
<ul className="ml-5 flex flex-col gap-0.5"> <ul className="mt-0.5 flex flex-col gap-0.5">
{mods.map((mod) => ( {mods.map((mod) => {
const href = `/modules/${category}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
return (
<li key={mod.id}> <li key={mod.id}>
<Link <Link href={href} className={itemClass(active)}>
href={`/modules/${category}/${mod.slug}`} {active && <SelectionPill />}
className={`block rounded-md px-2 py-1 text-xs transition-colors ${ <ModuleTile slug={mod.slug} size={20} />
isActive(`/modules/${category}/${mod.slug}`) <span className="truncate">{mod.name}</span>
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{mod.name}
</Link> </Link>
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</div> </div>
</div>
</> </>
)} )}
</nav> </nav>
<div className="hidden md:block border-t border-sidebar-border p-2"> <div className="hidden border-t border-sidebar-border px-3 py-2 md:block">
<button <button
type="button" type="button"
onClick={toggle} onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors" className={`flex h-8 w-full items-center gap-3 rounded-lg text-sm text-muted-foreground transition-colors hover:bg-sidebar-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${isCollapsed ? 'justify-center' : 'px-2.5'}`}
aria-label={isCollapsed ? t('expand') : t('collapse')} aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<polyline points="15 18 9 12 15 6" /> <rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg> </svg>
{!isCollapsed && <span className="truncate">{t('collapse')}</span>} {!isCollapsed && <span className="truncate">{t('collapse')}</span>}
</button> </button>
@@ -210,7 +258,7 @@ export function Sidebar() {
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit {/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */} auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
{!isCollapsed && ( {!isCollapsed && (
<div className="border-t border-sidebar-border px-4 py-2"> <div className="px-5 pb-3 pt-1">
<AppVersionBadge /> <AppVersionBadge />
</div> </div>
)} )}
@@ -220,7 +268,7 @@ export function Sidebar() {
return ( return (
<> <>
<aside <aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block" className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{ style={{
width: isCollapsed width: isCollapsed
? 'var(--sidebar-width-collapsed)' ? 'var(--sidebar-width-collapsed)'
@@ -237,22 +285,21 @@ export function Sidebar() {
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
aria-hidden="true" aria-hidden="true"
/> />
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg"> <aside className="fixed left-0 top-0 z-50 flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar shadow-xl">
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4"> {/* Kopf der Schublade im Stil der App-Leiste (immer dunkel). */}
<div className="flex h-[var(--header-height)] items-center justify-between bg-appbar px-3 text-appbar-foreground">
<TesseraLogo <TesseraLogo
variant="horizontal" variant="horizontal"
size={28} size={26}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen plateOutline="always"
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer className="gap-2.5"
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher wordmarkClassName="text-[15px] font-semibold tracking-normal text-appbar-foreground"
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
wordmarkClassName="dark:text-primary"
/> />
<button <button
type="button" type="button"
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
aria-label={tCommon('close')} aria-label={tCommon('close')}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
@@ -260,7 +307,7 @@ export function Sidebar() {
</svg> </svg>
</button> </button>
</div> </div>
{sidebarContent} <div className="min-h-0 flex-1">{sidebarContent}</div>
</aside> </aside>
</div> </div>
)} )}
+1 -1
View File
@@ -35,7 +35,7 @@ export function ThemeToggle() {
<button <button
type="button" type="button"
onClick={cycleTheme} onClick={cycleTheme}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
aria-label={label} aria-label={label}
title={label} title={label}
> >
+62
View File
@@ -0,0 +1,62 @@
import { create } from 'zustand';
export interface NavModule {
id: string;
slug: string;
name: string;
category: string;
}
interface NavState {
/** Freigegebene Module, wie die Seitenleiste sie geladen hat. */
modules: NavModule[];
setModules: (modules: NavModule[]) => void;
}
/**
* Geteilter Stand der Seitenleiste: die Kopfzeile liest daraus den
* Modulnamen fuer den Seitentitel, ohne selbst noch einmal zu laden.
*/
export const useNavStore = create<NavState>()((set) => ({
modules: [],
setModules: (modules) => set({ modules }),
}));
/** Ersatztitel, solange die Seitenleiste die Module noch nicht geladen hat. */
const MODULE_TITLE_KEYS: Record<string, string> = {
proxmox: 'proxmox.title',
domaincheck: 'domaincheck.title',
'cert-manager': 'certManager.title',
'dkv-fleet': 'dkvFleet.pageTitle',
'tender-radar': 'tenderRadar.page.title',
};
/**
* Seitentitel der Kopfzeile zu einem Pfad. `null` auf der Startseite (dort
* stehen die Dashboard-Reiter), sonst ein Uebersetzungsschluessel oder ein
* fertiger Modulname.
*/
export function resolvePageTitle(
pathname: string,
modules: NavModule[],
): { key: string } | { text: string } | null {
if (pathname === '/') return null;
if (pathname.startsWith('/marketplace')) return { key: 'sidebar.marketplace' };
if (pathname.startsWith('/settings')) return { key: 'sidebar.settings' };
if (pathname.startsWith('/changelog')) return { key: 'sidebar.whatsNew' };
if (pathname.startsWith('/admin')) return { key: 'sidebar.admin' };
if (pathname.startsWith('/change-password')) return { key: 'auth.changePassword.title' };
if (pathname.startsWith('/modules/')) {
const segments = pathname.split('/').filter(Boolean).slice(1);
for (const segment of segments) {
const mod = modules.find((m) => m.slug === segment);
if (mod) return { text: mod.name };
}
for (const segment of segments) {
const key = MODULE_TITLE_KEYS[segment];
if (key) return { key };
}
return { text: '' };
}
return { key: 'header.breadcrumb.home' };
}