'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { DndContext, DragEndEvent, DragOverEvent, DragStartEvent, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import CounterCard, { Counter } from './components/CounterCard'; import CounterModal, { Group } from './components/CounterModal'; import CounterDetailModal, { HistoryData } from './components/CounterDetailModal'; import GroupSection from './components/GroupSection'; import SortableGroupSection from './components/SortableGroupSection'; export default function Home() { const router = useRouter(); const [counters, setCounters] = useState([]); const [groups, setGroups] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [editingCounter, setEditingCounter] = useState(null); const [activeCounter, setActiveCounter] = useState(null); const [activeGroup, setActiveGroup] = useState(null); const [newGroupName, setNewGroupName] = useState(''); const [addingGroup, setAddingGroup] = useState(false); const [loading, setLoading] = useState(true); const [historyCounterId, setHistoryCounterId] = useState(null); const [editMode, setEditMode] = useState(false); // Always-current ref so drag handlers never read stale closure state const countersRef = useRef(counters); countersRef.current = counters; // Pre-fetch cache for history modal const historyCache = useRef>(new Map()); const handlePrefetch = useCallback((id: number) => { if (historyCache.current.has(id)) return; fetch(`/api/counters/${id}/history`) .then(r => r.json()) .then(d => historyCache.current.set(id, d)) .catch(() => {}); }, []); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); // ── Data fetching ────────────────────────────────────────────────────────── async function load() { try { const [cRes, gRes] = await Promise.all([fetch('/api/counters'), fetch('/api/groups')]); const [c, g] = await Promise.all([cRes.json(), gRes.json()]); setCounters(c); setGroups(g); } finally { setLoading(false); } } useEffect(() => { load(); }, []); useEffect(() => { router.prefetch('/stats'); }, [router]); // ── Optimistic increment/decrement ───────────────────────────────────────── const handleIncrement = useCallback(async (id: number) => { setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value + 1 } : c)); const res = await fetch(`/api/counters/${id}/increment`, { method: 'POST' }); if (!res.ok) setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value - 1 } : c)); }, []); const handleDecrement = useCallback(async (id: number) => { const counter = countersRef.current.find(c => c.id === id); if (!counter || counter.value <= 0) return; setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value - 1 } : c)); const res = await fetch(`/api/counters/${id}/decrement`, { method: 'POST' }); if (!res.ok) setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value + 1 } : c)); }, []); // ── Create / Edit counter ────────────────────────────────────────────────── async function handleSaveCounter(data: { name: string; value: number; group_id: number | null; image_path: string | null }) { if (editingCounter) { // Optimistic update — close immediately, patch in background const optimistic = { ...editingCounter, ...data }; setCounters(prev => prev.map(c => c.id === editingCounter.id ? optimistic : c)); setModalOpen(false); setEditingCounter(null); const res = await fetch(`/api/counters/${editingCounter.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (res.ok) { const updated = await res.json(); setCounters(prev => prev.map(c => c.id === updated.id ? updated : c)); } else { // Roll back setCounters(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c)); } } else { // Optimistic create with a temporary id const tempId = -Date.now(); const optimistic = { ...data, id: tempId, order_index: 0 }; setCounters(prev => [...prev, optimistic]); setModalOpen(false); setEditingCounter(null); const res = await fetch('/api/counters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (res.ok) { const created = await res.json(); setCounters(prev => prev.map(c => c.id === tempId ? created : c)); } else { // Roll back setCounters(prev => prev.filter(c => c.id !== tempId)); } } } // ── Delete counter ───────────────────────────────────────────────────────── async function handleDeleteCounter(id: number) { const prev = counters.find(c => c.id === id); setCounters(cs => cs.filter(c => c.id !== id)); const res = await fetch(`/api/counters/${id}`, { method: 'DELETE' }); if (!res.ok && prev) setCounters(cs => [...cs, prev].sort((a, b) => a.order_index - b.order_index)); } // ── Groups ───────────────────────────────────────────────────────────────── async function handleCreateGroup() { if (!newGroupName.trim()) return; const res = await fetch('/api/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newGroupName.trim() }), }); const created = await res.json(); setGroups(prev => [...prev, created]); setNewGroupName(''); setAddingGroup(false); } async function handleRenameGroup(group: Group) { const res = await fetch(`/api/groups/${group.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: group.name }), }); const updated = await res.json(); setGroups(prev => prev.map(g => g.id === group.id ? updated : g)); } async function handleDeleteGroup(id: number) { setGroups(prev => prev.filter(g => g.id !== id)); setCounters(prev => prev.map(c => c.group_id === id ? { ...c, group_id: null } : c)); await fetch(`/api/groups/${id}`, { method: 'DELETE' }); } // ── Drag and drop ────────────────────────────────────────────────────────── function handleDragStart({ active }: DragStartEvent) { if (active.data.current?.type === 'group') { setActiveGroup(groups.find(g => `grp-${g.id}` === String(active.id)) ?? null); setActiveCounter(null); } else { setActiveCounter(countersRef.current.find(c => c.id === active.id) ?? null); setActiveGroup(null); } } function handleDragOver({ active, over }: DragOverEvent) { if (active.data.current?.type === 'group') return; if (!over) return; const activeId = active.id as number; const overId = over.id as string | number; let targetGroupId: number | null = null; if (typeof overId === 'string' && overId.startsWith('group-')) { targetGroupId = Number(overId.replace('group-', '')); } else if (overId === 'ungrouped') { targetGroupId = null; } else { const overCounter = countersRef.current.find(c => c.id === overId); if (overCounter) targetGroupId = overCounter.group_id; } const activeC = countersRef.current.find(c => c.id === activeId); if (!activeC) return; if (activeC.group_id === targetGroupId) return; setCounters(prev => prev.map(c => c.id === activeId ? { ...c, group_id: targetGroupId } : c)); } function handleDragEnd({ active, over }: DragEndEvent) { setActiveCounter(null); setActiveGroup(null); if (!over) return; // Group reorder if (active.data.current?.type === 'group') { const activeIdx = groups.findIndex(g => `grp-${g.id}` === String(active.id)); const overIdx = groups.findIndex(g => `grp-${g.id}` === String(over.id)); if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return; setGroups(arrayMove(groups, activeIdx, overIdx).map((g, i) => ({ ...g, order_index: i }))); return; } // Counter reorder const activeId = active.id as number; const overId = over.id as string | number; if (typeof overId === 'string') { let targetGroupId: number | null = null; if (overId.startsWith('group-')) targetGroupId = Number(overId.replace('group-', '')); setCounters(prev => prev.map(c => c.id === activeId ? { ...c, group_id: targetGroupId } : c)); return; } setCounters(prev => { const activeIdx = prev.findIndex(c => c.id === activeId); const overIdx = prev.findIndex(c => c.id === (overId as number)); if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return prev; const targetGroupId = prev[overIdx].group_id; return arrayMove(prev, activeIdx, overIdx).map((c, i) => ({ ...c, order_index: i, group_id: c.id === activeId ? targetGroupId : c.group_id, })); }); } // ── Derived data ─────────────────────────────────────────────────────────── const ungrouped = counters.filter(c => c.group_id === null); if (loading) { return (

Tally counters

{[16, 24, 16, 28].map((w, i) => (
))}
{Array.from({ length: 10 }).map((_, i) => (
))}
); } return (
{/* Header */}

Tally counters

Stats {addingGroup ? (
setNewGroupName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateGroup(); if (e.key === 'Escape') { setAddingGroup(false); setNewGroupName(''); } }} placeholder="Group name…" className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve" />
) : ( )}
{/* Board */} {/* Groups */} `grp-${g.id}`)} strategy={verticalListSortingStrategy}> {groups.map(group => ( c.group_id === group.id)} onIncrement={handleIncrement} onDecrement={handleDecrement} onEdit={c => { setEditingCounter(c); setModalOpen(true); }} onHistory={setHistoryCounterId} onPrefetch={handlePrefetch} editMode={editMode} onRenameGroup={handleRenameGroup} onDeleteGroup={handleDeleteGroup} /> ))} { setEditingCounter(c); setModalOpen(true); }} onHistory={setHistoryCounterId} onPrefetch={handlePrefetch} editMode={editMode} /> {activeCounter && (
{}} onDecrement={() => {}} onEdit={() => {}} onHistory={() => {}} />
)} {activeGroup && (
{activeGroup.name}
)}
{counters.length === 0 && (

No counters yet.

)}
{ setModalOpen(false); setEditingCounter(null); }} onSave={handleSaveCounter} onDelete={handleDeleteCounter} /> setHistoryCounterId(null)} />
); }