Files
tessera-ctl/apps/web/src/components/dashboard/widget-catalog-modal.tsx
T
schalli 39ea1474a5 feat: Link-Widget restlos entfernt (Web, API, Migration, Handbuch, Changelog)
- Web: Union-Mitglied, Constraints, Icon, Registry-Eintrag, wire-Funktion,
  Katalog, Seiten-Verdrahtung, Tests und i18n (de/en) fuer den Typ link
  entfernt; link-widget.tsx/.test.tsx geloescht
- API: create-widget.dto.ts (@IsIn-Liste) und widget-module-map.ts
  (Kommentare) auf sieben Typen angepasst; schema.prisma unveraendert
- Neue Migration 20260916120000_remove_link_widget: eine idempotente
  DELETE-Anweisung auf WidgetInstance, FavoriteLink kaskadiert ueber den
  bestehenden FK; wird in diesem Auftrag NICHT ausgefuehrt
- Neuer widget-wrapper-Test belegt den grauen Fallback fuer unbekannte
  Widget-Typen (Bestandsverhalten, jetzt festgeschrieben)
- Handbuch (Widget-Tabelle, Einstellungs-Hinweise) und CHANGELOG
  (Geaendert/Entfernt/Behoben) aktualisiert
- Web: 52 Dateien / 344 Tests gruen, tsc Exit 0; API: tsc Exit 0,
  dashboard-Spec 31 Tests gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:44:09 +02:00

132 lines
3.8 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from './widget-registry';
interface WidgetCatalogModalProps {
isOpen: boolean;
onClose: () => void;
onAddWidget: (type: WidgetType) => void;
}
const WIDGET_TYPES: WidgetType[] = [
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
];
/**
* Modal dialog showing available widget types as selectable cards.
* 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,
}: WidgetCatalogModalProps) {
const t = useTranslations('widgets');
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"
onClick={onClose}
>
{/* Backdrop — visually only, hidden from assistive tech */}
<div className="fixed inset-0 bg-black/50" aria-hidden="true" />
{/* 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"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{t('catalogTitle')}
</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<svg
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">
{WIDGET_TYPES.map((type) => {
const def = WIDGET_REGISTRY[type];
const Icon = def.icon;
return (
<button
key={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>
);
}