import type { ComponentType } from 'react'; import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; /** * Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in * `packages/shared/src/index.ts` — dieselbe Liste, gegen die die API in * `create-widget.dto.ts` mit `@IsIn` validiert. Vorher stand sie an sieben * Stellen; vergass man eine, fehlte die Kachel im Katalog oder die API * lehnte sie mit 400 ab. * * Hier weiter-exportiert, weil ein knappes Dutzend Web-Dateien den Typ seit * jeher von der Registry bezieht (dashboard-grid, widget-wrapper, * dashboard-store, widget-settings-panel ...). * * clock/search/calendar/note: implemented in previous plans. * calculator/favorites/stopwatch: Phase 8 additions (der fruehere * Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten * decken den Fall ab). * picture-frame: Bilderrahmen (quick-260921-pi9). * xframe: Webseite als Rahmen (quick-260921-qd3). * proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v). */ export type { WidgetType }; /** * Props contract that every widget component must accept. */ export interface WidgetProps { instanceId: string; config: Record; isEditMode: boolean; } /** * Per-type size constraints for the react-grid-layout grid. * Values from UI-SPEC size table (D-06) and Phase-8 Grid-Constraints (D-01). */ export const WIDGET_CONSTRAINTS: Record< WidgetType, { minW: number; minH: number; defaultW: number; defaultH: number } > = { // quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im // 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 + // Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 = // 268 px -> 10 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH = // altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in // dashboard-grid.tsx aus dieser Tabelle ueberschrieben. // Existing widgets clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, // quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter // den Monat nur ein Termin. calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, // Phase 8 new widgets (D-01) // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=), // Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px. calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, // Favoriten: minW 1 statt 3 (Nutzerwunsch 23.09.) — bei kurzen Linknamen // blieb rechts viel Leerraum. Der Titel kuerzt mit Auslassungszeichen, // in der schmalsten Stufe bleibt das Symbol als Leiste stehen. favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 }, stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, // quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der // Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im // Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel, // in der Bild und Unterschrift-Streifen noch erkennbar sind. 'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 }, // quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 12x12 = halbe // Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren nur 216 px, zu flach // fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein // Rahmen noch Sinn hat. xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 }, // quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und // Zusammenfassung (die Serverliste blendet sich darunter per Container- // Query aus); 8x8 = rund 456x216 px bei 1400 px Breite zeigt etwa sechs // Serverzeilen; 3 Spalten = rund 166 px zeigen nur Punkte und Namen. proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }, }; /** * Definition for a widget type in the catalog. */ export interface WidgetDefinition { type: WidgetType; nameKey: string; descriptionKey: string; /** * Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus * `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel * und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox. */ moduleSlug?: string; /** Inline SVG icon as React component */ icon: ComponentType<{ className?: string }>; minW: number; minH: number; defaultW: number; defaultH: number; /** Lazy-loaded widget component */ component: ComponentType; } /** * Placeholder widget for types not yet implemented. * Renders the widget type name as a centered label. */ function PlaceholderWidget({ config }: WidgetProps) { const label = (config as Record).__widgetType ?? 'Widget'; return (
{label}
); } // --- Icon components (inline SVG, established project pattern) --- function ClockIcon({ className }: { className?: string }) { return ( ); } function SearchIcon({ className }: { className?: string }) { return ( ); } function CalendarIcon({ className }: { className?: string }) { return ( ); } function NoteIcon({ className }: { className?: string }) { return ( ); } function CalculatorIcon({ className }: { className?: string }) { return ( ); } function FavoritesIcon({ className }: { className?: string }) { return ( ); } function StopwatchIcon({ className }: { className?: string }) { return ( ); } function PictureFrameIcon({ className }: { className?: string }) { return ( ); } function XframeIcon({ className }: { className?: string }) { return ( ); } function ProxmoxIcon({ className }: { className?: string }) { return ( ); } /** * Registry of all widget types. Used by the widget catalog modal * and the grid renderer to look up components and constraints. */ export const WIDGET_REGISTRY: Record = { clock: { type: 'clock', nameKey: 'clock.name', descriptionKey: 'clock.description', icon: ClockIcon, ...WIDGET_CONSTRAINTS.clock, moduleSlug: WIDGET_MODULE_SLUGS.clock, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, search: { type: 'search', nameKey: 'search.name', descriptionKey: 'search.description', icon: SearchIcon, ...WIDGET_CONSTRAINTS.search, moduleSlug: WIDGET_MODULE_SLUGS.search, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, calendar: { type: 'calendar', nameKey: 'calendar.name', descriptionKey: 'calendar.description', icon: CalendarIcon, ...WIDGET_CONSTRAINTS.calendar, moduleSlug: WIDGET_MODULE_SLUGS.calendar, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, note: { type: 'note', nameKey: 'note.name', descriptionKey: 'note.description', icon: NoteIcon, ...WIDGET_CONSTRAINTS.note, moduleSlug: WIDGET_MODULE_SLUGS.note, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, calculator: { type: 'calculator', nameKey: 'calculator.name', descriptionKey: 'calculator.description', icon: CalculatorIcon, ...WIDGET_CONSTRAINTS.calculator, moduleSlug: WIDGET_MODULE_SLUGS.calculator, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, favorites: { type: 'favorites', nameKey: 'favorites.name', descriptionKey: 'favorites.description', icon: FavoritesIcon, ...WIDGET_CONSTRAINTS.favorites, moduleSlug: WIDGET_MODULE_SLUGS.favorites, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, stopwatch: { type: 'stopwatch', nameKey: 'stopwatch.name', descriptionKey: 'stopwatch.description', icon: StopwatchIcon, ...WIDGET_CONSTRAINTS.stopwatch, moduleSlug: WIDGET_MODULE_SLUGS.stopwatch, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, 'picture-frame': { type: 'picture-frame', nameKey: 'pictureFrame.name', descriptionKey: 'pictureFrame.description', icon: PictureFrameIcon, ...WIDGET_CONSTRAINTS['picture-frame'], moduleSlug: WIDGET_MODULE_SLUGS['picture-frame'], component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, xframe: { type: 'xframe', nameKey: 'xframe.name', descriptionKey: 'xframe.description', icon: XframeIcon, ...WIDGET_CONSTRAINTS.xframe, moduleSlug: WIDGET_MODULE_SLUGS.xframe, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, proxmox: { type: 'proxmox', nameKey: 'proxmox.name', descriptionKey: 'proxmox.description', icon: ProxmoxIcon, ...WIDGET_CONSTRAINTS.proxmox, // Erste Modul-Kachel: ohne Zugriff auf das Modul 'proxmox' fehlt sie im // Katalog (Komfort) und verbindlich serverseitig auf dem Dashboard. moduleSlug: WIDGET_MODULE_SLUGS.proxmox, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, }; /** * Meldet die Komponente einer Kachel an ihrem Registry-Eintrag an * (quick-260922-m1h — ersetzt die vormals neun `wireXWidget()`-Funktionen * mit je eigenem Bool-Flag). * * Aufgerufen wird sie in `apps/web/src/app/(portal)/page.tsx` und NICHT * hier: die Komponenten duerfen nicht aus der Registry heraus importiert * werden, sonst entsteht ein Zirkelimport (jede Kachel importiert ueber den * Wrapper wieder die Registry). Die Seite ist die Stelle, an der beides * zusammenkommt. * * Mehrfachanmeldung desselben Typs ist ein No-Op — die erste gewinnt, genau * wie die alten Flags. Ein Typ, der nicht in `WIDGET_TYPES` steht, wirft in * der Entwicklung (dann fehlt der Eintrag in `packages/shared`) und wird in * der Produktion ignoriert, damit eine vergessene Kachel nicht das ganze * Dashboard mitreisst. */ const registeredTypes = new Set(); export function registerWidget(type: WidgetType, component: ComponentType) { const definition: WidgetDefinition | undefined = WIDGET_REGISTRY[type]; if (!definition) { if (process.env.NODE_ENV !== 'production') { throw new Error( `registerWidget: unbekannter Widget-Typ "${type}". Fehlt der Typ in WIDGET_TYPES (packages/shared/src/index.ts)?`, ); } return; } if (registeredTypes.has(type)) return; definition.component = component; registeredTypes.add(type); } /** * Welche Kacheln der Katalog zeigen darf (quick-260922-m1h). * * Eine Kachel ohne `moduleSlug` ist immer sichtbar; eine Kachel MIT * `moduleSlug` nur, wenn der Slug unter den zugaenglichen Modulen steht. * `accessibleModuleSlugs === null` heisst "Modulliste unbekannt" (Abruf * laeuft noch oder ist fehlgeschlagen) — dann sind Modul-Kacheln * ausgeblendet, fail-closed wie serverseitig. * * ACHTUNG (T-M1H-01): Das hier ist reiner Komfort — es verhindert nur, dass * jemand eine Kachel anlegt, die ihm danach kommentarlos wieder verschwindet. * Die verbindliche Durchsetzung bleibt serverseitig in * `DashboardService.getWidgets` (fail-closed) und im Modul-Guard der * jeweiligen Daten-Endpunkte. Diesen Filter zu umgehen bringt nichts. * * Reine Funktion mit der Registry als Parameter, damit sie ohne eine echte * Modul-Kachel testbar ist (Muster `picture-frame-config.ts`). */ export function visibleWidgetTypes( registry: Record>, accessibleModuleSlugs: readonly string[] | null, ): T[] { const types = Object.keys(registry) as T[]; return types.filter((type) => { const slug = registry[type].moduleSlug; if (slug === undefined) return true; if (accessibleModuleSlugs === null) return false; return accessibleModuleSlugs.includes(slug); }); }