00c2cfe2c9
Kacheln 24 statt 20 px, Navigationssymbole 22 statt 20 px, Eintraege 34 px hoch mit 1 px Abstand (vorher 36 + 2 px) — Nutzerwunsch 29.09. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useTranslations } from 'next-intl';
|
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
|
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
|
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';
|
|
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
|
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
|
|
|
/** 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';
|
|
|
|
interface SidebarModule {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
category: string;
|
|
}
|
|
|
|
/**
|
|
* Ein Eintrag der Seitenleiste: eingebautes Modul ODER eigenes Modul
|
|
* (quick-260929-9wc). Beide laufen durch dieselbe Gruppierung, Suche und
|
|
* Kachelliste; sie unterscheiden sich nur in Ziel und Kachelsymbol.
|
|
*/
|
|
interface SidebarEntry {
|
|
key: string;
|
|
name: string;
|
|
category: string;
|
|
href: string;
|
|
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
|
|
tileSlug: string;
|
|
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
|
|
extraActiveHref?: string;
|
|
}
|
|
|
|
function moduleEntry(mod: SidebarModule): SidebarEntry {
|
|
const category = mod.category || 'other';
|
|
return {
|
|
key: mod.id,
|
|
name: mod.name,
|
|
category,
|
|
href: `/modules/${category}/${mod.slug}`,
|
|
tileSlug: mod.slug,
|
|
extraActiveHref: `/modules/${mod.slug}`,
|
|
};
|
|
}
|
|
|
|
function customEntry(mod: CustomModule): SidebarEntry {
|
|
return {
|
|
key: `custom-${mod.id}`,
|
|
name: mod.name,
|
|
category: mod.category || 'other',
|
|
href: `/modules/custom/${mod.id}`,
|
|
tileSlug: 'custom',
|
|
};
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const t = useTranslations('sidebar');
|
|
const tCommon = useTranslations('common');
|
|
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
|
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
|
const [customModules, setCustomModules] = useState<CustomModule[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
// 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 sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
|
|
|
|
const isActive = (href: string) => {
|
|
if (href === '/') return pathname === '/';
|
|
return pathname.startsWith(href);
|
|
};
|
|
|
|
// Design „Mosaik“ (Fluent 2): 36 px hohe Eintraege, 8 px Radius, die
|
|
// Auswahl traegt eine neutrale Flaeche, halbfette Schrift und links eine
|
|
// 3 x 16 px grosse Pille in der Akzentfarbe (`SelectionPill`).
|
|
const itemClass = (active: boolean) =>
|
|
`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 isEntryActive = (entry: SidebarEntry) =>
|
|
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
|
|
|
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
|
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
|
// die 20-px-Modulkachel passt weiter hinein.
|
|
const moduleItemClass = (active: boolean) =>
|
|
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
|
|
|
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
|
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
|
// mit 1 px Abstand statt 36 + 2 px.
|
|
const collapsedItemClass = (active: boolean) => itemClass(active).replace('h-9', 'h-[34px]');
|
|
|
|
const fetchActiveModules = useCallback(async () => {
|
|
const loadBuiltIn = async () => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/modules/active`, {
|
|
credentials: 'include',
|
|
});
|
|
if (res.ok) {
|
|
setActiveModules(await res.json());
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
}
|
|
};
|
|
// Eigene Module (quick-260929-9wc) im selben Auffrisch-Takt; ein Fehler
|
|
// laesst die Leiste wie beim Modulabruf still beim bisherigen Stand.
|
|
const loadCustom = async () => {
|
|
try {
|
|
setCustomModules(await listCustomModules());
|
|
} catch {
|
|
// silently fail
|
|
}
|
|
};
|
|
await Promise.all([loadBuiltIn(), loadCustom()]);
|
|
}, []);
|
|
|
|
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
|
|
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
|
|
// Pfadsegment /modules/custom/<id> ohne Aenderung findet.
|
|
useEffect(() => {
|
|
setNavModules([
|
|
...activeModules,
|
|
...customModules.map((m) => ({
|
|
id: m.id,
|
|
slug: m.id,
|
|
name: m.name,
|
|
category: m.category,
|
|
})),
|
|
]);
|
|
}, [activeModules, customModules, setNavModules]);
|
|
|
|
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
|
|
const entries = useMemo<SidebarEntry[]>(
|
|
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
|
|
[activeModules, customModules],
|
|
);
|
|
|
|
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
|
|
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
|
|
// aktiviertes Modul in der Seitenleiste erst nach einem Neuladen der
|
|
// Seite, die Navigation liefe also dem Berechtigungsstand hinterher.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: sidebarRefreshKey ist der Auffrisch-Ausloeser aus dem Marketplace-Speicher - ohne ihn liefe die Navigation dem Berechtigungsstand hinterher.
|
|
useEffect(() => {
|
|
fetchActiveModules();
|
|
}, [fetchActiveModules, sidebarRefreshKey]);
|
|
|
|
const toggleCategory = (cat: string) => {
|
|
setClosedCategories((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(cat)) next.delete(cat);
|
|
else next.add(cat);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const filteredCategories = useMemo(() => {
|
|
const categories = new Map<string, SidebarEntry[]>();
|
|
for (const entry of entries) {
|
|
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
|
categories.get(entry.category)!.push(entry);
|
|
}
|
|
|
|
if (!searchQuery) return Array.from(categories.entries());
|
|
|
|
const q = searchQuery.toLowerCase();
|
|
const result: [string, SidebarEntry[]][] = [];
|
|
for (const [cat, mods] of categories.entries()) {
|
|
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
|
|
result.push([cat, mods]);
|
|
} else {
|
|
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
|
|
if (filtered.length > 0) result.push([cat, filtered]);
|
|
}
|
|
}
|
|
return result;
|
|
}, [entries, searchQuery, categoryLabel]);
|
|
|
|
const navIcon = (children: React.ReactNode) => (
|
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
|
{children}
|
|
</svg>
|
|
);
|
|
|
|
const sidebarContent = (
|
|
<div className="flex h-full flex-col bg-sidebar">
|
|
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
|
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
|
<li>
|
|
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
|
{isActive('/') && <SelectionPill />}
|
|
{navIcon(
|
|
<>
|
|
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
|
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
|
<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>}
|
|
</Link>
|
|
</li>
|
|
<li>
|
|
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
|
{isActive('/marketplace') && <SelectionPill />}
|
|
{navIcon(
|
|
<>
|
|
<path d="M3 9h18l-1.5 10.5a2 2 0 0 1-2 1.5h-11a2 2 0 0 1-2-1.5z" />
|
|
<path d="M8 9V7a4 4 0 0 1 8 0v2" />
|
|
</>,
|
|
)}
|
|
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
|
|
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
|
|
{isCollapsed && entries.length > 0 && (
|
|
<ul className="mt-3 flex flex-col gap-px border-t border-sidebar-border pt-3">
|
|
{entries.map((entry) => {
|
|
const active = isEntryActive(entry);
|
|
return (
|
|
<li key={entry.key}>
|
|
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
|
|
{active && <SelectionPill />}
|
|
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{!isCollapsed && (
|
|
<>
|
|
<div className="mt-4 border-t border-sidebar-border pt-3">
|
|
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
|
|
</div>
|
|
|
|
<div className="mt-1" aria-label={t('categories.label')} role="group">
|
|
{entries.length === 0 ? (
|
|
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
|
{t('categories.empty')}
|
|
</div>
|
|
) : filteredCategories.length === 0 ? (
|
|
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
|
{t('noResults')}
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-2">
|
|
{filteredCategories.map(([category, categoryEntries]) => {
|
|
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
|
|
const open = searchQuery !== '' || !closedCategories.has(category);
|
|
return (
|
|
<li key={category}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCategory(category)}
|
|
aria-expanded={open}
|
|
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm 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"
|
|
>
|
|
<span className="truncate">{categoryLabel(category)}</span>
|
|
<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.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>
|
|
</button>
|
|
{open && (
|
|
<ul className="mt-0.5 flex flex-col gap-0.5">
|
|
{categoryEntries.map((entry) => {
|
|
const active = isEntryActive(entry);
|
|
return (
|
|
<li key={entry.key}>
|
|
<Link href={entry.href} className={moduleItemClass(active)}>
|
|
{active && <SelectionPill />}
|
|
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
|
<span className="truncate">{entry.name}</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Begruessung (Design „Mosaik“, Runde 3): im sonst leeren unteren
|
|
Bereich der Leiste statt ueber den Kacheln; eingeklappt entfaellt sie. */}
|
|
{!isCollapsed && (
|
|
<div className="border-t border-sidebar-border px-5 py-2.5">
|
|
<DashboardGreeting />
|
|
</div>
|
|
)}
|
|
|
|
<div className="hidden border-t border-sidebar-border px-3 py-2 md:block">
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
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')}
|
|
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="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<path d="M9 3v18" />
|
|
<path d="m16 15-3-3 3-3" />
|
|
</svg>
|
|
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
|
|
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
|
|
{!isCollapsed && (
|
|
<div className="px-5 pb-3 pt-1">
|
|
<AppVersionBadge />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
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={{
|
|
width: isCollapsed
|
|
? 'var(--sidebar-width-collapsed)'
|
|
: 'var(--sidebar-width)',
|
|
}}
|
|
>
|
|
{sidebarContent}
|
|
</aside>
|
|
|
|
{isMobileOpen && (
|
|
<div className="fixed inset-0 z-[60] md:hidden">
|
|
<div
|
|
className="fixed inset-0 bg-black/50"
|
|
onClick={() => setMobileOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<aside className="fixed left-0 top-0 z-50 flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar shadow-xl">
|
|
{/* 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
|
|
variant="horizontal"
|
|
size={26}
|
|
plateOutline="always"
|
|
className="gap-2.5"
|
|
wordmarkClassName="text-[15px] font-semibold tracking-normal text-appbar-foreground"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMobileOpen(false)}
|
|
aria-label={tCommon('close')}
|
|
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">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="min-h-0 flex-1">{sidebarContent}</div>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|