"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, rectSortingStrategy, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { SourceState } from "@/lib/types"; import { useAction } from "@/hooks/useAction"; import { spanStyle, GRID_COL_MIN, GRID_ROW_PX, GRID_GAP, MAX_ROWS } from "@/lib/layout"; import { SourceCard } from "./cards/SourceCard"; import { ThingsCard } from "./cards/ThingsCard"; export function Dashboard() { const router = useRouter(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [refreshingAll, setRefreshingAll] = useState(false); const [refreshingId, setRefreshingId] = useState(null); const [generating, setGenerating] = useState(false); const [editing, setEditing] = useState(false); // Initial paint from cache, then refresh stale sources in the background. useEffect(() => { let alive = true; (async () => { const cached = await fetch("/api/sources").then((r) => r.json()); if (!alive) return; setItems(cached.sources); setLoading(false); const fresh = await fetch("/api/sources?fresh=1").then((r) => r.json()); if (alive) setItems(fresh.sources); })(); return () => { alive = false; }; }, []); const refreshAll = useCallback(async () => { setRefreshingAll(true); try { const fresh = await fetch("/api/sources?force=1").then((r) => r.json()); setItems(fresh.sources); } finally { setRefreshingAll(false); } }, []); const refreshOne = useCallback(async (id: string) => { setRefreshingId(id); try { const res = await fetch(`/api/sources/${id}/refresh`, { method: "POST" }); if (res.ok) { const { source } = await res.json(); setItems((prev) => prev.map((it) => (it.source.id === id ? source : it))); } } finally { setRefreshingId(null); } }, []); const generatePaper = useCallback(async () => { setGenerating(true); try { const res = await fetch("/api/edition", { method: "POST" }); if (res.ok) router.push("/editions"); } finally { setGenerating(false); } }, [router]); // Persist a layout patch (position / cols / rows) for one source. const patchSource = useCallback((id: string, patch: Record) => { void fetch(`/api/sources/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); }, []); // Drag-reorder: renumber enabled cards into the same set of position slots they // already occupy, leaving disabled (hidden) sources' positions untouched. const onDragEnd = useCallback( (e: DragEndEvent) => { const { active, over } = e; if (!over || active.id === over.id) return; setItems((prev) => { const enabled = prev.filter((it) => it.source.enabled); const oldIndex = enabled.findIndex((it) => it.source.id === active.id); const newIndex = enabled.findIndex((it) => it.source.id === over.id); if (oldIndex < 0 || newIndex < 0) return prev; const slots = enabled.map((it) => it.source.position).sort((a, b) => a - b); const reordered = arrayMove(enabled, oldIndex, newIndex); const posById = new Map(); reordered.forEach((it, i) => { const pos = slots[i]; if (pos !== it.source.position) patchSource(it.source.id, { position: pos }); posById.set(it.source.id, pos); }); return prev .map((it) => posById.has(it.source.id) ? { ...it, source: { ...it.source, position: posById.get(it.source.id)! } } : it, ) .sort((a, b) => a.source.position - b.source.position); }); }, [patchSource], ); // Live local resize, persisted on release. const resize = useCallback( (id: string, cols: number, rows: number, commit: boolean) => { setItems((prev) => prev.map((it) => it.source.id === id ? { ...it, source: { ...it.source, cols, rows } } : it, ), ); if (commit) patchSource(id, { cols, rows }); }, [patchSource], ); useAction( { id: "refresh-all", label: "Refresh all sources", combo: "mod+shift+r", group: "Actions", run: refreshAll, }, [refreshAll], ); useAction( { id: "generate-paper", label: "Generate today's paper", combo: "mod+shift+g", group: "Actions", run: generatePaper, }, [generatePaper], ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); const enabled = items.filter((it) => it.source.enabled); const gridStyle: React.CSSProperties = { display: "grid", gridTemplateColumns: `repeat(auto-fill, minmax(${GRID_COL_MIN}px, 1fr))`, gridAutoRows: `${GRID_ROW_PX}px`, gridAutoFlow: "dense", gap: GRID_GAP, }; return (

{new Date().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", })}

setEditing((v) => !v)} primary={editing}> {editing ? "Done" : "Edit layout"} {generating ? "Generating…" : "Generate paper"} {refreshingAll ? "Refreshing…" : "Refresh all"}
{editing && (

Drag cards to reorder · drag a card’s bottom-right corner to resize · click Done when finished.

)} {loading ? (

Loading…

) : editing ? ( it.source.id)} strategy={rectSortingStrategy}>
{enabled.map((it) => ( refreshOne(it.source.id)} refreshing={refreshingId === it.source.id} /> ))}
) : (
{enabled.map((it) => (
refreshOne(it.source.id)} refreshing={refreshingId === it.source.id} />
))}
)}
); } function CardFor({ state, onRefresh, refreshing, }: { state: SourceState; onRefresh: () => void; refreshing: boolean; }) { return state.source.kind === "todos" ? ( ) : ( ); } // A draggable, corner-resizable grid tile (edit mode only). function SortableTile({ state, onResize, children, }: { state: SourceState; onResize: (id: string, cols: number, rows: number, commit: boolean) => void; children: React.ReactNode; }) { const { source } = state; const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ id: source.id, }); const nodeRef = useRef(null); // Corner-resize: translate pointer movement into whole grid-cell span changes. const onResizeStart = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); const grid = nodeRef.current?.parentElement; if (!grid) return; const cols = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length || 1; const cellW = (grid.clientWidth - GRID_GAP * (cols - 1)) / cols; const stepX = cellW + GRID_GAP; const stepY = GRID_ROW_PX + GRID_GAP; const startX = e.clientX; const startY = e.clientY; const startCols = source.cols; const startRows = source.rows; (e.target as Element).setPointerCapture(e.pointerId); const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); const move = (ev: PointerEvent) => { const nextCols = clamp(startCols + Math.round((ev.clientX - startX) / stepX), 1, cols); const nextRows = clamp(startRows + Math.round((ev.clientY - startY) / stepY), 1, MAX_ROWS); onResize(source.id, nextCols, nextRows, false); }; const up = (ev: PointerEvent) => { const nextCols = clamp(startCols + Math.round((ev.clientX - startX) / stepX), 1, cols); const nextRows = clamp(startRows + Math.round((ev.clientY - startY) / stepY), 1, MAX_ROWS); onResize(source.id, nextCols, nextRows, true); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; return (
{ setNodeRef(el); nodeRef.current = el; }} style={{ ...spanStyle(source.cols, source.rows), minHeight: 0, position: "relative", transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, zIndex: isDragging ? 10 : undefined, cursor: "grab", outline: "2px dashed var(--border-strong)", outlineOffset: -2, borderRadius: 8, }} {...attributes} {...listeners} > {/* Block inner clicks/scroll while editing; this overlay is the drag surface. */}
{children}
{/* Resize handle — bottom-right corner. */}
); } function Btn({ children, onClick, disabled, primary, }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; primary?: boolean; }) { return ( ); }