- Drag-and-drop widget arrangement (main two-column zone + sidebar) with per-widget span resize, persisted to Users.preferences.dashboard.layout via saveDashboardLayout (migration included) - New personal widgets: Wallet, Open Negotiations, My Tickets (View all), Recent Notifications (per-row mark as read) - Widgets that are empty or not visible to the viewer are omitted so they never render as empty drag targets - Registers both pending migrations and regenerates payload types/schema
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
"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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(animate && !isDragging ? transform : null),
|
|
transition: animate ? transition : undefined,
|
|
}}
|
|
className={cn(
|
|
"group relative flex min-w-0 [&>*]:flex-1 [&>*]:min-w-0",
|
|
span === 2 && "lg:col-span-2",
|
|
isDragging && "opacity-40",
|
|
isOver && "outline-2 outline-dashed outline-primary/70",
|
|
)}
|
|
>
|
|
{isOver && (
|
|
<span
|
|
aria-hidden
|
|
className="absolute inset-x-0 -top-1.5 z-20 h-1 rounded bg-primary"
|
|
/>
|
|
)}
|
|
{children}
|
|
{resizable && (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleSpan}
|
|
title={span === 1 ? "Widen to full width" : "Shrink to half width"}
|
|
aria-label={span === 1 ? `Widen widget ${id}` : `Shrink widget ${id}`}
|
|
className="absolute right-8 top-1 z-20 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
|
>
|
|
{span === 1 ? (
|
|
<Maximize2Icon className="size-4" />
|
|
) : (
|
|
<Minimize2Icon className="size-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
ref={setActivatorNodeRef}
|
|
{...attributes}
|
|
{...listeners}
|
|
aria-label={`Drag widget ${id}`}
|
|
className="absolute right-1 top-1 z-20 cursor-grab rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 active:cursor-grabbing"
|
|
>
|
|
<GripVerticalIcon className="size-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StaticWidget({ span, children }: { span: 1 | 2; children: ReactNode }) {
|
|
return (
|
|
<div className={cn("flex min-w-0 [&>*]:flex-1 [&>*]:min-w-0", span === 2 && "lg:col-span-2")}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MainZone({
|
|
layout,
|
|
nodesById,
|
|
editable,
|
|
onToggleSpan,
|
|
}: {
|
|
layout: DashboardLayout;
|
|
nodesById: Map<DashboardWidgetId, ReactNode>;
|
|
editable: boolean;
|
|
onToggleSpan?: (id: DashboardWidgetId) => void;
|
|
}) {
|
|
const { setNodeRef, isOver } = useDroppable({ id: ZONE_MAIN });
|
|
const items = layout.main;
|
|
|
|
const content = editable ? (
|
|
<SortableContext items={items} strategy={rectSortingStrategy}>
|
|
{items.map((id) => (
|
|
<SortableWidget
|
|
key={id}
|
|
id={id}
|
|
span={effectiveSpan(layout, id)}
|
|
animate={false}
|
|
resizable
|
|
onToggleSpan={() => onToggleSpan?.(id)}
|
|
>
|
|
{nodesById.get(id) ?? null}
|
|
</SortableWidget>
|
|
))}
|
|
</SortableContext>
|
|
) : (
|
|
items.map((id) => (
|
|
<StaticWidget key={id} span={effectiveSpan(layout, id)}>
|
|
{nodesById.get(id) ?? null}
|
|
</StaticWidget>
|
|
))
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={editable ? setNodeRef : undefined}
|
|
aria-label="Main widgets"
|
|
className={cn(
|
|
"grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2",
|
|
editable && "rounded-lg",
|
|
editable && isOver && "ring-1 ring-primary/50",
|
|
)}
|
|
>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarZone({
|
|
layout,
|
|
nodesById,
|
|
editable,
|
|
animate,
|
|
}: {
|
|
layout: DashboardLayout;
|
|
nodesById: Map<DashboardWidgetId, ReactNode>;
|
|
editable: boolean;
|
|
animate: boolean;
|
|
}) {
|
|
const { setNodeRef, isOver } = useDroppable({ id: ZONE_SIDEBAR });
|
|
const items = layout.sidebar;
|
|
|
|
const content = editable ? (
|
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
|
{items.map((id) => (
|
|
<SortableWidget
|
|
key={id}
|
|
id={id}
|
|
span={1}
|
|
animate={animate}
|
|
resizable={false}
|
|
onToggleSpan={() => undefined}
|
|
>
|
|
{nodesById.get(id) ?? null}
|
|
</SortableWidget>
|
|
))}
|
|
</SortableContext>
|
|
) : (
|
|
items.map((id) => (
|
|
<StaticWidget key={id} span={1}>
|
|
{nodesById.get(id) ?? null}
|
|
</StaticWidget>
|
|
))
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={editable ? setNodeRef : undefined}
|
|
aria-label="Sidebar widgets"
|
|
className={cn(
|
|
"flex min-w-0 flex-col gap-4",
|
|
editable && "rounded-lg",
|
|
editable && isOver && "ring-1 ring-primary/50",
|
|
)}
|
|
>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DashboardGrid({ layout: initialLayout, widgets }: DashboardGridProps) {
|
|
const [layout, setLayout] = useState<DashboardLayout>(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 (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
{editing && (
|
|
<>
|
|
{saving && (
|
|
<span aria-live="polite" className="text-xs text-muted-foreground">
|
|
Saving...
|
|
</span>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={resetLayout}>
|
|
<RotateCcwIcon data-icon="inline-start" />
|
|
Reset layout
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant={editing ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setEditing((value) => !value)}
|
|
>
|
|
{editing ? (
|
|
<>
|
|
<CheckIcon data-icon="inline-start" />
|
|
Done
|
|
</>
|
|
) : (
|
|
<>
|
|
<LayoutGridIcon data-icon="inline-start" />
|
|
Customize
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{editing ? (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCorners}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
|
<div className="flex min-w-0 flex-col gap-4 xl:col-span-2">
|
|
<MainZone layout={layout} nodesById={nodesById} editable onToggleSpan={toggleSpan} />
|
|
</div>
|
|
<SidebarZone
|
|
layout={layout}
|
|
nodesById={nodesById}
|
|
editable
|
|
animate={sidebarAnimate}
|
|
/>
|
|
</div>
|
|
<DragOverlay>
|
|
{activeDrag ? (
|
|
<div
|
|
style={{ width: activeDrag.width }}
|
|
className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2.5 shadow-lg"
|
|
>
|
|
<GripVerticalIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate text-sm font-medium">
|
|
{WIDGET_LABELS[activeDrag.id as DashboardWidgetId]}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
|
<div className="flex min-w-0 flex-col gap-4 xl:col-span-2">
|
|
<MainZone layout={layout} nodesById={nodesById} editable={false} />
|
|
</div>
|
|
<SidebarZone
|
|
layout={layout}
|
|
nodesById={nodesById}
|
|
editable={false}
|
|
animate={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{editing && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Drag cards by the handle to rearrange. Changes save automatically.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|