12214a948e
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis - Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt - optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht - Titel-Schalter mit fester Beschriftung + aria-pressed - Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog - Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck - Uebersetzungen und CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
589 lines
20 KiB
TypeScript
589 lines
20 KiB
TypeScript
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<string, unknown>;
|
|
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<string, number> = { 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<WidgetProps>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, string>).__widgetType ?? 'Widget';
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Icon components (inline SVG, established project pattern) ---
|
|
|
|
function ClockIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SearchIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CalendarIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function NoteIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<polyline points="10 9 9 9 8 9" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CalculatorIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<rect x="4" y="2" width="16" height="20" rx="2" />
|
|
<line x1="8" y1="6" x2="16" y2="6" />
|
|
<line x1="8" y1="10" x2="8" y2="10" />
|
|
<line x1="12" y1="10" x2="12" y2="10" />
|
|
<line x1="16" y1="10" x2="16" y2="10" />
|
|
<line x1="8" y1="14" x2="8" y2="14" />
|
|
<line x1="12" y1="14" x2="12" y2="14" />
|
|
<line x1="16" y1="14" x2="16" y2="18" />
|
|
<line x1="8" y1="18" x2="8" y2="18" />
|
|
<line x1="12" y1="18" x2="12" y2="18" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FavoritesIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function StopwatchIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<circle cx="12" cy="13" r="8" />
|
|
<polyline points="12 9 12 13 14 15" />
|
|
<line x1="9" y1="2" x2="15" y2="2" />
|
|
<line x1="12" y1="2" x2="12" y2="4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PictureFrameIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function XframeIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
<line x1="3" y1="9" x2="21" y2="9" />
|
|
<circle cx="6.5" cy="6.5" r="0.5" />
|
|
<circle cx="9.5" cy="6.5" r="0.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ProxmoxIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
|
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
|
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
|
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ReminderIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
>
|
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<WidgetType, WidgetDefinition> = {
|
|
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<WidgetType>();
|
|
|
|
export function registerWidget(type: WidgetType, component: ComponentType<WidgetProps>) {
|
|
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<T extends string>(
|
|
registry: Record<T, Pick<WidgetDefinition, 'moduleSlug'>>,
|
|
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);
|
|
});
|
|
}
|