feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite

- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:25:33 +02:00
parent 643b1a2caa
commit b9d87be360
23 changed files with 1270 additions and 40 deletions
+106 -35
View File
@@ -13,6 +13,7 @@ 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() {
@@ -34,11 +35,50 @@ interface SidebarModule {
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.
@@ -67,6 +107,9 @@ export function Sidebar() {
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.
@@ -74,19 +117,50 @@ export function Sidebar() {
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
const fetchActiveModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
const loadBuiltIn = async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
setActiveModules(await res.json());
}
} catch {
// silently fail
}
} catch {
// silently fail
}
}, [setNavModules]);
};
// 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
@@ -107,17 +181,16 @@ export function Sidebar() {
};
const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarModule[]>();
for (const mod of activeModules) {
const cat = mod.category || 'other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(mod);
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, SidebarModule[]][] = [];
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]);
@@ -127,7 +200,7 @@ export function Sidebar() {
}
}
return result;
}, [activeModules, searchQuery, categoryLabel]);
}, [entries, 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">
@@ -168,16 +241,15 @@ export function Sidebar() {
</ul>
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && activeModules.length > 0 && (
{isCollapsed && entries.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}`);
{entries.map((entry) => {
const active = isEntryActive(entry);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
<li key={entry.key}>
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
</Link>
</li>
);
@@ -192,7 +264,7 @@ export function Sidebar() {
</div>
<div className="mt-1" aria-label={t('categories.label')} role="group">
{activeModules.length === 0 ? (
{entries.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
@@ -202,7 +274,7 @@ export function Sidebar() {
</div>
) : (
<ul className="flex flex-col gap-2">
{filteredCategories.map(([category, mods]) => {
{filteredCategories.map(([category, categoryEntries]) => {
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
const open = searchQuery !== '' || !closedCategories.has(category);
return (
@@ -220,15 +292,14 @@ export function Sidebar() {
</button>
{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}`);
{categoryEntries.map((entry) => {
const active = isEntryActive(entry);
return (
<li key={mod.id}>
<Link href={href} className={moduleItemClass(active)}>
<li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{mod.name}</span>
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{entry.name}</span>
</Link>
</li>
);