diff --git a/web/src/app/dashboards/[id]/page.tsx b/web/src/app/dashboards/[id]/page.tsx index 5f7be6e..d70eb2c 100644 --- a/web/src/app/dashboards/[id]/page.tsx +++ b/web/src/app/dashboards/[id]/page.tsx @@ -235,7 +235,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string document.addEventListener("mouseup", onUp); }; - // Drag: HTML5 DnD + // Drag: free-form grid positioning const handleDragStart = (e: React.DragEvent, widgetId: string) => { setDraggingId(widgetId); e.dataTransfer.setData("text/plain", widgetId); @@ -247,29 +247,65 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string e.dataTransfer.dropEffect = "move"; }; - const handleDrop = async (e: React.DragEvent, targetId: string) => { + const handleGridDrop = (e: React.DragEvent) => { e.preventDefault(); const sourceId = e.dataTransfer.getData("text/plain"); - if (!sourceId || sourceId === targetId) { setDraggingId(null); return; } + if (!sourceId) { setDraggingId(null); return; } + if (!gridRef.current) { setDraggingId(null); return; } - const source = widgets.find((w) => w.id === sourceId); - const target = widgets.find((w) => w.id === targetId); - if (!source || !target) { setDraggingId(null); return; } + const gridRect = gridRef.current.getBoundingClientRect(); + const unitSize = gridRect.width / 12; + // Calculate grid position from mouse coordinates + const relX = e.clientX - gridRect.left; + const relY = e.clientY - gridRect.top; + const sourceWidget = widgets.find((w) => w.id === sourceId); + if (!sourceWidget) { setDraggingId(null); return; } - // Swap positions - const sourcePos = { ...source.position }; - const targetPos = { ...target.position }; + // Snap to nearest grid unit, clamp to bounds + const newX = Math.max(0, Math.min(12 - sourceWidget.position.w, Math.round(relX / unitSize))); + const newY = Math.max(0, Math.round(relY / (unitSize * 0.6))); // rows are shorter - setWidgets((prev) => - prev.map((w) => { - if (w.id === sourceId) return { ...w, position: targetPos }; - if (w.id === targetId) return { ...w, position: sourcePos }; - return w; - }) - ); + setWidgets((current) => { + let resolved = current.map((w) => { + if (w.id === sourceId) { + return { ...w, position: { ...w.position, x: newX, y: newY } }; + } + return { ...w, position: { ...w.position } }; + }); + + // Push overlapping widgets down (skip the moved widget) + for (let pass = 0; pass < 10; pass++) { + let hasOverlap = false; + for (let i = 0; i < resolved.length; i++) { + for (let j = i + 1; j < resolved.length; j++) { + const a = resolved[i].position; + const b = resolved[j].position; + if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) { + hasOverlap = true; + // Push the widget that wasn't just moved (or the lower one) + const moveIdx = resolved[i].id === sourceId ? j : i; + const fixedIdx = moveIdx === i ? j : i; + resolved[moveIdx] = { + ...resolved[moveIdx], + position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h }, + }; + } + } + } + if (!hasOverlap) break; + } + + // Persist + for (const w of resolved) { + const orig = current.find((o) => o.id === w.id); + if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) { + updateWidget(id, w.id, { position: w.position }); + } + } + + return resolved; + }); - await updateWidget(id, sourceId, { position: targetPos }); - await updateWidget(id, targetId, { position: sourcePos }); setDraggingId(null); }; @@ -412,6 +448,8 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
{widgets.map((widget) => (
editMode && handleDragStart(e, widget.id)} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, widget.id)} onDragEnd={handleDragEnd} > {/* Drag handle */}