import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; import type { ComponentType } from 'react'; /** * 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). * reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2). */ 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 // 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. // quick-260929-dmx: Raster in der Breite doppelt so fein (48 Spalten am // lg-Breakpoint). Alle Breitenwerte (minW, defaultW) sind gegenueber dem // 24-Spalten-Raster verdoppelt, die Kacheln behalten ihre Bildschirmbreite; // Hoehen (minH, defaultH) unveraendert. Einzige Ausnahme: Kalender-minW. // Existing widgets clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 }, search: { minW: 12, minH: 2, defaultW: 24, 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. // quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind // rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar; // bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das // damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an // der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und // Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 + // (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren // Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite. // Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an, // soweit rechts Platz ist (applyConstraintMinima). calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 }, note: { minW: 8, minH: 4, defaultW: 12, 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: 6, minH: 10, defaultW: 12, defaultH: 10 }, // Favoriten: minW 1 (im 24er-Raster, jetzt 2 von 48) 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: 2, minH: 3, defaultW: 12, defaultH: 10 }, stopwatch: { minW: 8, minH: 3, defaultW: 12, defaultH: 6 }, // quick-260921-pi9: ein Bild braucht Flaeche — 16x12 (im 48er-Raster) // entspricht der Kalender-Vorgabe (rund 530x330 px, Querformat wie ein // Foto); Hoehe 8 war im Browser-Rundgang mit 216 px zu flach. 8x4 ist die // kleinste Kachel, in der Bild und Unterschrift-Streifen noch erkennbar sind. 'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 }, // quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 24x12 im // 48er-Raster = halbe Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren // nur 216 px, zu flach fuer eine Seite; vgl. Bilderrahmen). 8x4 kleinste // Kachel, in der ein Rahmen noch Sinn hat. xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 }, // quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und // Zusammenfassung (die Serverliste blendet sich darunter per Container- // Query aus); 16x8 (im 48er-Raster) = rund 456x216 px bei 1400 px Breite // zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte // und Namen. proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 }, // quick-260929-if2: Liste plus Knopfzeile „Neue Erinnerung“. Im 48er-Raster // sind 8 Spalten = rund 230 px die kleinste Breite, in der Titel, Zeitangabe // und die umbrechenden Knoepfe noch benutzbar sind (wie Notiz und Favoriten); // 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 = // rund 340x270 px: drei bis vier Erinnerungen auf einmal. reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, }; /** * Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht * hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln) * die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird. */ export const GRID_COLS: Record = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }; /** * Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints * (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am * xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte * Breakpoints bleiben unbegrenzt. */ export function clampWidthToCols(width: number, breakpoint: string): number { const cols = GRID_COLS[breakpoint]; return cols === undefined ? width : Math.min(width, cols); } /** * 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 ( ); } function ReminderIcon({ 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 }, reminder: { type: 'reminder', nameKey: 'reminder.name', descriptionKey: 'reminder.description', icon: ReminderIcon, ...WIDGET_CONSTRAINTS.reminder, moduleSlug: WIDGET_MODULE_SLUGS.reminder, 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); }); }