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:
@@ -9,6 +9,20 @@ 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';
|
||||
|
||||
/** 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';
|
||||
|
||||
@@ -25,7 +39,11 @@ export function Sidebar() {
|
||||
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
||||
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
||||
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 sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
|
||||
|
||||
@@ -34,25 +52,34 @@ export function Sidebar() {
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const linkClass = (href: string) =>
|
||||
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${
|
||||
isActive(href)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
: 'text-sidebar-foreground hover:bg-muted'
|
||||
// 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 fetchActiveModules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
setActiveModules(await res.json());
|
||||
const modules: SidebarModule[] = await res.json();
|
||||
setActiveModules(modules);
|
||||
setNavModules(modules);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, []);
|
||||
}, [setNavModules]);
|
||||
|
||||
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
|
||||
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
|
||||
@@ -64,7 +91,7 @@ export function Sidebar() {
|
||||
}, [fetchActiveModules, sidebarRefreshKey]);
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setOpenCategories((prev) => {
|
||||
setClosedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
@@ -85,7 +112,7 @@ export function Sidebar() {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const result: [string, SidebarModule[]][] = [];
|
||||
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]);
|
||||
} else {
|
||||
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
|
||||
@@ -93,115 +120,136 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
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 = (
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
<nav className="flex-1 overflow-y-auto p-3">
|
||||
<ul className="flex flex-col gap-1">
|
||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
<li>
|
||||
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}>
|
||||
<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">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
<Link href="/" className={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={linkClass('/marketplace')} aria-label={t('marketplace')}>
|
||||
<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">
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<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" />
|
||||
</svg>
|
||||
<Link href="/marketplace" className={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 && 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 && (
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<div className="mt-4 border-t border-sidebar-border pt-3">
|
||||
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<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 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{filteredCategories.map(([category, mods]) => (
|
||||
<div className="mt-1" aria-label={t('categories.label')} role="group">
|
||||
{activeModules.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, mods]) => {
|
||||
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
|
||||
const open = searchQuery !== '' || !closedCategories.has(category);
|
||||
return (
|
||||
<li key={category}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCategory(category)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
|
||||
isActive(`/modules/${category}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
aria-expanded={open}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<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" />
|
||||
<span className="truncate">{categoryLabel(category)}</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.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>
|
||||
<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>
|
||||
{openCategories.has(category) && (
|
||||
<ul className="ml-5 flex flex-col gap-0.5">
|
||||
{mods.map((mod) => (
|
||||
<li key={mod.id}>
|
||||
<Link
|
||||
href={`/modules/${category}/${mod.slug}`}
|
||||
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
isActive(`/modules/${category}/${mod.slug}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{mod.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{open && (
|
||||
<ul className="mt-0.5 flex flex-col gap-0.5">
|
||||
{mods.map((mod) => {
|
||||
const href = `/modules/${category}/${mod.slug}`;
|
||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
||||
return (
|
||||
<li key={mod.id}>
|
||||
<Link href={href} className={itemClass(active)}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={mod.slug} size={20} />
|
||||
<span className="truncate">{mod.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
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')}
|
||||
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' : ''}`}>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
<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>
|
||||
@@ -210,7 +258,7 @@ export function Sidebar() {
|
||||
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
|
||||
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-sidebar-border px-4 py-2">
|
||||
<div className="px-5 pb-3 pt-1">
|
||||
<AppVersionBadge />
|
||||
</div>
|
||||
)}
|
||||
@@ -220,7 +268,7 @@ export function Sidebar() {
|
||||
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-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={{
|
||||
width: isCollapsed
|
||||
? 'var(--sidebar-width-collapsed)'
|
||||
@@ -237,22 +285,21 @@ export function Sidebar() {
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
|
||||
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
|
||||
<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={28}
|
||||
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
|
||||
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
|
||||
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
|
||||
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
|
||||
wordmarkClassName="dark:text-primary"
|
||||
/>
|
||||
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="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">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
@@ -260,7 +307,7 @@ export function Sidebar() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{sidebarContent}
|
||||
<div className="min-h-0 flex-1">{sidebarContent}</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user