Files
tessera-ctl/apps/web/src/components/dashboard/widget-catalog-modal.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

145 lines
4.8 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType, visibleWidgetTypes } from './widget-registry';
interface WidgetCatalogModalProps {
isOpen: boolean;
onClose: () => void;
onAddWidget: (type: WidgetType) => void;
/**
* Slugs der Module, die der angemeldete Benutzer nutzen darf — geholt von
* der Dashboard-Seite ueber `GET /modules/active` (quick-260922-m1h).
* `null` heisst "noch unbekannt oder Abruf fehlgeschlagen": dann bleiben
* Kacheln MIT `moduleSlug` ausgeblendet (fail-closed).
*
* Der Abruf steht bewusst NICHT in diesem Dialog, damit er ein reines
* Anzeige-Bauteil bleibt und ohne Netzwerk-Attrappe testbar ist.
*/
accessibleModuleSlugs: readonly string[] | null;
}
/**
* Modal dialog showing available widget types as selectable cards.
*
* quick-260922-m1h: Die Liste kommt aus WIDGET_REGISTRY (Reihenfolge der
* Registry-Definition) statt aus einer zweiten, hier gepflegten Liste — eine
* neue Kachel musste sonst an zwei Stellen eingetragen werden.
* Click on a card adds the widget to the dashboard and closes the modal.
* Escape to close, click outside to close, focus trap (D-01 flow).
*/
export function WidgetCatalogModal({
isOpen,
onClose,
onAddWidget,
accessibleModuleSlugs,
}: WidgetCatalogModalProps) {
const t = useTranslations('widgets');
const tCommon = useTranslations('common');
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap + Escape handler
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
// Focus the dialog on open
dialogRef.current?.focus();
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Hintergrund — jetzt eine echte, benannte Schaltflaeche statt einer rein
optischen Flaeche: ein fokussierbares Element darf vor der Vorlesehilfe
nicht verborgen sein (D-01). */}
<button
type="button"
onClick={onClose}
aria-label={t('catalogClose')}
className="fixed inset-0 bg-black/50"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('catalogTitle')}
className="relative z-50 w-full max-w-[480px] rounded-lg border bg-card p-6 shadow-xl"
tabIndex={-1}
>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{t('catalogTitle')}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon('close')}
>
<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="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* 2x2 grid of widget type cards */}
<div className="grid grid-cols-2 gap-3">
{visibleWidgetTypes(WIDGET_REGISTRY, accessibleModuleSlugs).map((type) => {
const def = WIDGET_REGISTRY[type];
const Icon = def.icon;
return (
<button
key={type}
type="button"
data-widget-type={type}
onClick={() => {
onAddWidget(type);
onClose();
}}
className="flex flex-col items-center gap-2 rounded-lg border bg-card p-4 text-center transition-colors hover:border-primary hover:bg-muted"
aria-label={t(def.nameKey)}
>
<div className="flex h-14 w-14 items-center justify-center rounded-lg bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<span className="text-sm font-semibold text-foreground">
{t(def.nameKey)}
</span>
<span className="text-xs text-muted-foreground">
{t(def.descriptionKey)}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}