56c07c3581
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>
145 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|