feat: add drag-to-rearrange and resize handles to dashboard widgets
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, use, useCallback } from "react";
|
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
GripIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
GaugeIcon,
|
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getDashboard,
|
getDashboard,
|
||||||
createWidget,
|
createWidget,
|
||||||
deleteWidget,
|
deleteWidget,
|
||||||
|
updateWidget,
|
||||||
getWidgetData,
|
getWidgetData,
|
||||||
getViews,
|
getViews,
|
||||||
getTeams,
|
getTeams,
|
||||||
@@ -156,6 +157,95 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
|||||||
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [resizingId, setResizingId] = useState<string | null>(null);
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(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 }) => {
|
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
|
||||||
if (!widget.data) {
|
if (!widget.data) {
|
||||||
return (
|
return (
|
||||||
@@ -288,23 +378,55 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4">
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
|
||||||
|
>
|
||||||
{widgets.map((widget) => (
|
{widgets.map((widget) => (
|
||||||
<div
|
<div
|
||||||
key={widget.id}
|
key={widget.id}
|
||||||
className="group relative"
|
className={cn(
|
||||||
|
"group relative",
|
||||||
|
editMode && "cursor-grab",
|
||||||
|
draggingId === widget.id && "opacity-50",
|
||||||
|
resizingId === widget.id && "select-none",
|
||||||
|
)}
|
||||||
style={widgetGridStyle(widget.position)}
|
style={widgetGridStyle(widget.position)}
|
||||||
|
draggable={editMode}
|
||||||
|
onDragStart={(e) => editMode && handleDragStart(e, widget.id)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, widget.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
{editMode && (
|
||||||
|
<div className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex">
|
||||||
|
<GripIcon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{renderWidget(widget)}
|
{renderWidget(widget)}
|
||||||
{editMode && (
|
{editMode && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteWidget(widget.id)}
|
onClick={() => handleDeleteWidget(widget.id)}
|
||||||
className="absolute right-2 top-2 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
|
className="absolute right-2 top-2 z-10 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
|
||||||
title="Remove widget"
|
title="Remove widget"
|
||||||
>
|
>
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 right-0 z-10 hidden h-5 w-5 cursor-se-resize items-center justify-center group-hover:flex"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, widget.id)}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" className="text-muted-foreground">
|
||||||
|
<path d="M0 10 L10 0" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M5 10 L10 5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M0 10 L10 10" stroke="transparent" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -331,6 +331,15 @@ export async function createWidget(dashboardId: string, data: {
|
|||||||
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
|
return request<DashboardWidget>(`/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<string, unknown>;
|
||||||
|
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
|
||||||
|
return request<DashboardWidget>(`/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 }> {
|
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" });
|
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user