-
+
+ {/* 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 (
+
+ {/* Backdrop */}
+
+
+ {/* Dialog */}
+
e.stopPropagation()}
+ tabIndex={-1}
+ >
+ {/* Header */}
+
+
+ {t('catalogTitle')}
+
+
+
+
+ {/* 2x2 grid of widget type cards */}
+
+ {WIDGET_TYPES.map((type) => {
+ const def = WIDGET_REGISTRY[type];
+ const Icon = def.icon;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx
new file mode 100644
index 0000000..392bc6a
--- /dev/null
+++ b/apps/web/src/components/dashboard/widget-registry.tsx
@@ -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;
+ 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;
+}
+
+/**
+ * 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).__widgetType ?? 'Widget';
+ return (
+
+ {label}
+
+ );
+}
+
+// --- Icon components (inline SVG, established project pattern) ---
+
+function ClockIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function SearchIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function CalendarIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function NoteIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+/**
+ * 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 = {
+ 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,
+) {
+ if (!clockWired) {
+ WIDGET_REGISTRY.clock.component = component;
+ clockWired = true;
+ }
+}
diff --git a/apps/web/src/components/dashboard/widgets/clock-widget.tsx b/apps/web/src/components/dashboard/widgets/clock-widget.tsx
new file mode 100644
index 0000000..ce532f0
--- /dev/null
+++ b/apps/web/src/components/dashboard/widgets/clock-widget.tsx
@@ -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 (
+
+
+ {showDate && (
+
+ {dateFormatter.format(now)}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx b/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
new file mode 100644
index 0000000..b68c4b6
--- /dev/null
+++ b/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
@@ -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;
+ };
+ 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 (
+
+ {/* Edit mode: drag handle */}
+ {isEditMode && (
+
+ )}
+
+ {/* Edit mode: delete button */}
+ {isEditMode && (
+
+ )}
+
+ {/* Widget body */}
+
+ {WidgetComponent ? (
+
+ ) : (
+
+ {widget.widgetType}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/lib/dashboard-api.ts b/apps/web/src/lib/dashboard-api.ts
new file mode 100644
index 0000000..ad86cbb
--- /dev/null
+++ b/apps/web/src/lib/dashboard-api.ts
@@ -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> {
+ 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,
+): Promise {
+ 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 }>
+> {
+ 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 }> {
+ 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 {
+ 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,
+): Promise {
+ 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');
+}
diff --git a/apps/web/src/lib/stores/dashboard-store.ts b/apps/web/src/lib/stores/dashboard-store.ts
new file mode 100644
index 0000000..056f48f
--- /dev/null
+++ b/apps/web/src/lib/stores/dashboard-store.ts
@@ -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;
+}
+
+interface DashboardState {
+ layouts: Record>;
+ widgets: WidgetInstance[];
+ isEditMode: boolean;
+ isDirty: boolean;
+ isLoading: boolean;
+ error: string | null;
+
+ setEditMode: (mode: boolean) => void;
+ updateLayouts: (layouts: Record) => void;
+ addWidget: (widgetType: WidgetType) => Promise;
+ removeWidget: (id: string) => Promise;
+ loadDashboard: () => Promise;
+ saveLayout: () => Promise;
+}
+
+/**
+ * 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()((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' });
+ }
+ },
+}));
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
index bb02c60..b015b24 100644
--- a/apps/web/src/test/setup.ts
+++ b/apps/web/src/test/setup.ts
@@ -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() {}
+ };
+}
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index 64c0446..3431c94 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -13,5 +13,11 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
+ css: false,
+ server: {
+ deps: {
+ inline: ['react-grid-layout', 'react-resizable'],
+ },
+ },
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9689d86..d05b94b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -135,6 +135,12 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.7(react@19.2.7)
+ react-grid-layout:
+ specifier: 2.2.3
+ version: 2.2.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react-resizable:
+ specifier: ^4.0.2
+ version: 4.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -160,6 +166,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.17)
+ '@types/react-grid-layout':
+ specifier: ^2.1.0
+ version: 2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.0.2(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)(terser@5.48.0))
@@ -1527,6 +1536,10 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
+ '@types/react-grid-layout@2.1.0':
+ resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
+ deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
+
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
@@ -1928,6 +1941,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2403,6 +2420,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-equals@4.0.3:
+ resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
+
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -2926,6 +2946,10 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -3606,6 +3630,9 @@ packages:
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -3684,9 +3711,36 @@ packages:
peerDependencies:
react: ^19.2.7
+ react-draggable@4.7.0:
+ resolution: {integrity: sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==}
+ peerDependencies:
+ react: '>= 16.3.0'
+ react-dom: '>= 16.3.0'
+
+ react-grid-layout@2.2.3:
+ resolution: {integrity: sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==}
+ peerDependencies:
+ react: '>= 16.3.0'
+ react-dom: '>= 16.3.0'
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ react-resizable@3.2.0:
+ resolution: {integrity: sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==}
+ peerDependencies:
+ react: '>= 16.3'
+ react-dom: '>= 16.3'
+
+ react-resizable@4.0.2:
+ resolution: {integrity: sha512-jZD9ghYRmyJCw0+awYctSZ+9pmX1WXQvzDrTovELYc8obC/BShTI2r4c14LIVzeQ+vJZNb0yKM7bG2eqv7Vfyg==}
+ peerDependencies:
+ react: '>= 16.3'
+ react-dom: '>= 16.3'
+
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -3714,6 +3768,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ resize-observer-polyfill@1.5.1:
+ resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -5562,6 +5619,13 @@ snapshots:
dependencies:
'@types/react': 19.2.17
+ '@types/react-grid-layout@2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ react-grid-layout: 2.2.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ transitivePeerDependencies:
+ - react
+ - react-dom
+
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3
@@ -6029,6 +6093,8 @@ snapshots:
clone@1.0.4: {}
+ clsx@2.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6537,6 +6603,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-equals@4.0.3: {}
+
fast-json-stable-stringify@2.1.0: {}
fast-safe-stringify@2.1.1: {}
@@ -7111,6 +7179,10 @@ snapshots:
chalk: 4.1.2
is-unicode-supported: 0.1.0
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
lru-cache@10.4.3:
optional: true
@@ -8216,6 +8288,12 @@ snapshots:
asap: 2.0.6
optional: true
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
proto-list@1.2.4:
optional: true
@@ -8341,8 +8419,42 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
+ react-draggable@4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ clsx: 2.1.1
+ prop-types: 15.8.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ react-grid-layout@2.2.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ clsx: 2.1.1
+ fast-equals: 4.0.3
+ prop-types: 15.8.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-draggable: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react-resizable: 3.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ resize-observer-polyfill: 1.5.1
+
+ react-is@16.13.1: {}
+
react-is@17.0.2: {}
+ react-resizable@3.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ prop-types: 15.8.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-draggable: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+
+ react-resizable@4.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ prop-types: 15.8.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-draggable: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+
react@19.2.7: {}
readable-stream@3.6.2:
@@ -8365,6 +8477,8 @@ snapshots:
require-from-string@2.0.2: {}
+ resize-observer-polyfill@1.5.1: {}
+
resolve-from@4.0.0: {}
resolve@1.22.12: