@vendure/dashboard 3.4.1-master-202508080243 → 3.4.1-master-202508120233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.4.1-master-202508080243",
4
+ "version": "3.4.1-master-202508120233",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -99,10 +99,9 @@
99
99
  "@tiptap/starter-kit": "^2.11.5",
100
100
  "@types/react": "^19.0.10",
101
101
  "@types/react-dom": "^19.0.4",
102
- "@types/react-grid-layout": "^1.3.5",
103
102
  "@uidotdev/usehooks": "^2.4.1",
104
- "@vendure/common": "^3.4.1-master-202508080243",
105
- "@vendure/core": "^3.4.1-master-202508080243",
103
+ "@vendure/common": "^3.4.1-master-202508120233",
104
+ "@vendure/core": "^3.4.1-master-202508120233",
106
105
  "@vitejs/plugin-react": "^4.3.4",
107
106
  "acorn": "^8.11.3",
108
107
  "acorn-walk": "^8.3.2",
@@ -126,7 +125,6 @@
126
125
  "react-day-picker": "^9.8.0",
127
126
  "react-dom": "^19.0.0",
128
127
  "react-dropzone": "^14.3.8",
129
- "react-grid-layout": "^1.5.1",
130
128
  "react-hook-form": "^7.60.0",
131
129
  "react-resizable-panels": "^3.0.3",
132
130
  "recharts": "^2.15.4",
@@ -154,5 +152,5 @@
154
152
  "lightningcss-linux-arm64-musl": "^1.29.3",
155
153
  "lightningcss-linux-x64-musl": "^1.29.1"
156
154
  },
157
- "gitHead": "4f469d9885bc91671b630286fd6ec14c37479d52"
155
+ "gitHead": "050ab3e98d6054ea65ea01924f69dfd1a9c85306"
158
156
  }
@@ -1,4 +1,6 @@
1
1
  import { Button } from '@/vdb/components/ui/button.js';
2
+ import type { GridLayout as GridLayoutType } from '@/vdb/components/ui/grid-layout.js';
3
+ import { GridLayout } from '@/vdb/components/ui/grid-layout.js';
2
4
  import {
3
5
  getDashboardWidget,
4
6
  getDashboardWidgetRegistry,
@@ -12,12 +14,9 @@ import {
12
14
  PageLayout,
13
15
  PageTitle,
14
16
  } from '@/vdb/framework/layout-engine/page-layout.js';
17
+ import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
15
18
  import { createFileRoute } from '@tanstack/react-router';
16
- import { useEffect, useMemo, useRef, useState } from 'react';
17
- import { Responsive as ResponsiveGridLayout } from 'react-grid-layout';
18
-
19
- import 'react-grid-layout/css/styles.css';
20
- import 'react-resizable/css/styles.css';
19
+ import { useEffect, useState, useRef } from 'react';
21
20
 
22
21
  export const Route = createFileRoute('/_authenticated/')({
23
22
  component: DashboardPage,
@@ -27,13 +26,11 @@ const findNextPosition = (
27
26
  existingWidgets: DashboardWidgetInstance[],
28
27
  newWidgetSize: { w: number; h: number },
29
28
  ) => {
30
- // Create a set of all occupied cells
31
29
  const occupied = new Set();
32
30
  let maxExistingRow = 0;
33
31
 
34
32
  existingWidgets.forEach(widget => {
35
33
  const { x, y, w, h } = widget.layout;
36
- // Track the maximum row used by existing widgets
37
34
  maxExistingRow = Math.max(maxExistingRow, y + h);
38
35
 
39
36
  for (let i = x; i < x + w; i++) {
@@ -43,16 +40,13 @@ const findNextPosition = (
43
40
  }
44
41
  });
45
42
 
46
- // Search up to 3 rows past the last occupied row
47
43
  const maxSearchRows = maxExistingRow + 3;
48
44
 
49
- // Find first position where the widget fits
50
45
  for (let y = 0; y < maxSearchRows; y++) {
51
- for (let x = 0; x < 12 - (newWidgetSize.w || 1); x++) {
46
+ for (let x = 0; x <= 12 - newWidgetSize.w; x++) {
52
47
  let fits = true;
53
- // Check if all cells needed for this widget are free
54
- for (let i = x; i < x + (newWidgetSize.w || 1); i++) {
55
- for (let j = y; j < y + (newWidgetSize.h || 1); j++) {
48
+ for (let i = x; i < x + newWidgetSize.w; i++) {
49
+ for (let j = y; j < y + newWidgetSize.h; j++) {
56
50
  if (occupied.has(`${i},${j}`)) {
57
51
  fits = false;
58
52
  break;
@@ -65,46 +59,52 @@ const findNextPosition = (
65
59
  }
66
60
  }
67
61
  }
68
- // If no space found, place it in the next row after all existing widgets
69
62
  return { x: 0, y: maxExistingRow };
70
63
  };
71
64
 
72
65
  function DashboardPage() {
73
66
  const [widgets, setWidgets] = useState<DashboardWidgetInstance[]>([]);
74
67
  const [editMode, setEditMode] = useState(false);
75
- const [layoutWidth, setLayoutWidth] = useState<number | undefined>(undefined);
76
- const layoutRef = useRef<HTMLDivElement>(null);
77
-
78
- useEffect(() => {
79
- if (!layoutRef.current) return;
80
-
81
- const resizeObserver = new ResizeObserver(entries => {
82
- for (const entry of entries) {
83
- setLayoutWidth(entry.contentRect.width);
84
- }
85
- });
86
-
87
- resizeObserver.observe(layoutRef.current);
68
+ const [isInitialized, setIsInitialized] = useState(false);
69
+ const prevEditModeRef = useRef(editMode);
88
70
 
89
- return () => {
90
- resizeObserver.disconnect();
91
- };
92
- }, []);
71
+ const { settings, setWidgetLayout } = useUserSettings();
93
72
 
94
73
  useEffect(() => {
74
+ const savedLayouts = settings.widgetLayout || {};
75
+
95
76
  const initialWidgets = Array.from(getDashboardWidgetRegistry().entries()).reduce(
96
77
  (acc: DashboardWidgetInstance[], [id, widget]) => {
78
+ const defaultSize = {
79
+ w: widget.defaultSize.w ?? 4, // Default 4 columns
80
+ h: widget.defaultSize.h ?? 3, // Default 3 rows
81
+ };
82
+
83
+ // Use minSize if specified, otherwise fall back to defaultSize
84
+ const minSize = {
85
+ w: widget.minSize?.w ?? defaultSize.w,
86
+ h: widget.minSize?.h ?? defaultSize.h,
87
+ };
88
+
89
+ // Check if we have a saved layout for this widget
90
+ const savedLayout = savedLayouts[id];
91
+
97
92
  const layout = {
98
- ...widget.defaultSize,
99
- x: widget.defaultSize.x ?? 0,
100
- y: widget.defaultSize.y ?? 0,
93
+ w: savedLayout?.w ?? defaultSize.w,
94
+ h: savedLayout?.h ?? defaultSize.h,
95
+ x: savedLayout?.x ?? widget.defaultSize.x ?? 0,
96
+ y: savedLayout?.y ?? widget.defaultSize.y ?? 0,
97
+ minW: minSize.w,
98
+ minH: minSize.h,
99
+ maxW: widget.maxSize?.w,
100
+ maxH: widget.maxSize?.h,
101
101
  };
102
102
 
103
- // If x or y is not set, find the next available position
104
- if (widget.defaultSize.x === undefined || widget.defaultSize.y === undefined) {
103
+ // Only find next position if we don't have a saved layout
104
+ if (!savedLayout) {
105
105
  const pos = findNextPosition(acc, {
106
- w: widget.defaultSize.w || 1,
107
- h: widget.defaultSize.h || 1,
106
+ w: layout.w,
107
+ h: layout.h,
108
108
  });
109
109
  layout.x = pos.x;
110
110
  layout.y = pos.y;
@@ -123,69 +123,87 @@ function DashboardPage() {
123
123
  );
124
124
 
125
125
  setWidgets(initialWidgets);
126
- }, []);
126
+ setIsInitialized(true);
127
+ }, [settings.widgetLayout]);
128
+
129
+ // Save layout when edit mode is turned off
130
+ useEffect(() => {
131
+ // Only save when transitioning from edit mode ON to OFF
132
+ if (prevEditModeRef.current && !editMode && isInitialized && widgets.length > 0) {
133
+ const layoutConfig: Record<string, { x: number; y: number; w: number; h: number }> = {};
134
+ widgets.forEach(widget => {
135
+ layoutConfig[widget.widgetId] = {
136
+ x: widget.layout.x,
137
+ y: widget.layout.y,
138
+ w: widget.layout.w,
139
+ h: widget.layout.h,
140
+ };
141
+ });
142
+ setWidgetLayout(layoutConfig);
143
+ }
144
+
145
+ // Update the ref for next render
146
+ prevEditModeRef.current = editMode;
147
+ }, [editMode, isInitialized, widgets, setWidgetLayout]);
127
148
 
128
- const handleLayoutChange = (layout: ReactGridLayout.Layout[]) => {
149
+ const handleLayoutChange = (layouts: GridLayoutType[]) => {
129
150
  setWidgets(prev =>
130
151
  prev.map((widget, i) => ({
131
152
  ...widget,
132
- layout: layout[i],
153
+ layout: layouts[i] || widget.layout,
133
154
  })),
134
155
  );
135
156
  };
136
157
 
137
- const memoizedLayoutGrid = useMemo(() => {
138
- return (
139
- layoutWidth && (
140
- <ResponsiveGridLayout
141
- className="overflow-hidden"
142
- key={layoutWidth}
143
- width={layoutWidth}
144
- layouts={{ lg: widgets.map(w => ({ ...w.layout, i: w.id })) }}
145
- onLayoutChange={handleLayoutChange}
146
- cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
147
- rowHeight={100}
148
- isDraggable={editMode}
149
- isResizable={editMode}
150
- autoSize={true}
151
- innerRef={layoutRef}
152
- transformScale={0.9}
153
- >
154
- {widgets.map(widget => {
155
- const definition = getDashboardWidget(widget.widgetId);
156
- if (!definition) return null;
157
- const WidgetComponent = definition.component;
158
-
159
- return (
160
- <div key={widget.id}>
161
- <WidgetComponent id={widget.id} config={widget.config} />
162
- </div>
163
- );
164
- })}
165
- </ResponsiveGridLayout>
166
- )
167
- );
168
- }, [layoutWidth, editMode, widgets]);
158
+ const renderWidget = (widget: DashboardWidgetInstance) => {
159
+ const definition = getDashboardWidget(widget.widgetId);
160
+ if (!definition) return null;
161
+ const WidgetComponent = definition.component;
162
+
163
+ return <WidgetComponent key={widget.id} id={widget.id} config={widget.config} />;
164
+ };
169
165
 
170
166
  return (
171
167
  <Page pageId="insights">
172
168
  <PageTitle>Insights</PageTitle>
173
169
  <PageActionBar>
174
170
  <PageActionBarRight>
175
- <Button variant="outline" onClick={() => setEditMode(prev => !prev)}>
176
- Edit Mode
177
- {editMode ? (
178
- <span className="text-xs text-destructive">ON</span>
179
- ) : (
180
- <span className="text-xs text-muted-foreground">OFF</span>
181
- )}
171
+ <Button
172
+ variant={editMode ? "default" : "outline"}
173
+ onClick={() => setEditMode(prev => !prev)}
174
+ >
175
+ {editMode ? "Save Layout" : "Edit Layout"}
182
176
  </Button>
183
177
  </PageActionBarRight>
184
178
  </PageActionBar>
185
179
  <PageLayout>
186
180
  <FullWidthPageBlock blockId="widgets">
187
- <div ref={layoutRef} className="h-full w-full">
188
- {memoizedLayoutGrid}
181
+ <div className="w-full">
182
+ {widgets.length > 0 ? (
183
+ <GridLayout
184
+ layouts={widgets.map(w => ({ ...w.layout, i: w.id }))}
185
+ onLayoutChange={handleLayoutChange}
186
+ cols={12}
187
+ rowHeight={100}
188
+ isDraggable={editMode}
189
+ isResizable={editMode}
190
+ className="min-h-[400px]"
191
+ gutter={10}
192
+ >
193
+ {
194
+ widgets
195
+ .map(widget => renderWidget(widget))
196
+ .filter(Boolean) as React.ReactElement[]
197
+ }
198
+ </GridLayout>
199
+ ) : (
200
+ <div
201
+ className="flex items-center justify-center text-muted-foreground"
202
+ style={{ height: '400px' }}
203
+ >
204
+ No widgets available
205
+ </div>
206
+ )}
189
207
  </div>
190
208
  </FullWidthPageBlock>
191
209
  </PageLayout>
@@ -94,7 +94,3 @@
94
94
  }
95
95
  }
96
96
 
97
- /* Overrides for the react-grid-layout library */
98
- .react-grid-item {
99
- transition: none !important;
100
- }
@@ -0,0 +1,376 @@
1
+ import * as React from "react";
2
+ import { useState, useRef, useCallback, useEffect } from "react";
3
+ import { cn } from "@/vdb/lib/utils";
4
+
5
+ export interface GridLayout {
6
+ x: number;
7
+ y: number;
8
+ w: number;
9
+ h: number;
10
+ i: string;
11
+ minW?: number;
12
+ minH?: number;
13
+ maxW?: number;
14
+ maxH?: number;
15
+ }
16
+
17
+ export interface GridLayoutProps {
18
+ children: React.ReactElement[];
19
+ layouts: GridLayout[];
20
+ onLayoutChange?: (layouts: GridLayout[]) => void;
21
+ cols?: number;
22
+ rowHeight?: number;
23
+ isDraggable?: boolean;
24
+ isResizable?: boolean;
25
+ className?: string;
26
+ gutter?: number;
27
+ }
28
+
29
+ interface GridItemProps {
30
+ layout: GridLayout;
31
+ children: React.ReactNode;
32
+ isDraggable?: boolean;
33
+ isResizable?: boolean;
34
+ onLayoutChange?: (layout: GridLayout) => void;
35
+ onInteractionStart?: () => void;
36
+ onInteractionEnd?: () => void;
37
+ cols?: number;
38
+ rowHeight?: number;
39
+ gutter?: number;
40
+ }
41
+
42
+ function GridItem({
43
+ layout,
44
+ children,
45
+ isDraggable = false,
46
+ isResizable = false,
47
+ onLayoutChange,
48
+ onInteractionStart,
49
+ onInteractionEnd,
50
+ cols = 12,
51
+ rowHeight = 100,
52
+ gutter = 10,
53
+ }: GridItemProps) {
54
+ const [isResizing, setIsResizing] = useState(false);
55
+ const [isDragging, setIsDragging] = useState(false);
56
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0, mouseX: 0, mouseY: 0 });
57
+ const itemRef = useRef<HTMLDivElement>(null);
58
+
59
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
60
+ if (!isDraggable || isResizing) return;
61
+ e.preventDefault();
62
+ e.stopPropagation();
63
+
64
+ const rect = itemRef.current?.getBoundingClientRect();
65
+ if (!rect) return;
66
+
67
+ setIsDragging(true);
68
+ onInteractionStart?.();
69
+ setDragStart({
70
+ x: layout.x,
71
+ y: layout.y,
72
+ mouseX: e.clientX,
73
+ mouseY: e.clientY,
74
+ });
75
+ }, [isDraggable, isResizing, layout.x, layout.y, onInteractionStart]);
76
+
77
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
78
+ if (!isResizable) return;
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ setIsResizing(true);
82
+ onInteractionStart?.();
83
+ setDragStart({
84
+ x: layout.w,
85
+ y: layout.h,
86
+ mouseX: e.clientX,
87
+ mouseY: e.clientY,
88
+ });
89
+ }, [isResizable, layout.w, layout.h, onInteractionStart]);
90
+
91
+ useEffect(() => {
92
+ const handleMouseMove = (e: MouseEvent) => {
93
+ if (!itemRef.current) return;
94
+
95
+ const containerRect = itemRef.current.parentElement?.getBoundingClientRect();
96
+ if (!containerRect) return;
97
+
98
+ const colWidth = (containerRect.width - gutter * (cols - 1)) / cols;
99
+
100
+ if (isDragging && onLayoutChange) {
101
+ const deltaX = e.clientX - dragStart.mouseX;
102
+ const deltaY = e.clientY - dragStart.mouseY;
103
+
104
+ const newX = Math.round(dragStart.x + deltaX / colWidth);
105
+ const newY = Math.round(dragStart.y + deltaY / rowHeight);
106
+
107
+ onLayoutChange({
108
+ ...layout,
109
+ x: Math.max(0, Math.min(cols - layout.w, newX)),
110
+ y: Math.max(0, newY),
111
+ });
112
+ } else if (isResizing && onLayoutChange) {
113
+ const deltaX = e.clientX - dragStart.mouseX;
114
+ const deltaY = e.clientY - dragStart.mouseY;
115
+
116
+ const newW = Math.round(dragStart.x + deltaX / colWidth);
117
+ const newH = Math.round(dragStart.y + deltaY / rowHeight);
118
+
119
+ // Apply min/max constraints
120
+ const minW = layout.minW ?? 1;
121
+ const minH = layout.minH ?? 1;
122
+ const maxW = layout.maxW ?? (cols - layout.x);
123
+ const maxH = layout.maxH ?? 999;
124
+
125
+ onLayoutChange({
126
+ ...layout,
127
+ w: Math.max(minW, Math.min(maxW, Math.min(cols - layout.x, newW))),
128
+ h: Math.max(minH, Math.min(maxH, newH)),
129
+ });
130
+ }
131
+ };
132
+
133
+ const handleMouseUp = () => {
134
+ if (isDragging || isResizing) {
135
+ onInteractionEnd?.();
136
+ }
137
+ setIsDragging(false);
138
+ setIsResizing(false);
139
+ };
140
+
141
+ if (isDragging || isResizing) {
142
+ document.addEventListener('mousemove', handleMouseMove);
143
+ document.addEventListener('mouseup', handleMouseUp);
144
+ return () => {
145
+ document.removeEventListener('mousemove', handleMouseMove);
146
+ document.removeEventListener('mouseup', handleMouseUp);
147
+ };
148
+ }
149
+ }, [isDragging, isResizing, dragStart, layout, onLayoutChange, onInteractionEnd, cols, rowHeight]);
150
+
151
+ const colWidth = `calc((100% - ${gutter * (cols - 1)}px) / ${cols})`;
152
+ const style: React.CSSProperties = {
153
+ position: 'absolute',
154
+ left: `calc(${layout.x} * (${colWidth} + ${gutter}px))`,
155
+ top: `calc(${layout.y} * (${rowHeight}px + ${gutter}px))`,
156
+ width: `calc(${layout.w} * ${colWidth} + ${(layout.w - 1) * gutter}px)`,
157
+ height: `calc(${layout.h} * ${rowHeight}px + ${(layout.h - 1) * gutter}px)`,
158
+ zIndex: isDragging || isResizing ? 1000 : 10, // Normal widgets above grid (10), active widget much higher (1000)
159
+ };
160
+
161
+ return (
162
+ <div
163
+ ref={itemRef}
164
+ style={style}
165
+ className={cn(
166
+ "transition-shadow",
167
+ isDraggable && !isResizing && "cursor-move",
168
+ (isDragging || isResizing) && "shadow-lg",
169
+ )}
170
+ onMouseDown={handleMouseDown}
171
+ >
172
+ <div className="h-full w-full">
173
+ {children}
174
+ </div>
175
+ {isResizable && (
176
+ <div
177
+ className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize bg-muted-foreground/20 hover:bg-muted-foreground/40 transition-colors"
178
+ style={{
179
+ clipPath: "polygon(100% 0%, 0% 100%, 100% 100%)",
180
+ }}
181
+ onMouseDown={handleResizeStart}
182
+ />
183
+ )}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ export function GridLayout({
189
+ children,
190
+ layouts,
191
+ onLayoutChange,
192
+ cols = 12,
193
+ rowHeight = 100,
194
+ isDraggable = false,
195
+ isResizable = false,
196
+ className,
197
+ gutter = 10,
198
+ }: GridLayoutProps) {
199
+ const [showGrid, setShowGrid] = useState(false);
200
+ const [isMobile, setIsMobile] = useState(false);
201
+
202
+ // Detect mobile screen size
203
+ useEffect(() => {
204
+ const checkMobile = () => {
205
+ setIsMobile(window.innerWidth < 768); // Tailwind's md breakpoint
206
+ };
207
+
208
+ checkMobile();
209
+ window.addEventListener('resize', checkMobile);
210
+ return () => window.removeEventListener('resize', checkMobile);
211
+ }, []);
212
+
213
+ // Transform layouts for mobile - stack widgets vertically in full width
214
+ const mobileLayouts = React.useMemo(() => {
215
+ if (!isMobile) return layouts;
216
+
217
+ return layouts.map((layout, index) => ({
218
+ ...layout,
219
+ x: 0,
220
+ y: layouts.slice(0, index).reduce((sum, l) => sum + l.h, 0),
221
+ w: cols, // Full width
222
+ }));
223
+ }, [layouts, isMobile, cols]);
224
+
225
+ const effectiveLayouts = isMobile ? mobileLayouts : layouts;
226
+ const effectiveGutter = isMobile ? 5 : gutter; // Smaller gutter on mobile
227
+ const maxRow = Math.max(...effectiveLayouts.map(l => l.y + l.h), 4); // Minimum 4 rows
228
+ const containerHeight = maxRow * rowHeight + (maxRow - 1) * effectiveGutter;
229
+
230
+ // Helper function to check if two layouts overlap
231
+ const layoutsOverlap = (a: GridLayout, b: GridLayout): boolean => {
232
+ return !(
233
+ a.x + a.w <= b.x || // a is left of b
234
+ b.x + b.w <= a.x || // b is left of a
235
+ a.y + a.h <= b.y || // a is above b
236
+ b.y + b.h <= a.y // b is above a
237
+ );
238
+ };
239
+
240
+ // Helper function to find the next available position for a widget
241
+ const findNextAvailablePosition = (
242
+ widget: GridLayout,
243
+ occupiedLayouts: GridLayout[],
244
+ draggedWidget?: GridLayout
245
+ ): GridLayout => {
246
+ const sortedLayouts = [...occupiedLayouts]
247
+ .filter(l => l.i !== widget.i && (!draggedWidget || l.i !== draggedWidget.i))
248
+ .sort((a, b) => a.y === b.y ? a.x - b.x : a.y - b.y);
249
+
250
+ // Try to place widget in rows, starting from its current position
251
+ for (let y = widget.y; y < 100; y++) { // Max 100 rows
252
+ for (let x = 0; x <= cols - widget.w; x++) {
253
+ const testLayout = { ...widget, x, y };
254
+
255
+ // Check if this position overlaps with any other widget
256
+ const hasOverlap = sortedLayouts.some(layout => layoutsOverlap(testLayout, layout));
257
+
258
+ // Also check overlap with dragged widget if provided
259
+ if (!hasOverlap && (!draggedWidget || !layoutsOverlap(testLayout, draggedWidget))) {
260
+ return testLayout;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Fallback: place at the bottom
266
+ const maxY = Math.max(...sortedLayouts.map(l => l.y + l.h), 0);
267
+ return { ...widget, x: 0, y: maxY };
268
+ };
269
+
270
+ const handleItemLayoutChange = useCallback((newLayout: GridLayout) => {
271
+ if (onLayoutChange && !isMobile) { // Disable layout changes on mobile
272
+ const newLayouts = [...layouts];
273
+ const draggedIndex = layouts.findIndex(l => l.i === newLayout.i);
274
+
275
+ if (draggedIndex === -1) return;
276
+
277
+ // Update the dragged widget's position
278
+ newLayouts[draggedIndex] = newLayout;
279
+
280
+ // Find widgets that overlap with the new position
281
+ const overlappingWidgets: number[] = [];
282
+
283
+ for (let i = 0; i < newLayouts.length; i++) {
284
+ if (i !== draggedIndex && layoutsOverlap(newLayout, newLayouts[i])) {
285
+ overlappingWidgets.push(i);
286
+ }
287
+ }
288
+
289
+ // Move overlapping widgets to new positions
290
+ for (const index of overlappingWidgets) {
291
+ const widgetToMove = newLayouts[index];
292
+ const newPosition = findNextAvailablePosition(widgetToMove, newLayouts, newLayout);
293
+ newLayouts[index] = newPosition;
294
+ }
295
+
296
+ onLayoutChange(newLayouts);
297
+ }
298
+ }, [layouts, onLayoutChange, cols, isMobile]);
299
+
300
+ const handleInteractionStart = useCallback(() => {
301
+ setShowGrid(true);
302
+ }, []);
303
+
304
+ const handleInteractionEnd = useCallback(() => {
305
+ setShowGrid(false);
306
+ }, []);
307
+
308
+ // Create grid overlay
309
+ const renderGridOverlay = () => {
310
+ if (!showGrid) return null;
311
+
312
+ const gridCells = [];
313
+ for (let row = 0; row < maxRow; row++) {
314
+ for (let col = 0; col < cols; col++) {
315
+ const colWidth = `calc((100% - ${effectiveGutter * (cols - 1)}px) / ${cols})`;
316
+ const cellStyle: React.CSSProperties = {
317
+ position: 'absolute',
318
+ left: `calc(${col} * (${colWidth} + ${effectiveGutter}px))`,
319
+ top: `calc(${row} * (${rowHeight}px + ${effectiveGutter}px))`,
320
+ width: colWidth,
321
+ height: `${rowHeight}px`,
322
+ pointerEvents: 'none',
323
+ zIndex: 0, // Behind widgets but above background
324
+ boxSizing: 'border-box',
325
+ };
326
+
327
+ gridCells.push(
328
+ <div
329
+ key={`grid-${row}-${col}`}
330
+ style={cellStyle}
331
+ className="transition-opacity duration-200 border-2 border-dashed border-primary bg-primary/10"
332
+ />
333
+ );
334
+ }
335
+ }
336
+
337
+ return gridCells;
338
+ };
339
+
340
+ return (
341
+ <div
342
+ className={cn("relative w-full bg-muted/10", className)}
343
+ style={{
344
+ height: `${containerHeight}px`,
345
+ backgroundImage: `
346
+ linear-gradient(to right, hsl(var(--border)) 1px, transparent 1px),
347
+ linear-gradient(to bottom, hsl(var(--border)) 1px, transparent 1px)
348
+ `,
349
+ backgroundSize: `${100 / cols}% ${rowHeight}px`,
350
+ }}
351
+ >
352
+ {children.map((child, index) => {
353
+ const layout = effectiveLayouts[index];
354
+ if (!layout) return null;
355
+
356
+ return (
357
+ <GridItem
358
+ key={layout.i}
359
+ layout={layout}
360
+ isDraggable={isDraggable && !isMobile} // Disable dragging on mobile
361
+ isResizable={isResizable && !isMobile} // Disable resizing on mobile
362
+ onLayoutChange={handleItemLayoutChange}
363
+ onInteractionStart={handleInteractionStart}
364
+ onInteractionEnd={handleInteractionEnd}
365
+ cols={cols}
366
+ rowHeight={rowHeight}
367
+ gutter={effectiveGutter}
368
+ >
369
+ {child}
370
+ </GridItem>
371
+ );
372
+ })}
373
+ {renderGridOverlay()}
374
+ </div>
375
+ );
376
+ }
@@ -69,7 +69,7 @@ export function DashboardBaseWidget({
69
69
  <CardHeader
70
70
  ref={headerRef}
71
71
  className={cn(
72
- 'flex flex-row items-center',
72
+ 'flex flex-col lg:flex-row items-start lg:items-center',
73
73
  actions ? 'justify-between' : 'justify-start',
74
74
  )}
75
75
  >
@@ -1,8 +1,6 @@
1
1
  import { AnimatedCurrency, AnimatedNumber } from '@/vdb/components/shared/animated-number.js';
2
2
  import { Tabs, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
3
3
  import { api } from '@/vdb/graphql/api.js';
4
- import { useChannel } from '@/vdb/hooks/use-channel.js';
5
- import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
6
4
  import { useQuery } from '@tanstack/react-query';
7
5
  import { endOfDay, endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from 'date-fns';
8
6
  import { useMemo, useState } from 'react';
@@ -139,9 +137,9 @@ export function OrdersSummaryWidget() {
139
137
  </Tabs>
140
138
  }
141
139
  >
142
- <div className="flex flex-col gap-4 items-center justify-center text-center">
143
- <div className="flex flex-col gap-2">
144
- <p className="text-lg text-muted-foreground">Total Orders</p>
140
+ <div className="flex flex-col lg:gap-4 items-center justify-center text-center">
141
+ <div className="flex flex-col lg:gap-2">
142
+ <p className="lg:text-lg text-muted-foreground">Total Orders</p>
145
143
  <p className="text-3xl font-semibold">
146
144
  <AnimatedNumber
147
145
  animationConfig={{ mass: 0.5, stiffness: 90, damping: 10 }}
@@ -150,8 +148,8 @@ export function OrdersSummaryWidget() {
150
148
  </p>
151
149
  <PercentageChange value={orderChange} />
152
150
  </div>
153
- <div className="flex flex-col gap-2">
154
- <p className="text-lg text-muted-foreground">Total Revenue</p>
151
+ <div className="flex flex-col lg:gap-2">
152
+ <p className="lg:text-lg text-muted-foreground">Total Revenue</p>
155
153
  <p className="text-3xl font-semibold">
156
154
  <AnimatedCurrency
157
155
  animationConfig={{ mass: 0.2, stiffness: 90, damping: 10 }}
@@ -231,6 +231,7 @@ export function registerDefaults() {
231
231
  name: 'Metrics Widget',
232
232
  component: MetricsWidget,
233
233
  defaultSize: { w: 12, h: 6, x: 0, y: 0 },
234
+ minSize: { w: 6, h: 4 },
234
235
  });
235
236
 
236
237
  registerDashboardWidget({
@@ -42,6 +42,10 @@ export type DashboardWidgetInstance = {
42
42
  y: number;
43
43
  w: number;
44
44
  h: number;
45
+ minW?: number;
46
+ minH?: number;
47
+ maxW?: number;
48
+ maxH?: number;
45
49
  };
46
50
  /**
47
51
  * @description
@@ -26,6 +26,7 @@ export interface UserSettings {
26
26
  devMode: boolean;
27
27
  hasSeenOnboarding: boolean;
28
28
  tableSettings?: Record<string, TableSettings>;
29
+ widgetLayout?: Record<string, { x: number; y: number; w: number; h: number }>;
29
30
  }
30
31
 
31
32
  const defaultSettings: UserSettings = {
@@ -57,6 +58,7 @@ export interface UserSettingsContextType {
57
58
  key: K,
58
59
  value: TableSettings[K],
59
60
  ) => void;
61
+ setWidgetLayout: (layoutConfig: Record<string, { x: number; y: number; w: number; h: number }>) => void;
60
62
  }
61
63
 
62
64
  export const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
@@ -87,6 +89,7 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
87
89
  const [serverSettings, setServerSettings] = useState<UserSettings | null>(null);
88
90
  const [isReady, setIsReady] = useState(false);
89
91
  const previousContentLanguage = useRef(settings.contentLanguage);
92
+ const saveInProgressRef = useRef(false);
90
93
 
91
94
  // Load settings from server on mount
92
95
  const { data: serverSettingsResponse, isSuccess: serverSettingsLoaded } = useQuery({
@@ -102,8 +105,14 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
102
105
  api.mutate(setSettingsStoreValueDocument, {
103
106
  input: { key: SETTINGS_STORE_KEY, value: settingsToSave },
104
107
  }),
108
+ onSuccess: (_, settingsSaved) => {
109
+ // Only update serverSettings after successful save
110
+ setServerSettings(settingsSaved);
111
+ saveInProgressRef.current = false;
112
+ },
105
113
  onError: error => {
106
114
  console.error('Failed to save user settings to server:', error);
115
+ saveInProgressRef.current = false;
107
116
  },
108
117
  });
109
118
 
@@ -119,6 +128,10 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
119
128
  setSettings(mergedSettings);
120
129
  setServerSettings(mergedSettings);
121
130
  setIsReady(true);
131
+ } else {
132
+ // Server has no settings, use local settings
133
+ setServerSettings(settings);
134
+ setIsReady(true);
122
135
  }
123
136
  } catch (e) {
124
137
  console.error('Failed to parse server settings:', e);
@@ -139,12 +152,13 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
139
152
 
140
153
  // Save to server when settings differ from server state
141
154
  useEffect(() => {
142
- if (isReady && serverSettings) {
155
+ if (isReady && serverSettings && !saveInProgressRef.current) {
143
156
  const serverDiffers = JSON.stringify(serverSettings) !== JSON.stringify(settings);
144
157
 
145
158
  if (serverDiffers) {
159
+ saveInProgressRef.current = true;
146
160
  saveToServerMutation.mutate(settings);
147
- setServerSettings(settings);
161
+ // Don't update serverSettings here - wait for mutation success
148
162
  }
149
163
  }
150
164
  }, [settings, serverSettings, isReady, saveToServerMutation]);
@@ -182,6 +196,7 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
182
196
  },
183
197
  }));
184
198
  },
199
+ setWidgetLayout: layoutConfig => updateSetting('widgetLayout', layoutConfig),
185
200
  };
186
201
 
187
202
  return <UserSettingsContext.Provider value={contextValue}>{children}</UserSettingsContext.Provider>;