'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 (
);
}
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([]);
const [customModules, setCustomModules] = useState([]);
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>(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/ 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(
() => [...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();
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) => (
);
const sidebarContent = (
{/* Begruessung (Design „Mosaik“, Runde 3): im sonst leeren unteren
Bereich der Leiste statt ueber den Kacheln; eingeklappt entfaellt sie. */}
{!isCollapsed && (
)}
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
{!isCollapsed && (