Web app ready

This commit is contained in:
2026-06-06 17:08:04 +02:00
parent ff187a5bd4
commit 3e127afbae
39 changed files with 2622 additions and 123 deletions
+244
View File
@@ -0,0 +1,244 @@
'use client';
import { useEffect, useState } from 'react';
export interface DayCounter {
date: string; // YYYY-MM-DD
counter_id: number;
counter_name: string;
image_path: string | null;
increments: number;
}
interface Props {
dailyCounters: DayCounter[];
}
// Mon-first: 0=Mon … 6=Sun
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
function pad(n: number) { return String(n).padStart(2, '0'); }
function toDateStr(y: number, m: number, d: number) {
return `${y}-${pad(m + 1)}-${pad(d)}`;
}
// Returns 0 (Mon) … 6 (Sun) for a given date
function mondayDow(year: number, month: number, day: number) {
const dow = new Date(year, month, day).getDay(); // 0=Sun…6=Sat
return (dow + 6) % 7;
}
export default function CalendarView({ dailyCounters }: Props) {
const now = new Date();
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth());
const [selectedDay, setSelectedDay] = useState<{ date: string; counters: DayCounter[] } | null>(null);
// Close modal on Escape
useEffect(() => {
if (!selectedDay) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedDay(null); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [selectedDay]);
const byDate = new Map<string, DayCounter[]>();
for (const dc of dailyCounters) {
if (!byDate.has(dc.date)) byDate.set(dc.date, []);
byDate.get(dc.date)!.push(dc);
}
const todayStr = toDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const isCurrentMonth = viewYear === now.getFullYear() && viewMonth === now.getMonth();
function prev() {
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
else setViewMonth(m => m - 1);
}
function next() {
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1);
}
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const firstOffset = mondayDow(viewYear, viewMonth, 1); // blank cells before day 1
const totalCells = Math.ceil((daysInMonth + firstOffset) / 7) * 7;
const cells = Array.from({ length: totalCells }, (_, i) => {
const dayNum = i - firstOffset + 1;
if (dayNum < 1 || dayNum > daysInMonth) return null;
const date = toDateStr(viewYear, viewMonth, dayNum);
return { dayNum, date, counters: byDate.get(date) ?? [] };
});
return (
<div>
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prev}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text 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">
<polyline points="15 18 9 12 15 6" />
</svg>
Prev
</button>
<div className="flex items-center gap-2">
<span className="font-semibold text-ctp-text">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
{!isCurrentMonth && (
<button
onClick={() => { setViewYear(now.getFullYear()); setViewMonth(now.getMonth()); }}
className="text-xs px-2 py-0.5 rounded-full bg-ctp-surface0 text-ctp-subtext1 hover:bg-ctp-surface1 hover:text-ctp-text transition-colors"
>
Today
</button>
)}
</div>
<button
onClick={next}
disabled={isCurrentMonth}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
Next
<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">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
{/* Day-of-week header */}
<div className="grid grid-cols-7 mb-1">
{DOW.map(d => (
<div key={d} className="text-center text-ctp-overlay0 font-medium" style={{ fontSize: '10px' }}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-ctp-surface1 rounded-xl overflow-hidden border border-ctp-surface1">
{cells.map((cell, i) =>
cell ? (
<div
key={i}
onClick={() => setSelectedDay({ date: cell.date, counters: cell.counters })}
className={`bg-ctp-base min-h-[4.5rem] p-1 flex flex-col gap-0.5 cursor-pointer hover:bg-ctp-surface0 transition-colors ${
cell.date === todayStr ? 'ring-1 ring-inset ring-ctp-mauve' : ''
}`}
>
<span className={`text-xs font-semibold leading-none mb-0.5 ${
cell.date === todayStr
? 'text-ctp-mauve'
: cell.counters.length > 0
? 'text-ctp-text'
: 'text-ctp-overlay1'
}`}>
{cell.dayNum}
</span>
{cell.counters.slice(0, 3).map(c => (
<div
key={c.counter_id}
className="flex items-center gap-0.5 bg-ctp-surface0 rounded px-1 overflow-hidden"
title={`${c.counter_name}: +${c.increments}`}
>
<span className="text-ctp-subtext1 truncate leading-tight" style={{ fontSize: '9px' }}>
{c.counter_name}
</span>
<span className="text-ctp-mauve font-bold shrink-0 leading-tight" style={{ fontSize: '9px' }}>
+{c.increments}
</span>
</div>
))}
{cell.counters.length > 3 && (
<span className="text-ctp-overlay0 leading-tight" style={{ fontSize: '9px' }}>
+{cell.counters.length - 3} more
</span>
)}
</div>
) : (
<div key={i} className="bg-ctp-mantle min-h-[4.5rem]" />
)
)}
</div>
{/* Day detail modal */}
{selectedDay && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
onClick={() => setSelectedDay(null)}
>
<div
className="bg-ctp-surface0 rounded-2xl shadow-xl w-full max-w-sm p-6"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h3 className="font-bold text-ctp-text text-lg">
{new Date(selectedDay.date + 'T00:00:00').toLocaleDateString(undefined, {
weekday: 'long', month: 'long', day: 'numeric',
})}
</h3>
<button
onClick={() => setSelectedDay(null)}
className="text-ctp-overlay1 hover:text-ctp-text transition-colors"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Counter list */}
{selectedDay.counters.length === 0 ? (
<p className="text-center text-ctp-overlay1 py-4">No activity on this day.</p>
) : (
<div className="space-y-3">
{selectedDay.counters.map(c => (
<div key={c.counter_id} className="flex items-center gap-3 bg-ctp-base rounded-xl p-3">
{c.image_path ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${c.image_path}`}
alt=""
className="w-12 h-12 rounded-lg object-cover shrink-0 bg-ctp-surface1"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-ctp-surface1 shrink-0 flex items-center justify-center text-ctp-overlay0 text-xl">
#
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-ctp-text truncate">{c.counter_name}</p>
<p className="text-ctp-mauve font-bold text-lg leading-tight">+{c.increments}</p>
</div>
</div>
))}
</div>
)}
{/* Total */}
{selectedDay.counters.length > 1 && (
<p className="mt-4 text-right text-sm text-ctp-subtext1">
Total: <span className="text-ctp-mauve font-bold">
+{selectedDay.counters.reduce((s, c) => s + c.increments, 0)}
</span>
</p>
)}
</div>
</div>
)}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
export interface Counter {
id: number;
name: string;
value: number;
image_path: string | null;
group_id: number | null;
order_index: number;
}
interface Props {
counter: Counter;
onIncrement: (id: number) => void;
onDecrement: (id: number) => void;
onEdit: (counter: Counter) => void;
onHistory: (id: number) => void;
onPrefetch?: (id: number) => void;
editMode?: boolean;
dragHandleProps?: Record<string, unknown>;
}
export default function CounterCard({ counter, onIncrement, onDecrement, onEdit, onHistory, onPrefetch, editMode, dragHandleProps }: Props) {
return (
<div
{...dragHandleProps}
className={`bg-ctp-surface0 rounded-2xl shadow-sm ring-1 ring-ctp-surface1 overflow-hidden flex flex-col select-none transition-shadow hover:shadow-lg hover:shadow-ctp-crust${dragHandleProps ? ' cursor-grab active:cursor-grabbing' : ''}`}
>
{/* Title area — click opens edit modal */}
<button
onClick={() => !editMode && onEdit(counter)}
className={`w-full flex items-center justify-center px-4 pt-3 pb-2 h-16 transition-colors ${!editMode ? 'hover:bg-ctp-surface1' : ''}`}
title={editMode ? undefined : 'Edit counter'}
>
<h3 className="font-bold text-ctp-text text-lg leading-tight line-clamp-2 text-center">
{counter.name}
</h3>
</button>
{/* Middle: image — click opens history modal */}
<button
onClick={() => !editMode && onHistory(counter.id)}
onMouseEnter={() => !editMode && onPrefetch?.(counter.id)}
className={`w-full flex-1 flex items-center justify-center transition-colors ${!editMode ? 'hover:bg-ctp-surface1' : ''}`}
title={editMode ? undefined : 'View history'}
>
{counter.image_path ? (
<div className="w-full bg-ctp-surface0 flex items-center justify-center overflow-hidden" style={{ minHeight: '6rem', maxHeight: '9rem' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/uploads/${counter.image_path}`}
alt={counter.name}
className="max-w-full max-h-36 object-contain py-2"
/>
</div>
) : (
<div className="w-full flex items-center justify-center" style={{ minHeight: '4rem' }}>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-ctp-overlay0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" 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>
</div>
)}
</button>
{/* Counter controls */}
<div className="flex items-center justify-center px-3 py-3 mt-auto">
<div className="flex items-center bg-ctp-surface1 rounded-2xl overflow-hidden">
<button
onClick={() => onDecrement(counter.id)}
disabled={counter.value <= 0}
aria-label="Decrement"
className="w-11 h-11 flex items-center justify-center text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 active:scale-95 transition-all disabled:opacity-25 disabled:pointer-events-none"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<span className="text-2xl font-extrabold w-14 text-center tabular-nums text-ctp-text tracking-tight select-none">
{counter.value}
</span>
<button
onClick={() => onIncrement(counter.id)}
aria-label="Increment"
className="w-11 h-11 flex items-center justify-center text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 active:scale-95 transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import { Counter } from './CounterCard';
import HistoryChart, { DayValue } from './HistoryChart';
import CalendarView, { DayCounter } from './CalendarView';
export interface HistoryData {
counter: Counter;
dailyActivity: DayValue[];
allTimeTotal: number;
}
interface Props {
counterId: number | null;
cache?: Map<number, HistoryData>;
onClose: () => void;
}
function toLocalDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function computeCurrentStreak(dateSet: Set<string>): number {
const today = new Date();
let streak = 0;
const startOffset = dateSet.has(toLocalDateStr(today)) ? 0 : 1;
for (let i = startOffset; i < 400; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
if (dateSet.has(toLocalDateStr(d))) streak++;
else break;
}
return streak;
}
function computeLongestStreak(sortedDates: string[]): number {
if (sortedDates.length === 0) return 0;
let best = 1, cur = 1;
for (let i = 1; i < sortedDates.length; i++) {
const prev = new Date(sortedDates[i - 1] + 'T00:00:00');
const curr = new Date(sortedDates[i] + 'T00:00:00');
const diff = (curr.getTime() - prev.getTime()) / 86400000;
if (diff === 1) { cur++; if (cur > best) best = cur; }
else cur = 1;
}
return best;
}
export default function CounterDetailModal({ counterId, cache, onClose }: Props) {
const [data, setData] = useState<HistoryData | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!counterId) return;
const cached = cache?.get(counterId);
if (cached) { setData(cached); setLoading(false); return; }
setData(null);
setLoading(true);
fetch(`/api/counters/${counterId}/history`)
.then(r => r.json())
.then((d: HistoryData) => { cache?.set(counterId, d); setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, [counterId, cache]);
useEffect(() => {
if (!counterId) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [counterId, onClose]);
if (!counterId) return null;
const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []);
const sortedDates = [...dateSet].sort();
const currentStreak = computeCurrentStreak(dateSet);
const longestStreak = computeLongestStreak(sortedDates);
const calendarData: DayCounter[] = (data?.dailyActivity ?? []).map(d => ({
date: d.date,
counter_id: data!.counter.id,
counter_name: data!.counter.name,
image_path: data!.counter.image_path,
increments: d.value,
}));
const stats = data ? [
{ label: 'Current value', value: data.counter.value.toLocaleString() },
{ label: 'All-time net', value: data.allTimeTotal >= 0 ? `+${data.allTimeTotal}` : String(data.allTimeTotal) },
{ label: 'Current streak', value: `${currentStreak}d` },
{ label: 'Longest streak', value: `${longestStreak}d` },
] : [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-ctp-surface1 shrink-0">
{data?.counter.image_path && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${data.counter.image_path}`}
alt=""
className="w-9 h-9 rounded-lg object-cover bg-ctp-surface0 shrink-0"
/>
)}
<h2 className="text-lg font-bold text-ctp-text flex-1 truncate">
{data?.counter.name ?? '…'}
</h2>
<button
onClick={onClose}
className="text-ctp-overlay1 hover:text-ctp-text transition-colors shrink-0"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{loading && (
<div className="flex items-center justify-center py-16">
<p className="text-ctp-overlay1 animate-pulse">Loading</p>
</div>
)}
{!loading && data && (
<>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map(({ label, value }) => (
<div key={label} className="bg-ctp-base rounded-xl p-3 text-center">
<p className="text-xl font-extrabold text-ctp-mauve tabular-nums">{value}</p>
<p className="text-xs text-ctp-subtext1 mt-0.5">{label}</p>
</div>
))}
</div>
{data.dailyActivity.length === 0 ? (
<div className="text-center py-10 space-y-1">
<p className="text-ctp-overlay1">No activity in the last year.</p>
<p className="text-ctp-overlay0 text-sm">Start tapping + to see history here.</p>
</div>
) : (
<>
{/* 90-day bar chart */}
<div>
<h3 className="text-sm font-bold text-ctp-text mb-3">Last 90 Days</h3>
<HistoryChart data={data.dailyActivity} days={90} />
</div>
{/* Calendar */}
<div>
<h3 className="text-sm font-bold text-ctp-text mb-4">Activity Calendar</h3>
<CalendarView dailyCounters={calendarData} />
</div>
</>
)}
</>
)}
</div>
</div>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Counter } from './CounterCard';
export interface Group {
id: number;
name: string;
order_index: number;
}
interface Props {
open: boolean;
initial?: Counter | null;
groups: Group[];
onClose: () => void;
onSave: (data: {
name: string;
value: number;
group_id: number | null;
image_path: string | null;
}) => void;
onDelete?: (id: number) => void;
}
export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false);
const deleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [name, setName] = useState('');
const [value, setValue] = useState(0);
const [groupId, setGroupId] = useState<number | null>(null);
const [imagePath, setImagePath] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const nameRef = useRef<HTMLInputElement>(null);
// Revoke blob URLs when previewUrl changes (prevents memory leaks)
useEffect(() => {
const url = previewUrl;
return () => { if (url?.startsWith('blob:')) URL.revokeObjectURL(url); };
}, [previewUrl]);
useEffect(() => {
if (open) {
setName(initial?.name ?? '');
setValue(initial?.value ?? 0);
setGroupId(initial?.group_id ?? null);
setImagePath(initial?.image_path ?? null);
setPreviewUrl(initial?.image_path ? `/api/uploads/${initial.image_path}` : null);
setError('');
setConfirmDelete(false);
if (deleteTimer.current) clearTimeout(deleteTimer.current);
setTimeout(() => nameRef.current?.focus(), 50);
}
}, [open, initial]);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPreviewUrl(URL.createObjectURL(file));
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd });
if (!res.ok) {
const body = await res.json();
setError(body.error ?? 'Upload failed');
return;
}
const { filename } = await res.json();
setImagePath(filename);
} catch {
setError('Upload failed');
} finally {
setUploading(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) { setError('Name is required'); return; }
if (uploading) { setError('Please wait for the image to finish uploading'); return; }
onSave({ name: name.trim(), value, group_id: groupId, image_path: imagePath });
}
function handleBackdrop(e: React.MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={handleBackdrop}
>
<div className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-ctp-surface1">
<h2 className="text-lg font-semibold text-ctp-text">
{initial ? 'Edit Counter' : 'New Counter'}
</h2>
<button onClick={onClose} className="text-ctp-overlay0 hover:text-ctp-text text-xl leading-none">×</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
{error && (
<p className="text-sm text-ctp-red bg-ctp-red/10 rounded-lg px-3 py-2">{error}</p>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Name</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full 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"
placeholder="Counter name"
/>
</div>
{/* Initial value */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">
{initial ? 'Value' : 'Initial Value'}
</label>
<input
type="number"
value={value}
onChange={e => setValue(Number(e.target.value))}
className="w-full 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"
/>
</div>
{/* Group */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Group</label>
<select
value={groupId ?? ''}
onChange={e => setGroupId(e.target.value ? Number(e.target.value) : null)}
className="w-full 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"
>
<option value=""> No group </option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
{/* Image upload */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Photo</label>
{previewUrl && (
<div className="relative w-full mb-2 rounded-lg overflow-hidden bg-ctp-crust flex items-center justify-center" style={{ minHeight: '8rem', maxHeight: '12rem' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewUrl} alt="Preview" className="max-w-full max-h-48 object-contain py-2" />
<button
type="button"
onClick={() => { setPreviewUrl(null); setImagePath(null); }}
className="absolute top-1 right-1 bg-ctp-crust/80 text-ctp-text rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-ctp-surface0"
>
×
</button>
</div>
)}
<label className={`flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-dashed text-sm font-medium transition-colors ${
uploading
? 'border-ctp-surface1 text-ctp-overlay0 cursor-wait'
: 'border-ctp-surface2 text-ctp-subtext1 hover:border-ctp-mauve hover:text-ctp-mauve hover:bg-ctp-mauve/5'
}`}>
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" disabled={uploading} />
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{uploading ? 'Uploading…' : previewUrl ? 'Replace photo' : 'Upload photo'}
</label>
</div>
<div className="flex items-center justify-between gap-3 pt-2">
{/* Delete — only shown when editing an existing counter */}
{initial && onDelete ? (
<button
type="button"
onClick={() => {
if (confirmDelete) {
if (deleteTimer.current) clearTimeout(deleteTimer.current);
onDelete(initial.id);
onClose();
} else {
setConfirmDelete(true);
deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
}
}}
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
confirmDelete
? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
: 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
}`}
>
{confirmDelete ? 'Confirm delete' : 'Delete counter'}
</button>
) : <span />}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender disabled:opacity-50 text-ctp-base font-medium transition-colors"
>
{initial ? 'Save Changes' : 'Create Counter'}
</button>
</div>
</div>
</form>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
import { useDroppable } from '@dnd-kit/core';
import SortableCounter from './SortableCounter';
import { Counter } from './CounterCard';
import { Group } from './CounterModal';
interface Props {
group: Group | null; // null = ungrouped section
counters: Counter[];
onIncrement: (id: number) => void;
onDecrement: (id: number) => void;
onEdit: (counter: Counter) => void;
onHistory: (id: number) => void;
onPrefetch?: (id: number) => void;
editMode: boolean;
dragHandleProps?: Record<string, unknown>;
onRenameGroup?: (group: Group) => void;
onDeleteGroup?: (id: number) => void;
}
export default function GroupSection({
group,
counters,
onIncrement,
onDecrement,
onEdit,
onHistory,
onPrefetch,
editMode,
dragHandleProps,
onRenameGroup,
onDeleteGroup,
}: Props) {
const droppableId = group ? `group-${group.id}` : 'ungrouped';
const { setNodeRef, isOver } = useDroppable({ id: droppableId, data: { groupId: group?.id ?? null } });
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState(group?.name ?? '');
const [confirmDelete, setConfirmDelete] = useState(false);
function commitRename() {
if (group && newName.trim() && newName.trim() !== group.name) {
onRenameGroup?.({ ...group, name: newName.trim() });
}
setEditingName(false);
}
const isEmpty = counters.length === 0;
return (
<div className="mb-8">
{/* Section header */}
<div className="flex items-center gap-3 mb-3">
{group && editMode && dragHandleProps && (
<div
{...dragHandleProps}
className="cursor-grab active:cursor-grabbing text-ctp-overlay0 hover:text-ctp-subtext1 transition-colors shrink-0"
title="Drag to reorder group"
>
<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="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</div>
)}
{group ? (
editingName ? (
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
onBlur={commitRename}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingName(false); }}
className="text-lg font-bold bg-transparent border-b-2 border-ctp-mauve outline-none text-ctp-text"
/>
) : (
editMode ? (
<h2
className="text-lg font-bold text-ctp-text cursor-pointer hover:text-ctp-mauve transition-colors"
title="Click to rename"
onClick={() => { setNewName(group.name); setEditingName(true); }}
>
{group.name}
</h2>
) : (
<h2 className="text-lg font-bold text-ctp-text">{group.name}</h2>
)
)
) : (
<h2 className="text-lg font-bold text-ctp-overlay1">Ungrouped</h2>
)}
{group && editMode && (
<button
onClick={() => {
if (confirmDelete) { onDeleteGroup?.(group.id); }
else {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
}
}}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
confirmDelete
? 'bg-ctp-red text-ctp-base'
: 'bg-ctp-surface0 text-ctp-overlay1 hover:bg-ctp-red/10 hover:text-ctp-red'
}`}
>
{confirmDelete ? 'Sure?' : 'Delete group'}
</button>
)}
</div>
{/* Drop zone */}
<div
ref={setNodeRef}
className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 min-h-[80px] rounded-2xl transition-colors ${
isOver ? 'bg-ctp-mauve/10 ring-2 ring-ctp-mauve' : ''
} ${isEmpty ? 'border-2 border-dashed border-ctp-surface1 p-4' : ''}`}
>
<SortableContext items={counters.map(c => c.id)} strategy={rectSortingStrategy}>
{counters.map(counter => (
<SortableCounter
key={counter.id}
counter={counter}
onIncrement={onIncrement}
onDecrement={onDecrement}
onEdit={onEdit}
onHistory={onHistory}
onPrefetch={onPrefetch}
editMode={editMode}
/>
))}
</SortableContext>
{isEmpty && (
<p className="col-span-full text-center text-sm text-ctp-overlay0 self-center">
Drop counters here
</p>
)}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
export interface DayValue {
date: string; // YYYY-MM-DD
value: number;
}
interface Props {
data: DayValue[];
days?: number;
}
function toLocalDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function HistoryChart({ data, days = 90 }: Props) {
const [hovered, setHovered] = useState<number | null>(null);
const byDate = new Map(data.map(d => [d.date, d.value]));
const today = new Date();
const filled = Array.from({ length: days }, (_, i) => {
const d = new Date(today);
d.setDate(d.getDate() - (days - 1 - i));
const date = toLocalDateStr(d);
return { date, value: byDate.get(date) ?? 0, month: d.getMonth() };
});
const maxVal = Math.max(...filled.map(d => d.value), 1);
const CHART_H = 40;
const BAR_UNIT = 100 / days;
// Show a month label whenever the month changes
const monthLabels: { label: string; pct: number }[] = [];
filled.forEach((d, i) => {
if (i === 0 || d.month !== filled[i - 1].month) {
monthLabels.push({ label: MONTH_ABBR[d.month], pct: (i / days) * 100 });
}
});
const hoveredDay = hovered !== null ? filled[hovered] : null;
return (
<div>
<div className="relative">
{/* Tooltip */}
{hoveredDay && hovered !== null && (
<div
className="absolute bottom-0 mb-1 text-xs bg-ctp-surface1 text-ctp-text rounded-lg px-2 py-1 pointer-events-none shadow border border-ctp-surface2 z-10 whitespace-nowrap -translate-x-1/2"
style={{ left: `${Math.min(92, Math.max(8, ((hovered + 0.5) / days) * 100))}%` }}
>
<span className="font-bold text-ctp-mauve">
{hoveredDay.value > 0 ? `+${hoveredDay.value}` : ''}
</span>
<span className="text-ctp-subtext1 ml-1.5">{hoveredDay.date}</span>
</div>
)}
<svg
viewBox={`0 0 100 ${CHART_H}`}
className="w-full h-20"
preserveAspectRatio="none"
style={{ display: 'block' }}
>
{filled.map((d, i) => {
const barH = d.value > 0 ? Math.max(1.5, (d.value / maxVal) * (CHART_H - 2)) : 1;
const x = i * BAR_UNIT + BAR_UNIT * 0.1;
const w = BAR_UNIT * 0.8;
const y = d.value > 0 ? CHART_H - barH : CHART_H - 1;
return (
<rect
key={d.date}
x={x} y={y}
width={w} height={barH}
rx={0.4}
className={d.value > 0 ? 'fill-ctp-mauve' : 'fill-ctp-surface1'}
style={{ opacity: hovered === i ? 0.6 : 1 }}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
/>
);
})}
</svg>
</div>
{/* Month labels */}
<div className="relative h-4 mt-0.5 select-none">
{monthLabels.map(({ label, pct }) => (
<span
key={`${label}-${pct}`}
className="absolute text-ctp-overlay0 leading-none pointer-events-none"
style={{ left: `${pct}%`, fontSize: '10px' }}
>
{label}
</span>
))}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import CounterCard, { Counter } from './CounterCard';
interface Props {
counter: Counter;
onIncrement: (id: number) => void;
onDecrement: (id: number) => void;
onEdit: (counter: Counter) => void;
onHistory: (id: number) => void;
onPrefetch?: (id: number) => void;
editMode: boolean;
}
export default function SortableCounter({ counter, editMode, ...handlers }: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: counter.id,
data: { type: 'counter', groupId: counter.group_id },
disabled: !editMode,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isDragging ? 50 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<CounterCard
{...handlers}
counter={counter}
editMode={editMode}
dragHandleProps={editMode ? { ...attributes, ...listeners } : undefined}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import GroupSection from './GroupSection';
import { Counter } from './CounterCard';
import { Group } from './CounterModal';
interface Props {
group: Group;
counters: Counter[];
onIncrement: (id: number) => void;
onDecrement: (id: number) => void;
onEdit: (counter: Counter) => void;
onHistory: (id: number) => void;
onPrefetch?: (id: number) => void;
editMode: boolean;
onRenameGroup: (group: Group) => void;
onDeleteGroup: (id: number) => void;
}
export default function SortableGroupSection({ group, editMode, ...props }: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `grp-${group.id}`,
data: { type: 'group' },
disabled: !editMode,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style}>
<GroupSection
group={group}
editMode={editMode}
dragHandleProps={editMode ? { ...attributes, ...listeners } : undefined}
{...props}
/>
</div>
);
}