feat(05-01): dashboard grid, clock widget, widget registry, store, and tests

- Install react-grid-layout@2.2.3 and react-resizable
- Create widget-registry.tsx with all 4 widget types, WIDGET_CONSTRAINTS, WidgetProps
- Create dashboard-api.ts with fetch/save layout and widget CRUD functions
- Create dashboard-store.ts (Zustand, NO persist — D-05) with edit mode and auto-save on exit
- Create DashboardGrid with react-grid-layout v2 Responsive, ResizeObserver width
- Create ClockWidget using Intl.DateTimeFormat (no manual UTC offsets)
- Create WidgetWrapper with drag handle and delete button in edit mode
- Create EditModeToggle (pencil/checkmark), WidgetCatalogModal (2x2 grid)
- Rewrite portal page.tsx as dashboard with grid, edit toggle, widget catalog
- Add ResizeObserver polyfill in test setup, CSS mock support in vitest config
- All 5 tests green (dashboard grid + clock widget)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:21:43 +02:00
parent 792b7661d8
commit d5e1c42e64
14 changed files with 1115 additions and 29 deletions
@@ -0,0 +1,124 @@
'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'];
/**
* 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}
aria-hidden="true"
>
{/* Backdrop */}
<div 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"
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>
);
}