39ea1474a5
- 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>
132 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|