From 41fb10120cf53467789dc788da35b31fbc7d4a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 13:41:04 +0200 Subject: [PATCH] feat: add drag-to-rearrange and resize handles to dashboard widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit mode now supports: - Drag handle (grip icon, top-left) to rearrange widgets via HTML5 DnD (drops swap widget positions, persists via API) - Resize handle (corner icon, bottom-right) with mousedown→mousemove→mouseup tracking to change widget width/height in grid units, persists via API - Cursor feedback: grab cursor on draggable widgets, se-resize on handle - Visual feedback: dragging widget shows 50% opacity Co-Authored-By: Claude Opus 4.8 --- web/src/app/dashboards/[id]/page.tsx | 146 ++++++++++++++++++++++++--- web/src/lib/api.ts | 9 ++ 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/web/src/app/dashboards/[id]/page.tsx b/web/src/app/dashboards/[id]/page.tsx index 1438430..120c643 100644 --- a/web/src/app/dashboards/[id]/page.tsx +++ b/web/src/app/dashboards/[id]/page.tsx @@ -1,20 +1,21 @@ "use client"; -import { useState, useEffect, use, useCallback } from "react"; +import { useState, useEffect, use, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { + GripIcon, PencilIcon, PlusIcon, Trash2Icon, RefreshCwIcon, - GaugeIcon, LayoutGridIcon, } from "lucide-react"; import { getDashboard, createWidget, deleteWidget, + updateWidget, getWidgetData, getViews, getTeams, @@ -156,6 +157,95 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string setWidgets((prev) => prev.filter((w) => w.id !== widgetId)); }; + const gridRef = useRef(null); + const [resizingId, setResizingId] = useState(null); + const [draggingId, setDraggingId] = useState(null); + + // Resize: track mousedown → mousemove → mouseup + const handleResizeStart = (e: React.MouseEvent, widgetId: string) => { + e.preventDefault(); + e.stopPropagation(); + setResizingId(widgetId); + const startX = e.clientX; + const startY = e.clientY; + const widget = widgets.find((w) => w.id === widgetId); + if (!widget || !gridRef.current) return; + + const startW = widget.position.w; + const startH = widget.position.h; + const gridWidth = gridRef.current.offsetWidth; + const unitSize = gridWidth / 12; + + const onMove = (ev: MouseEvent) => { + const dx = Math.round((ev.clientX - startX) / unitSize); + const dy = Math.round((ev.clientY - startY) / unitSize); + const newW = Math.max(1, Math.min(12, startW + dx)); + const newH = Math.max(1, startH + dy); + setWidgets((prev) => + prev.map((w) => + w.id === widgetId + ? { ...w, position: { ...w.position, w: newW, h: newH } } + : w + ) + ); + }; + + const onUp = async () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + setResizingId(null); + const updated = widgets.find((w) => w.id === widgetId); + if (updated) { + await updateWidget(id, widgetId, { position: updated.position }); + } + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + // Drag: HTML5 DnD + const handleDragStart = (e: React.DragEvent, widgetId: string) => { + setDraggingId(widgetId); + e.dataTransfer.setData("text/plain", widgetId); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDrop = async (e: React.DragEvent, targetId: string) => { + e.preventDefault(); + const sourceId = e.dataTransfer.getData("text/plain"); + if (!sourceId || sourceId === targetId) { 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; } + + // Swap positions + const sourcePos = { ...source.position }; + const targetPos = { ...target.position }; + + setWidgets((prev) => + prev.map((w) => { + if (w.id === sourceId) return { ...w, position: targetPos }; + if (w.id === targetId) return { ...w, position: sourcePos }; + return w; + }) + ); + + await updateWidget(id, sourceId, { position: targetPos }); + await updateWidget(id, targetId, { position: sourcePos }); + setDraggingId(null); + }; + + const handleDragEnd = () => { + setDraggingId(null); + }; + const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => { if (!widget.data) { return ( @@ -288,23 +378,55 @@ 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 */} + {editMode && ( +
+ +
+ )} {renderWidget(widget)} {editMode && ( - + <> + + {/* Resize handle */} +
handleResizeStart(e, widget.id)} + > + + + + + +
+ )}
))} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9df4427..2d50ae1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -331,6 +331,15 @@ export async function createWidget(dashboardId: string, data: { return request(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) }); } +export async function updateWidget(dashboardId: string, widgetId: string, data: { + title?: string; + widget_type?: string; + position?: { x: number; y: number; w: number; h: number }; + config?: Record; +}): Promise<{ data: DashboardWidget | null; error: string | null }> { + return request(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "PATCH", body: JSON.stringify(data) }); +} + export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" }); }