418 lines
18 KiB
TypeScript
418 lines
18 KiB
TypeScript
'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<Counter[]>([]);
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingCounter, setEditingCounter] = useState<Counter | null>(null);
|
|
const [activeCounter, setActiveCounter] = useState<Counter | null>(null);
|
|
const [activeGroup, setActiveGroup] = useState<Group | null>(null);
|
|
const [newGroupName, setNewGroupName] = useState('');
|
|
const [addingGroup, setAddingGroup] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [historyCounterId, setHistoryCounterId] = useState<number | null>(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<Map<number, HistoryData>>(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 (
|
|
<main className="min-h-screen bg-ctp-base px-4 py-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between mb-8 flex-wrap gap-4">
|
|
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Tally counters</h1>
|
|
<div className="flex gap-3 items-center">
|
|
{[16, 24, 16, 28].map((w, i) => (
|
|
<div key={i} className={`h-9 w-${w} rounded-lg bg-ctp-surface0 animate-pulse`} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
|
{Array.from({ length: 10 }).map((_, i) => (
|
|
<div key={i} className="bg-ctp-surface0 rounded-2xl h-52 animate-pulse" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="min-h-screen bg-ctp-base px-4 py-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8 flex-wrap gap-4">
|
|
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Tally counters</h1>
|
|
<div className="flex gap-3 flex-wrap items-center">
|
|
<Link
|
|
href="/stats"
|
|
className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="20" x2="18" y2="10" />
|
|
<line x1="12" y1="20" x2="12" y2="4" />
|
|
<line x1="6" y1="20" x2="6" y2="14" />
|
|
</svg>
|
|
Stats
|
|
</Link>
|
|
{addingGroup ? (
|
|
<div className="flex gap-2">
|
|
<input
|
|
autoFocus
|
|
value={newGroupName}
|
|
onChange={e => 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"
|
|
/>
|
|
<button onClick={handleCreateGroup} className="px-3 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors">Add</button>
|
|
<button onClick={() => { setAddingGroup(false); setNewGroupName(''); }} className="px-3 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors">Cancel</button>
|
|
</div>
|
|
) : (
|
|
<button onClick={() => setAddingGroup(true)} className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors">
|
|
+ New Group
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
if (editMode) {
|
|
// Persist all reorder/group changes accumulated during edit mode
|
|
setEditMode(false);
|
|
fetch('/api/reorder', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
counters: counters.map(c => ({ id: c.id, order_index: c.order_index, group_id: c.group_id })),
|
|
groups: groups.map(g => ({ id: g.id, order_index: g.order_index })),
|
|
}),
|
|
});
|
|
} else {
|
|
setEditMode(true);
|
|
}
|
|
}}
|
|
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
|
editMode
|
|
? 'border-ctp-mauve bg-ctp-mauve/10 text-ctp-mauve hover:bg-ctp-mauve/20'
|
|
: 'border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0'
|
|
}`}
|
|
>
|
|
{editMode ? 'Done' : 'Edit'}
|
|
</button>
|
|
<button
|
|
onClick={() => { setEditingCounter(null); setModalOpen(true); }}
|
|
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors shadow-sm"
|
|
>
|
|
+ Add Counter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Board */}
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
{/* Groups */}
|
|
<SortableContext items={groups.map(g => `grp-${g.id}`)} strategy={verticalListSortingStrategy}>
|
|
{groups.map(group => (
|
|
<SortableGroupSection
|
|
key={group.id}
|
|
group={group}
|
|
counters={counters.filter(c => 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}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
|
|
<GroupSection
|
|
group={null}
|
|
counters={ungrouped}
|
|
onIncrement={handleIncrement}
|
|
onDecrement={handleDecrement}
|
|
onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
|
|
onHistory={setHistoryCounterId}
|
|
onPrefetch={handlePrefetch}
|
|
editMode={editMode}
|
|
/>
|
|
|
|
<DragOverlay>
|
|
{activeCounter && (
|
|
<div className="rotate-2 scale-105 shadow-2xl">
|
|
<CounterCard
|
|
counter={activeCounter}
|
|
onIncrement={() => {}}
|
|
onDecrement={() => {}}
|
|
onEdit={() => {}}
|
|
onHistory={() => {}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{activeGroup && (
|
|
<div className="px-4 py-3 bg-ctp-mantle rounded-xl shadow-2xl ring-1 ring-ctp-mauve opacity-90">
|
|
<span className="font-bold text-ctp-text">{activeGroup.name}</span>
|
|
</div>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
|
|
{counters.length === 0 && (
|
|
<div className="text-center mt-20">
|
|
<p className="text-ctp-overlay0 text-lg">No counters yet.</p>
|
|
<button
|
|
onClick={() => setModalOpen(true)}
|
|
className="mt-4 px-6 py-3 rounded-xl bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors shadow"
|
|
>
|
|
Create your first counter
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<CounterModal
|
|
open={modalOpen}
|
|
initial={editingCounter}
|
|
groups={groups}
|
|
onClose={() => { setModalOpen(false); setEditingCounter(null); }}
|
|
onSave={handleSaveCounter}
|
|
onDelete={handleDeleteCounter}
|
|
/>
|
|
|
|
<CounterDetailModal
|
|
counterId={historyCounterId}
|
|
cache={historyCache.current}
|
|
onClose={() => setHistoryCounterId(null)}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|