Web app ready
This commit is contained in:
+411
-59
@@ -1,65 +1,417 @@
|
||||
import Image from "next/image";
|
||||
'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() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user