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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock CSS imports that vitest cannot resolve
|
||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||
vi.mock('react-resizable/css/styles.css', () => ({}));
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Responsive } from 'react-grid-layout';
|
||||
import type { ResponsiveLayouts } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
|
||||
import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
|
||||
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 1 };
|
||||
|
||||
interface DashboardGridProps {
|
||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
|
||||
isEditMode: boolean;
|
||||
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
|
||||
onRemoveWidget: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard grid wrapper using react-grid-layout v2 Responsive component.
|
||||
* Renders widgets in a draggable/resizable grid when in edit mode.
|
||||
* Shows empty state when no widgets exist.
|
||||
*
|
||||
* v2 API: uses dragConfig/resizeConfig instead of isDraggable/isResizable.
|
||||
*/
|
||||
export function DashboardGrid({
|
||||
layouts,
|
||||
widgets,
|
||||
isEditMode,
|
||||
onLayoutChange,
|
||||
onRemoveWidget,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(1200);
|
||||
|
||||
// Measure container width (v2 requires explicit width — Pitfall 1)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Empty state (D-02)
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||
{t('emptyHeading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('emptyBody')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={layouts as ResponsiveLayouts}
|
||||
rowHeight={40}
|
||||
margin={[16, 16] as [number, number]}
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
handle: '.widget-drag-handle',
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||
>
|
||||
{widgets.map((widget) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={{
|
||||
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 2,
|
||||
h: constraints?.defaultH ?? 2,
|
||||
}),
|
||||
minW: constraints?.minW ?? 2,
|
||||
minH: constraints?.minH ?? 2,
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onRemove={onRemoveWidget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface EditModeToggleProps {
|
||||
isEditMode: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
|
||||
* Active state (edit mode) uses primary background.
|
||||
*/
|
||||
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`inline-flex items-center justify-center rounded-md p-2 transition-colors ${
|
||||
isEditMode
|
||||
? 'bg-primary text-primary-foreground hover:opacity-90'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
aria-pressed={isEditMode}
|
||||
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
|
||||
title={isEditMode ? t('saveChanges') : t('editMode')}
|
||||
>
|
||||
{isEditMode ? (
|
||||
// Checkmark icon
|
||||
<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"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
// Pencil icon
|
||||
<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"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Supported widget types for the dashboard.
|
||||
* clock is fully implemented in this plan; search/calendar/note are
|
||||
* declared here so the catalog shows all four types immediately.
|
||||
*/
|
||||
export type WidgetType = 'clock' | 'search' | 'calendar' | 'note';
|
||||
|
||||
/**
|
||||
* Props contract that every widget component must accept.
|
||||
*/
|
||||
export interface WidgetProps {
|
||||
instanceId: string;
|
||||
config: Record<string, unknown>;
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-type size constraints for the react-grid-layout grid.
|
||||
* Values from UI-SPEC size table (D-06).
|
||||
*/
|
||||
export const WIDGET_CONSTRAINTS: Record<
|
||||
WidgetType,
|
||||
{ minW: number; minH: number; defaultW: number; defaultH: number }
|
||||
> = {
|
||||
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
|
||||
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
|
||||
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
|
||||
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Definition for a widget type in the catalog.
|
||||
*/
|
||||
export interface WidgetDefinition {
|
||||
type: WidgetType;
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
/** Inline SVG icon as React component */
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
minW: number;
|
||||
minH: number;
|
||||
defaultW: number;
|
||||
defaultH: number;
|
||||
/** Lazy-loaded widget component */
|
||||
component: ComponentType<WidgetProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder widget for types not yet implemented.
|
||||
* Renders the widget type name as a centered label.
|
||||
*/
|
||||
function PlaceholderWidget({ config }: WidgetProps) {
|
||||
const label = (config as Record<string, string>).__widgetType ?? 'Widget';
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Icon components (inline SVG, established project pattern) ---
|
||||
|
||||
function ClockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all widget types. Used by the widget catalog modal
|
||||
* and the grid renderer to look up components and constraints.
|
||||
*
|
||||
* clock: Fully implemented this plan.
|
||||
* search, calendar, note: Placeholder components — implemented in later plans.
|
||||
*/
|
||||
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
clock: {
|
||||
type: 'clock',
|
||||
nameKey: 'clock.name',
|
||||
descriptionKey: 'clock.description',
|
||||
icon: ClockIcon,
|
||||
...WIDGET_CONSTRAINTS.clock,
|
||||
// Lazy import replaced at module level for clock
|
||||
component: PlaceholderWidget, // Replaced below
|
||||
},
|
||||
search: {
|
||||
type: 'search',
|
||||
nameKey: 'search.name',
|
||||
descriptionKey: 'search.description',
|
||||
icon: SearchIcon,
|
||||
...WIDGET_CONSTRAINTS.search,
|
||||
component: PlaceholderWidget,
|
||||
},
|
||||
calendar: {
|
||||
type: 'calendar',
|
||||
nameKey: 'calendar.name',
|
||||
descriptionKey: 'calendar.description',
|
||||
icon: CalendarIcon,
|
||||
...WIDGET_CONSTRAINTS.calendar,
|
||||
component: PlaceholderWidget,
|
||||
},
|
||||
note: {
|
||||
type: 'note',
|
||||
nameKey: 'note.name',
|
||||
descriptionKey: 'note.description',
|
||||
icon: NoteIcon,
|
||||
...WIDGET_CONSTRAINTS.note,
|
||||
component: PlaceholderWidget,
|
||||
},
|
||||
};
|
||||
|
||||
// Wire actual ClockWidget lazily to avoid circular deps
|
||||
// (import is deferred so widget-registry can be imported by tests without
|
||||
// pulling in the entire React tree)
|
||||
let clockWired = false;
|
||||
export function wireClockWidget(
|
||||
component: ComponentType<WidgetProps>,
|
||||
) {
|
||||
if (!clockWired) {
|
||||
WIDGET_REGISTRY.clock.component = component;
|
||||
clockWired = true;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Dashboard API client functions.
|
||||
* All calls use credentials: 'include' for cookie-based auth.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export async function fetchLayout(): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch layout');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function saveLayout(
|
||||
layouts: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ layouts }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save layout');
|
||||
}
|
||||
|
||||
export async function fetchWidgets(): Promise<
|
||||
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
|
||||
> {
|
||||
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch widgets');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addWidget(
|
||||
widgetType: string,
|
||||
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
|
||||
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ widgetType }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add widget');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeWidget(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/widgets/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove widget');
|
||||
}
|
||||
|
||||
export async function updateWidgetConfig(
|
||||
id: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/widgets/${id}/config`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update widget config');
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { create } from 'zustand';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
widgetType: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||
widgets: WidgetInstance[];
|
||||
isEditMode: boolean;
|
||||
isDirty: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
setEditMode: (mode: boolean) => void;
|
||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||
addWidget: (widgetType: WidgetType) => Promise<void>;
|
||||
removeWidget: (id: string) => Promise<void>;
|
||||
loadDashboard: () => Promise<void>;
|
||||
saveLayout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
|
||||
* Save only on exiting edit mode (D-01), not on every drag.
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [],
|
||||
isEditMode: false,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setEditMode: (mode: boolean) => {
|
||||
const prev = get().isEditMode;
|
||||
set({ isEditMode: mode });
|
||||
// On exit edit mode, auto-save if dirty (D-01)
|
||||
if (prev && !mode && get().isDirty) {
|
||||
get().saveLayout();
|
||||
}
|
||||
},
|
||||
|
||||
updateLayouts: (layouts) => {
|
||||
set({
|
||||
layouts: layouts as DashboardState['layouts'],
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
addWidget: async (widgetType: WidgetType) => {
|
||||
try {
|
||||
const newWidget = await api.addWidget(widgetType);
|
||||
const constraints = WIDGET_CONSTRAINTS[widgetType];
|
||||
|
||||
set((state) => {
|
||||
// Add default layout entry for all breakpoints
|
||||
const newLayouts = { ...state.layouts };
|
||||
for (const bp of Object.keys(newLayouts)) {
|
||||
const arr = [...(newLayouts[bp] || [])];
|
||||
// Place at next available y position
|
||||
const maxY = arr.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
||||
arr.push({
|
||||
i: newWidget.id,
|
||||
x: 0,
|
||||
y: maxY,
|
||||
w: constraints.defaultW,
|
||||
h: constraints.defaultH,
|
||||
});
|
||||
newLayouts[bp] = arr;
|
||||
}
|
||||
|
||||
return {
|
||||
widgets: [...state.widgets, newWidget],
|
||||
layouts: newLayouts,
|
||||
isDirty: true,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set({ error: 'Failed to add widget' });
|
||||
}
|
||||
},
|
||||
|
||||
removeWidget: async (id: string) => {
|
||||
try {
|
||||
await api.removeWidget(id);
|
||||
set((state) => {
|
||||
const newLayouts = { ...state.layouts };
|
||||
for (const bp of Object.keys(newLayouts)) {
|
||||
newLayouts[bp] = newLayouts[bp].filter((item) => item.i !== id);
|
||||
}
|
||||
return {
|
||||
widgets: state.widgets.filter((w) => w.id !== id),
|
||||
layouts: newLayouts,
|
||||
isDirty: true,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set({ error: 'Failed to remove widget' });
|
||||
}
|
||||
},
|
||||
|
||||
loadDashboard: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [layouts, widgets] = await Promise.all([
|
||||
api.fetchLayout(),
|
||||
api.fetchWidgets(),
|
||||
]);
|
||||
set({
|
||||
layouts: layouts as DashboardState['layouts'],
|
||||
widgets,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
error: 'Failed to load dashboard',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
saveLayout: async () => {
|
||||
try {
|
||||
await api.saveLayout(get().layouts);
|
||||
set({ isDirty: false });
|
||||
} catch {
|
||||
set({ error: 'Failed to save layout' });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1 +1,20 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Polyfill ResizeObserver for jsdom (needed by react-grid-layout)
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
private callback: ResizeObserverCallback;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe() {
|
||||
// Fire callback immediately with a mock entry
|
||||
this.callback(
|
||||
[{ contentRect: { width: 1200, height: 800 } } as unknown as ResizeObserverEntry],
|
||||
this,
|
||||
);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user