"use client";
import { useMemo, useRef, useState, type ReactNode } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCorners,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import {
CheckIcon,
GripVerticalIcon,
LayoutGridIcon,
Maximize2Icon,
Minimize2Icon,
RotateCcwIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { saveDashboardLayout } from "@/app/(frontend)/actions";
import {
DEFAULT_DASHBOARD_LAYOUT,
WIDGET_LABELS,
effectiveSpan,
type DashboardLayout,
type DashboardWidgetId,
type DashboardZone,
} from "@/lib/dashboard/layout";
import { cn } from "@/lib/utils";
export interface DashboardGridWidget {
readonly id: DashboardWidgetId;
readonly node: ReactNode;
}
interface DashboardGridProps {
readonly layout: DashboardLayout;
readonly widgets: readonly DashboardGridWidget[];
}
const ZONE_MAIN = "zone:main";
const ZONE_SIDEBAR = "zone:sidebar";
function zoneFromContainerId(id: string): DashboardZone | null {
if (id === ZONE_MAIN) return "main";
if (id === ZONE_SIDEBAR) return "sidebar";
return null;
}
function findZone(layout: DashboardLayout, id: string): DashboardZone | null {
if (layout.main.includes(id as DashboardWidgetId)) return "main";
if (layout.sidebar.includes(id as DashboardWidgetId)) return "sidebar";
return null;
}
/**
* Computes the next layout for a drop, or null when the drop changes nothing.
* Pure and driven only by the layout as of drag end, so there is no state
* mutation during the drag itself (mutating layout in onDragOver re-renders
* the grid and re-measures droppables mid-drag, which ping-pongs collision
* detection and crashes React with "Maximum update depth exceeded").
*/
function placementFor(
layout: DashboardLayout,
activeId: string,
overId: string,
): DashboardLayout | null {
const from = findZone(layout, activeId);
if (!from) return null;
const toContainer = zoneFromContainerId(overId);
const to = toContainer ?? findZone(layout, overId);
if (!to) return null;
if (to === from) {
if (toContainer) return null;
const items = layout[from];
const fromIndex = items.indexOf(activeId as DashboardWidgetId);
const toIndex = items.indexOf(overId as DashboardWidgetId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
return { ...layout, [from]: arrayMove(items, fromIndex, toIndex) };
}
const source = layout[from].filter((id) => id !== (activeId as DashboardWidgetId));
const target = [...layout[to]];
if (toContainer) {
target.push(activeId as DashboardWidgetId);
} else {
const overIndex = target.indexOf(overId as DashboardWidgetId);
target.splice(overIndex >= 0 ? overIndex : target.length, 0, activeId as DashboardWidgetId);
}
return { ...layout, [from]: source, [to]: target };
}
function SortableWidget({
id,
span,
animate,
resizable,
onToggleSpan,
children,
}: {
id: string;
span: 1 | 2;
animate: boolean;
resizable: boolean;
onToggleSpan: () => void;
children: ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({ id });
return (
*]:flex-1 [&>*]:min-w-0",
span === 2 && "lg:col-span-2",
isDragging && "opacity-40",
isOver && "outline-2 outline-dashed outline-primary/70",
)}
>
{isOver && (
)}
{children}
{resizable && (
)}
);
}
function StaticWidget({ span, children }: { span: 1 | 2; children: ReactNode }) {
return (
*]:flex-1 [&>*]:min-w-0", span === 2 && "lg:col-span-2")}>
{children}
);
}
function MainZone({
layout,
nodesById,
editable,
onToggleSpan,
}: {
layout: DashboardLayout;
nodesById: Map;
editable: boolean;
onToggleSpan?: (id: DashboardWidgetId) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: ZONE_MAIN });
const items = layout.main;
const content = editable ? (
{items.map((id) => (
onToggleSpan?.(id)}
>
{nodesById.get(id) ?? null}
))}
) : (
items.map((id) => (
{nodesById.get(id) ?? null}
))
);
return (
{content}
);
}
function SidebarZone({
layout,
nodesById,
editable,
animate,
}: {
layout: DashboardLayout;
nodesById: Map;
editable: boolean;
animate: boolean;
}) {
const { setNodeRef, isOver } = useDroppable({ id: ZONE_SIDEBAR });
const items = layout.sidebar;
const content = editable ? (
{items.map((id) => (
undefined}
>
{nodesById.get(id) ?? null}
))}
) : (
items.map((id) => (
{nodesById.get(id) ?? null}
))
);
return (
{content}
);
}
export function DashboardGrid({ layout: initialLayout, widgets }: DashboardGridProps) {
const [layout, setLayout] = useState(initialLayout);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<{ id: string; width: number } | null>(null);
const layoutRef = useRef(initialLayout);
layoutRef.current = layout;
const nodesById = useMemo(
() => new Map(widgets.map((widget) => [widget.id, widget.node])),
[widgets],
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor),
);
const sidebarAnimate =
!activeDrag || findZone(layout, activeDrag.id) === "sidebar";
function commitLayout(next: DashboardLayout) {
layoutRef.current = next;
setLayout(next);
setSaving(true);
void saveDashboardLayout(next)
.then((result) => {
if (!result.success) {
toast.error(result.error ?? "Could not save your dashboard layout.");
}
})
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Could not save your dashboard layout.",
);
})
.finally(() => setSaving(false));
}
function handleDragStart(event: DragStartEvent) {
setActiveDrag({
id: String(event.active.id),
width: event.active.rect.current.initial?.width ?? 288,
});
}
function handleDragEnd(event: DragEndEvent) {
setActiveDrag(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const next = placementFor(layoutRef.current, String(active.id), String(over.id));
if (!next) return;
commitLayout(next);
}
function handleDragCancel() {
setActiveDrag(null);
}
function toggleSpan(id: DashboardWidgetId) {
const current = layoutRef.current;
const spans = { ...(current.spans ?? {}) };
spans[id] = effectiveSpan(current, id) === 1 ? 2 : 1;
commitLayout({ ...current, spans });
}
function resetLayout() {
commitLayout(DEFAULT_DASHBOARD_LAYOUT);
}
return (
{editing && (
<>
{saving && (
Saving...
)}
>
)}
{editing ? (
{activeDrag ? (
{WIDGET_LABELS[activeDrag.id as DashboardWidgetId]}
) : null}
) : (
)}
{editing && (
Drag cards by the handle to rearrange. Changes save automatically.
)}
);
}