spb/note-spboucher-ai
Public
1"use client";2import { useEffect, useMemo, useState } from "react";3import { useQuery, useQueryClient } from "@tanstack/react-query";4import { DndContext, closestCenter, PointerSensor, TouchSensor, KeyboardSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";5import { SortableContext, rectSortingStrategy, useSortable, arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";6import { CSS } from "@dnd-kit/utilities";7import { GripVertical, X, Plus, Settings2, RotateCcw, Check, Columns2, Columns3, RectangleHorizontal } from "lucide-react";8import { nanoid } from "nanoid";9import { api } from "@/lib/client/api";10import { cn } from "@/lib/utils";11import { Button } from "@/components/ui/button";12import { Popover, PopoverTrigger, PopoverContent, Spinner, Tip } from "@/components/ui/primitives";13import { toast } from "@/components/ui/toast";14import { WIDGET_META, WIDGET_TYPES, type Widget, type WidgetSize, type WidgetType } from "./types";15import { WIDGET_COMPONENTS, type DashboardData } from "./widgets";1617const SIZE_CLASS: Record<WidgetSize, string> = { third: "md:col-span-2", half: "md:col-span-3", wide: "md:col-span-6" };18const SIZES: { id: WidgetSize; label: string; icon: React.ReactNode }[] = [{ id: "third", label: "Third", icon: <Columns3 /> }, { id: "half", label: "Half", icon: <Columns2 /> }, { id: "wide", label: "Full width", icon: <RectangleHorizontal /> }];19function greeting(d: Date) { const h = d.getHours(); return h < 5 ? "Good night" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; }2021function WidgetCard({ w, editing, data, refresh, onRemove, onResize }: { w: Widget; editing: boolean; data: DashboardData | undefined; refresh: () => void; onRemove: () => void; onResize: (s: WidgetSize) => void }) {22 const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: w.id, disabled: !editing });23 const meta = WIDGET_META[w.type]; const Comp = WIDGET_COMPONENTS[w.type];24 return (25 <section ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex min-h-[200px] flex-col rounded-xl border border-line bg-bg-elev shadow-card", SIZE_CLASS[w.size], isDragging && "z-10 opacity-80 shadow-pop ring-2 ring-accent/40", editing && "border-dashed border-line-strong")}>26 <header className="flex h-10 shrink-0 items-center gap-1 border-b border-line px-3">27 {editing && <button ref={setActivatorNodeRef} {...attributes} {...listeners} className="-ml-1.5 grid size-8 cursor-grab touch-none place-items-center rounded-md text-fg-faint hover:bg-bg-sunk hover:text-fg active:cursor-grabbing" aria-label="Drag to reorder"><GripVertical className="size-4" /></button>}28 <h2 className="min-w-0 flex-1 truncate text-[13px] font-semibold">{meta.label}</h2>29 {editing ? (30 <div className="flex items-center gap-0.5">31 {SIZES.map((s) => <Tip key={s.id} label={s.label}><button onClick={() => onResize(s.id)} className={cn("grid size-8 place-items-center rounded-md text-fg-faint hover:bg-bg-sunk [&_svg]:size-4", w.size === s.id && "bg-bg-sunk text-accent")} aria-label={s.label}>{s.icon}</button></Tip>)}32 <Tip label="Remove widget"><button onClick={onRemove} className="grid size-8 place-items-center rounded-md text-fg-faint hover:bg-danger/10 hover:text-danger" aria-label="Remove"><X className="size-4" /></button></Tip>33 </div>34 ) : null}35 </header>36 <div className={cn("flex min-h-0 flex-1 flex-col p-4", editing && "pointer-events-none select-none opacity-60")}>37 {data ? <Comp data={data} refresh={refresh} config={w.config} /> : <div className="flex flex-1 items-center justify-center"><Spinner /></div>}38 </div>39 </section>40 );41}4243export function Dashboard({ displayName }: { displayName: string }) {44 const qc = useQueryClient();45 const { data, isError, error } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/api/dashboard"), staleTime: 20_000 });46 const refresh = () => { qc.invalidateQueries({ queryKey: ["dashboard"] }); };47 const [editing, setEditing] = useState(false); const [draft, setDraft] = useState<Widget[] | null>(null); const [saving, setSaving] = useState(false);48 const widgets = draft ?? data?.layout.widgets ?? [];49 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));50 const now = useMemo(() => new Date(), []);51 const [dateLabel, setDateLabel] = useState(""); useEffect(() => { setDateLabel(new Intl.DateTimeFormat("en-CA", { weekday: "long", month: "long", day: "numeric", year: "numeric" }).format(new Date())); }, []);52 useEffect(() => { if (!editing) return; const k = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented && !document.querySelector("[role=dialog][data-state=open]")) cancel(); }; window.addEventListener("keydown", k); return () => window.removeEventListener("keydown", k); /* eslint-disable-next-line */ }, [editing]);5354 function start() { setDraft(data?.layout.widgets ?? []); setEditing(true); }55 function cancel() { setDraft(null); setEditing(false); }56 function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; setDraft((d) => { const list = d ?? []; const from = list.findIndex((w) => w.id === active.id); const to = list.findIndex((w) => w.id === over.id); return from < 0 || to < 0 ? list : arrayMove(list, from, to); }); }57 function add(type: WidgetType) { setDraft((d) => [...(d ?? []), { id: `w-${nanoid(8)}`, type, size: WIDGET_META[type].defaultSize }]); }58 async function save() {59 if (!draft) return; setSaving(true);60 try { const r = await api("/api/dashboard", { method: "PUT", json: { widgets: draft } }); qc.setQueryData<DashboardData>(["dashboard"], (old) => (old ? { ...old, layout: r } : old)); setEditing(false); setDraft(null); toast("Dashboard saved", { tone: "success" }); refresh(); }61 catch (e: any) { toast("Could not save layout", { description: e.message, tone: "danger" }); } finally { setSaving(false); }62 }63 async function reset() {64 setSaving(true);65 try { const r = await api("/api/dashboard", { method: "PUT", json: { reset: true } }); qc.setQueryData<DashboardData>(["dashboard"], (old) => (old ? { ...old, layout: r } : old)); setDraft(null); setEditing(false); toast("Default layout restored"); refresh(); }66 catch (e: any) { toast("Reset failed", { description: e.message, tone: "danger" }); } finally { setSaving(false); }67 }68 const missing = WIDGET_TYPES.filter((t) => !widgets.some((w) => w.type === t));69 return (70 <div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-8">71 <div className="mb-5 flex flex-wrap items-end gap-3">72 <div className="min-w-0 flex-1">73 <h1 className="text-2xl font-semibold tracking-tight">{greeting(now)}, {displayName.split(" ")[0]}</h1>74 <p className="mt-0.5 text-[13px] text-fg-muted">{dateLabel || " "}{data && data.tasks.length > 0 && <span> · {data.tasks.length} task{data.tasks.length === 1 ? "" : "s"} due soon</span>}</p>75 </div>76 {editing ? (77 <div className="flex flex-wrap items-center gap-1.5">78 <Popover><PopoverTrigger asChild><Button size="sm" variant="outline"><Plus /> Add widget</Button></PopoverTrigger>79 <PopoverContent className="w-80 p-1.5" align="end">80 <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-fg-faint">{missing.length ? "Available widgets" : "All widgets are on the board"}</div>81 {(missing.length ? missing : WIDGET_TYPES).map((t) => <button key={t} onClick={() => add(t)} className="flex w-full flex-col items-start rounded-md px-2 py-1.5 text-left hover:bg-bg-sunk"><span className="text-[13px] font-medium">{WIDGET_META[t].label}{!missing.includes(t) && <span className="ml-1 text-[10px] font-normal text-fg-faint">(duplicate)</span>}</span><span className="text-[11px] text-fg-muted">{WIDGET_META[t].description}</span></button>)}82 </PopoverContent></Popover>83 <Button size="sm" variant="ghost" onClick={reset} disabled={saving}><RotateCcw /> Reset</Button>84 <Button size="sm" variant="ghost" onClick={cancel} disabled={saving}>Cancel</Button>85 <Button size="sm" onClick={save} disabled={saving}>{saving ? <Spinner className="size-3.5 border-white/40 border-t-white" /> : <Check />} Save</Button>86 </div>87 ) : <Button size="sm" variant="ghost" onClick={start} disabled={!data}><Settings2 /> Customize</Button>}88 </div>89 {isError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-[13px] text-danger">Could not load the dashboard: {(error as Error).message}</div>}90 <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>91 <SortableContext items={widgets.map((w) => w.id)} strategy={rectSortingStrategy}>92 <div className="grid grid-cols-1 gap-4 md:grid-cols-6">93 {widgets.map((w) => <WidgetCard key={w.id} w={w} editing={editing} data={data} refresh={refresh} onRemove={() => setDraft((d) => (d ?? []).filter((x) => x.id !== w.id))} onResize={(size) => setDraft((d) => (d ?? []).map((x) => (x.id === w.id ? { ...x, size } : x)))} />)}94 {!widgets.length && data && <div className="col-span-full rounded-xl border border-dashed border-line-strong py-16 text-center text-[13px] text-fg-muted">{editing ? "Add widgets with the button above." : "Your dashboard is empty."} {!editing && <button className="text-accent hover:underline" onClick={start}>Customize</button>}</div>}95 </div>96 </SortableContext>97 </DndContext>98 {editing && <p className="mt-4 text-center text-[12px] text-fg-faint">Drag the handle to reorder (touch: press and hold). Resize with the column buttons. Esc cancels.</p>}99 </div>100 );101}102