diff --git a/apps/web/package.json b/apps/web/package.json index f07f60a..8374c29 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,8 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-grid-layout": "2.2.3", + "react-resizable": "^4.0.2", "zod": "^4.4.3", "zustand": "^5.0.14" }, @@ -26,6 +28,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", "postcss": "^8.0.0", diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 8bb0106..3fff8c0 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -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 ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

{t('layoutLoadError')}

+
+ ); + } return ( -
- {/* Grid icon */} -
- - - - - - +
+ {/* Edit mode toggle — top right */} +
+ setEditMode(!isEditMode)} + />
- {/* Empty state text (D-16) */} -

- {t('empty')} -

+ {/* Dashboard grid */} +
+ +
- {/* Add widget button */} - + {/* Add widget button (edit mode only) */} + {isEditMode && ( +
+ +
+ )} + + {/* Widget catalog modal */} + setCatalogOpen(false)} + onAddWidget={(type: WidgetType) => { + addWidget(type); + }} + />
); } diff --git a/apps/web/src/components/dashboard/dashboard-grid.test.tsx b/apps/web/src/components/dashboard/dashboard-grid.test.tsx index 70c4be7..cd97b6a 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.test.tsx @@ -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) => { diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx new file mode 100644 index 0000000..5e15c76 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -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>; + widgets: Array<{ id: string; widgetType: string; config: Record }>; + 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(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 ( +
+
+ + + + + + +
+

+ {t('emptyHeading')} +

+

+ {t('emptyBody')} +

+
+ ); + } + + return ( +
+ onLayoutChange(allLayouts)} + > + {widgets.map((widget) => { + const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; + return ( +
l.i === widget.id) ?? { + x: 0, + y: 0, + w: constraints?.defaultW ?? 2, + h: constraints?.defaultH ?? 2, + }), + minW: constraints?.minW ?? 2, + minH: constraints?.minH ?? 2, + }} + > + +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/dashboard/edit-mode-toggle.tsx b/apps/web/src/components/dashboard/edit-mode-toggle.tsx new file mode 100644 index 0000000..c4b7c5b --- /dev/null +++ b/apps/web/src/components/dashboard/edit-mode-toggle.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.tsx new file mode 100644 index 0000000..c0f0b76 --- /dev/null +++ b/apps/web/src/components/dashboard/widget-catalog-modal.tsx @@ -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(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 ( +