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:
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
|
||||
/**
|
||||
* Digital clock widget displaying time in a configurable timezone.
|
||||
* Uses Intl.DateTimeFormat — never computes UTC offsets manually (RESEARCH).
|
||||
*
|
||||
* Config:
|
||||
* - timezone: IANA timezone string (default 'Europe/Berlin')
|
||||
* - showDate: boolean (default false) — show date below time
|
||||
*/
|
||||
export function ClockWidget({ config }: WidgetProps) {
|
||||
const timezone = (config.timezone as string) || 'Europe/Berlin';
|
||||
const showDate = config.showDate === true;
|
||||
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setNow(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: timezone,
|
||||
});
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: timezone,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-2">
|
||||
<time
|
||||
role="time"
|
||||
className="font-semibold tabular-nums text-foreground"
|
||||
style={{ fontSize: 'clamp(28px, 4vw, 40px)' }}
|
||||
dateTime={now.toISOString()}
|
||||
>
|
||||
{timeFormatter.format(now)}
|
||||
</time>
|
||||
{showDate && (
|
||||
<span
|
||||
data-testid="clock-date"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{dateFormatter.format(now)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
||||
|
||||
interface WidgetWrapperProps {
|
||||
widget: {
|
||||
id: string;
|
||||
widgetType: string;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
isEditMode: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper card for all dashboard widgets.
|
||||
* In edit mode: shows drag handle (top) and delete button (top-right).
|
||||
* Renders the actual widget component via WIDGET_REGISTRY lookup.
|
||||
*/
|
||||
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||
const WidgetComponent = definition?.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm"
|
||||
data-widget-id={widget.id}
|
||||
role="article"
|
||||
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
||||
>
|
||||
{/* Edit mode: drag handle */}
|
||||
{isEditMode && (
|
||||
<div className="widget-drag-handle flex h-[6px] w-full cursor-grab items-center justify-center bg-muted active:cursor-grabbing">
|
||||
<div className="h-[2px] w-8 rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit mode: delete button */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(widget.id);
|
||||
}}
|
||||
className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label={t('deleteTooltip')}
|
||||
title={t('deleteTooltip')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Widget body */}
|
||||
<div className={`h-full ${isEditMode ? 'pt-0' : ''}`}>
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
config={widget.config}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{widget.widgetType}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user