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:
@@ -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,9 +62,11 @@ function LogoMark({
|
|||||||
fill={BRAND_PLATE}
|
fill={BRAND_PLATE}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
className={
|
className={
|
||||||
plateOutline
|
plateOutline === 'always'
|
||||||
? 'stroke-transparent dark:stroke-white/25'
|
? 'stroke-white/20'
|
||||||
: 'stroke-transparent'
|
: plateOutline
|
||||||
|
? 'stroke-transparent dark:stroke-white/25'
|
||||||
|
: '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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
<TesseraLogo
|
<Link
|
||||||
variant="horizontal"
|
href="/"
|
||||||
size={28}
|
className="flex items-center rounded-md px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
|
||||||
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
|
>
|
||||||
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
|
<TesseraLogo
|
||||||
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
|
variant="horizontal"
|
||||||
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
|
size={26}
|
||||||
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den
|
plateOutline="always"
|
||||||
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x).
|
className="gap-2.5"
|
||||||
wordmarkClassName="hidden sm:inline dark:text-primary"
|
// Schriftzug weiss auf der dunklen Leiste (in beiden
|
||||||
/>
|
// Erscheinungsbildern). Unter 640 px nur die Bildmarke: der Platz
|
||||||
|
// gehoert dort den Dashboard-Reitern (260924-h7x).
|
||||||
|
wordmarkClassName="hidden sm:inline text-[15px] font-semibold tracking-normal text-appbar-foreground"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -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">
|
{activeModules.length === 0 ? (
|
||||||
{t('categories.label')}
|
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||||
</div>
|
{t('categories.empty')}
|
||||||
<div className="mt-1">
|
</div>
|
||||||
{activeModules.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('categories.empty')}
|
{t('noResults')}
|
||||||
</div>
|
</div>
|
||||||
) : filteredCategories.length === 0 ? (
|
) : (
|
||||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
<ul className="flex flex-col gap-2">
|
||||||
{t('noResults')}
|
{filteredCategories.map(([category, mods]) => {
|
||||||
</div>
|
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
|
||||||
) : (
|
const open = searchQuery !== '' || !closedCategories.has(category);
|
||||||
<ul className="flex flex-col gap-1">
|
return (
|
||||||
{filteredCategories.map(([category, mods]) => (
|
|
||||||
<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'}`}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</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" />
|
|
||||||
</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) => {
|
||||||
<li key={mod.id}>
|
const href = `/modules/${category}/${mod.slug}`;
|
||||||
<Link
|
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
||||||
href={`/modules/${category}/${mod.slug}`}
|
return (
|
||||||
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
|
<li key={mod.id}>
|
||||||
isActive(`/modules/${category}/${mod.slug}`)
|
<Link href={href} className={itemClass(active)}>
|
||||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
{active && <SelectionPill />}
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
<ModuleTile slug={mod.slug} size={20} />
|
||||||
}`}
|
<span className="truncate">{mod.name}</span>
|
||||||
>
|
</Link>
|
||||||
{mod.name}
|
</li>
|
||||||
</Link>
|
);
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user