Files
tally-counter/app/page.tsx
T
2026-06-06 17:14:53 +02:00

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