1
0
Fork 0
polaris-task-force/src/components/frontend/dashboard/DashboardGrid.tsx
Z8MB1E f0af778713 feat(dashboard): customizable dashboard with personal widgets
- 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
2026-09-10 20:03:27 -04:00

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>
);
}