Files
tessera-ctl/apps/web/src/app/(portal)/page.tsx
T
schalli 56c07c3581 refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul
Ein neuer Widget-Typ war an sieben Stellen einzutragen; vergass man eine,
fehlte die Kachel im Katalog oder die API lehnte sie mit 400 ab.

- WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS stehen jetzt einmal in
  packages/shared; Registry, Katalog und die @IsIn-Whitelist der API
  leiten davon ab
- neun wireXWidget()-Funktionen durch ein generisches registerWidget()
  ersetzt (idempotent, unbekannter Typ wirft in der Entwicklung)
- der Katalog fuehrt keine zweite Typliste mehr, sondern leitet sie aus
  der Registry ab und filtert nach Modulzugriff (fail-closed, wenn die
  Modulliste unbekannt ist); der Abruf von /modules/active liegt auf der
  Dashboard-Seite, nicht im Dialog
- widget-module-map.ts liest die geteilte Tabelle statt einer Kopie, die
  oeffentliche Funktion bleibt unveraendert

Der Katalogfilter ist Komfort (T-M1H-01) — verbindlich bleibt der
serverseitige Filter in DashboardService.getWidgets.

Abweichung vom Plan: apps/web hing entgegen der Planannahme noch nicht
von @tessera/shared ab; die Abhaengigkeit wurde ergaenzt (Lockfile). Die
Dockerfiles kopieren packages/shared bereits, der Produktionsbau von
Next.js und der nest build laufen unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:03:58 +02:00

164 lines
6.0 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// Anmeldung der Kachel-Komponenten an der Registry. Steht hier und nicht in
// der Registry selbst, weil die Komponenten ueber den Wrapper wieder die
// Registry importieren — ein Import aus der Registry heraus waere ein
// Zirkelimport. Seit quick-260922-m1h EINE Funktion statt neun `wireXWidget`.
registerWidget('clock', ClockWidget);
registerWidget('search', SearchWidget);
registerWidget('calendar', CalendarWidget);
registerWidget('note', NoteWidget);
registerWidget('calculator', CalculatorWidget);
registerWidget('stopwatch', StopwatchWidget);
registerWidget('favorites', FavoritesWidget);
registerWidget('picture-frame', PictureFrameWidget);
registerWidget('xframe', XframeWidget);
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
interface ActiveModule {
slug: string;
}
export default function DashboardPage() {
const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false);
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
const {
layouts,
widgets,
isEditMode,
isLoading,
error,
setEditMode,
updateLayouts,
addWidget,
removeWidget,
loadDashboard,
} = useDashboardStore();
// Load dashboard data on mount
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
// Zugaengliche Module holen — gleiches Muster wie die Seitenleiste
// (`components/layout/sidebar.tsx`): derselbe Endpunkt, derselbe
// Sitzungs-Keks, Fehler still. Der Abruf steht hier und nicht im Dialog,
// damit der Dialog ein reines Anzeige-Bauteil bleibt.
const fetchAccessibleModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (!res.ok) return;
const modules: ActiveModule[] = await res.json();
setAccessibleModuleSlugs(modules.map((m) => m.slug));
} catch {
// still: die Liste bleibt null, der Katalog zeigt dann nur
// Plattform-Kacheln (fail-closed).
}
}, []);
useEffect(() => {
fetchAccessibleModules();
}, [fetchAccessibleModules]);
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
);
}
if (error) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-destructive">{t('layoutLoadError')}</p>
</div>
);
}
return (
<div className="relative p-2">
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
{/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && (
<button
type="button"
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<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"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('addWidget')}
</button>
)}
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Widget catalog modal */}
<WidgetCatalogModal
isOpen={catalogOpen}
onClose={() => setCatalogOpen(false)}
accessibleModuleSlugs={accessibleModuleSlugs}
onAddWidget={(type: WidgetType) => {
addWidget(type);
}}
/>
</div>
);
}