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
+98 -29
View File
@@ -1,42 +1,111 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { wireClockWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
// Wire ClockWidget into the registry (deferred to avoid circular deps)
wireClockWidget(ClockWidget);
export default function DashboardPage() {
const t = useTranslations('dashboard');
const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false);
const {
layouts,
widgets,
isEditMode,
isLoading,
error,
setEditMode,
updateLayouts,
addWidget,
removeWidget,
loadDashboard,
} = useDashboardStore();
// Load dashboard data on mount
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
);
}
if (error) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-destructive">{t('layoutLoadError')}</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
{/* Grid icon */}
<div className="rounded-lg bg-muted p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
<div className="relative p-4">
{/* Edit mode toggle — top right */}
<div className="absolute right-4 top-4 z-10">
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Empty state text (D-16) */}
<p className="text-lg text-muted-foreground">
{t('empty')}
</p>
{/* Dashboard grid */}
<div className="mt-8">
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
</div>
{/* Add widget button */}
<button className="mt-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity">
{t('addWidget')}
</button>
{/* Add widget button (edit mode only) */}
{isEditMode && (
<div className="fixed bottom-6 right-6 z-20">
<button
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('addWidget')}
</button>
</div>
)}
{/* Widget catalog modal */}
<WidgetCatalogModal
isOpen={catalogOpen}
onClose={() => setCatalogOpen(false)}
onAddWidget={(type: WidgetType) => {
addWidget(type);
}}
/>
</div>
);
}