Compare commits
2 Commits
458c7eae0c
...
3e127afbae
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e127afbae | |||
| ff187a5bd4 |
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
.data
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
|
||||
# Dev / editor
|
||||
.env*.local
|
||||
*.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Local dev data (SQLite DB + uploads)
|
||||
/.data
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# ── Stage 1: deps ─────────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine3.21 AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ── Stage 2: builder ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine3.21 AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: runner ───────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine3.21 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# DATA_DIR is the single volume mount point for both SQLite and uploads
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
# Create a non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
mkdir -p /data/uploads && chown -R nextjs:nodejs /data
|
||||
|
||||
# Copy standalone build output
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(_req: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const tx = db.transaction(() => {
|
||||
const row = db.prepare('UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 RETURNING *').get(Number(id));
|
||||
if (row) db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, -1, ?)').run(Number(id), Date.now());
|
||||
return row;
|
||||
});
|
||||
const result = tx();
|
||||
if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
|
||||
if (!counter) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
const cutoff = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const dailyActivity = db.prepare(`
|
||||
SELECT
|
||||
date(created_at / 1000, 'unixepoch', 'localtime') AS date,
|
||||
SUM(delta) AS value
|
||||
FROM events
|
||||
WHERE counter_id = ? AND created_at >= ?
|
||||
GROUP BY date
|
||||
HAVING SUM(delta) > 0
|
||||
ORDER BY date ASC
|
||||
`).all(Number(id), cutoff);
|
||||
|
||||
const allTimeTotal = (db.prepare(
|
||||
'SELECT COALESCE(SUM(delta), 0) AS total FROM events WHERE counter_id = ?'
|
||||
).get(Number(id)) as { total: number }).total;
|
||||
|
||||
return NextResponse.json({ counter, dailyActivity, allTimeTotal });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(_req: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const tx = db.transaction(() => {
|
||||
const row = db.prepare('UPDATE counters SET value = value + 1 WHERE id = ? RETURNING *').get(Number(id));
|
||||
if (row) db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, 1, ?)').run(Number(id), Date.now());
|
||||
return row;
|
||||
});
|
||||
const result = tx();
|
||||
if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import db, { UPLOADS_DIR } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
|
||||
if (!counter) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json(counter);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)) as Record<string, unknown> | undefined;
|
||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
const name = body.name?.trim() ?? existing.name;
|
||||
const value = body.value !== undefined ? body.value : existing.value;
|
||||
const group_id = body.group_id !== undefined ? body.group_id : existing.group_id;
|
||||
const image_path = body.image_path !== undefined ? body.image_path : existing.image_path;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE counters SET name = ?, value = ?, group_id = ?, image_path = ? WHERE id = ?'
|
||||
).run(name, value, group_id, image_path, Number(id));
|
||||
|
||||
const updated = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)) as Record<string, unknown> | undefined;
|
||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
// Remove associated image if present
|
||||
if (existing.image_path) {
|
||||
const imgFile = path.join(UPLOADS_DIR, path.basename(existing.image_path as string));
|
||||
try { fs.unlinkSync(imgFile); } catch { /* ignore missing file */ }
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM counters WHERE id = ?').run(Number(id));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const counters = db.prepare('SELECT * FROM counters ORDER BY order_index ASC, id ASC').all();
|
||||
return NextResponse.json(counters);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { name, value = 0, group_id = null, image_path = null } = await request.json();
|
||||
if (!name?.trim()) {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
const maxOrder = (db.prepare(
|
||||
'SELECT COALESCE(MAX(order_index), -1) AS m FROM counters'
|
||||
).get() as { m: number }).m;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO counters (name, value, group_id, image_path, order_index) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(name.trim(), value, group_id, image_path, maxOrder + 1);
|
||||
const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(result.lastInsertRowid);
|
||||
return NextResponse.json(counter, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PUT(request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const { name } = await request.json();
|
||||
const existing = db.prepare('SELECT * FROM groups WHERE id = ?').get(Number(id));
|
||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
if (!name?.trim()) return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||
db.prepare('UPDATE groups SET name = ? WHERE id = ?').run(name.trim(), Number(id));
|
||||
const updated = db.prepare('SELECT * FROM groups WHERE id = ?').get(Number(id));
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const existing = db.prepare('SELECT * FROM groups WHERE id = ?').get(Number(id));
|
||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE counters SET group_id = NULL WHERE group_id = ?').run(Number(id));
|
||||
db.prepare('DELETE FROM groups WHERE id = ?').run(Number(id));
|
||||
})();
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY order_index ASC, id ASC').all();
|
||||
return NextResponse.json(groups);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { name } = await request.json();
|
||||
if (!name?.trim()) {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
const maxOrder = (db.prepare(
|
||||
'SELECT COALESCE(MAX(order_index), -1) AS m FROM groups'
|
||||
).get() as { m: number }).m;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO groups (name, order_index) VALUES (?, ?)'
|
||||
).run(name.trim(), maxOrder + 1);
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(result.lastInsertRowid);
|
||||
return NextResponse.json(group, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Body: { counters?: { id: number; order_index: number; group_id: number | null }[], groups?: { id: number; order_index: number }[] }
|
||||
export async function PUT(request: Request) {
|
||||
const { counters, groups } = await request.json();
|
||||
|
||||
const updateCounters = db.prepare(
|
||||
'UPDATE counters SET order_index = ?, group_id = ? WHERE id = ?'
|
||||
);
|
||||
const updateGroups = db.prepare(
|
||||
'UPDATE groups SET order_index = ? WHERE id = ?'
|
||||
);
|
||||
|
||||
const reorderAll = db.transaction(() => {
|
||||
if (Array.isArray(counters)) {
|
||||
for (const c of counters) {
|
||||
updateCounters.run(c.order_index, c.group_id ?? null, c.id);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(groups)) {
|
||||
for (const g of groups) {
|
||||
updateGroups.run(g.order_index, g.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
reorderAll();
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { UPLOADS_DIR } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ALLOWED_TYPES: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
if (!ALLOWED_TYPES[file.type]) {
|
||||
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return NextResponse.json({ error: 'File too large (max 10 MB)' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = ALLOWED_TYPES[file.type];
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(path.join(UPLOADS_DIR, filename), buffer);
|
||||
|
||||
return NextResponse.json({ filename });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import { readFile, stat, access } from 'fs/promises';
|
||||
import { createHash } from 'crypto';
|
||||
import { UPLOADS_DIR } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ filename: string }> };
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
export async function GET(req: Request, { params }: Params) {
|
||||
const { filename } = await params;
|
||||
// Prevent path traversal
|
||||
const safe = path.basename(filename);
|
||||
const filePath = path.join(UPLOADS_DIR, safe);
|
||||
try { await access(filePath); } catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const ext = safe.split('.').pop()?.toLowerCase() ?? '';
|
||||
const contentType = MIME_MAP[ext] ?? 'application/octet-stream';
|
||||
|
||||
const stats = await stat(filePath);
|
||||
const etag = `"${createHash('md5').update(`${stats.size}-${stats.mtimeMs}`).digest('hex')}"`;
|
||||
|
||||
// Return 304 if browser already has this version
|
||||
if (req.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, { status: 304 });
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'ETag': etag,
|
||||
'Last-Modified': stats.mtime.toUTCString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 904 B |
@@ -0,0 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "@catppuccin/tailwindcss/mocha.css";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
button, [role="button"], label, select {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tally Counter",
|
||||
description: "Keep track of your tally counters",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export default function StatsLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-ctp-base px-4 py-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="h-8 w-20 rounded-lg bg-ctp-surface0 animate-pulse" />
|
||||
<div className="h-9 w-36 rounded-lg bg-ctp-surface0 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-ctp-mantle rounded-2xl p-6">
|
||||
<div className="h-5 w-48 rounded bg-ctp-surface0 animate-pulse mb-5" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-14 h-14 rounded-xl bg-ctp-surface0 animate-pulse shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-3 rounded bg-ctp-surface0 animate-pulse w-3/4" />
|
||||
<div className="h-4 rounded bg-ctp-surface0 animate-pulse w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-ctp-surface0 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-ctp-mantle rounded-2xl p-6">
|
||||
<div className="h-5 w-52 rounded bg-ctp-surface0 animate-pulse mb-5" />
|
||||
<div className="grid grid-cols-7 gap-px">
|
||||
{Array.from({ length: 35 }).map((_, i) => (
|
||||
<div key={i} className="h-16 rounded bg-ctp-surface0 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Link from 'next/link';
|
||||
import db from '@/lib/db';
|
||||
import CalendarView, { DayCounter } from '../components/CalendarView';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface TopCounter {
|
||||
id: number;
|
||||
name: string;
|
||||
image_path: string | null;
|
||||
increments: number;
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
const cutoff = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const topCounters = db.prepare(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.image_path,
|
||||
COALESCE(SUM(e.delta), 0) AS increments
|
||||
FROM counters c
|
||||
LEFT JOIN events e ON e.counter_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING increments > 0
|
||||
ORDER BY increments DESC
|
||||
LIMIT 10
|
||||
`).all() as TopCounter[];
|
||||
|
||||
const dailyCounters = db.prepare(`
|
||||
SELECT
|
||||
date(e.created_at / 1000, 'unixepoch', 'localtime') AS date,
|
||||
c.id AS counter_id,
|
||||
c.name AS counter_name,
|
||||
c.image_path AS image_path,
|
||||
SUM(e.delta) AS increments
|
||||
FROM events e
|
||||
JOIN counters c ON c.id = e.counter_id
|
||||
WHERE e.created_at >= ?
|
||||
GROUP BY date, c.id
|
||||
HAVING SUM(e.delta) > 0
|
||||
ORDER BY date ASC, increments DESC
|
||||
`).all(cutoff) as DayCounter[];
|
||||
|
||||
const maxIncrements = Math.max(...topCounters.map(c => c.increments), 1);
|
||||
const totalIncrements = dailyCounters.reduce((s, d) => s + d.increments, 0);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-ctp-base px-4 py-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-ctp-subtext1 bg-ctp-surface0 hover:bg-ctp-surface1 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>
|
||||
Back
|
||||
</Link>
|
||||
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Statistics</h1>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{totalIncrements === 0 && topCounters.length === 0 ? (
|
||||
<div className="text-center mt-24 space-y-2">
|
||||
<p className="text-ctp-overlay1 text-lg">No activity recorded yet.</p>
|
||||
<p className="text-ctp-overlay0 text-sm">Start tapping + on your counters to see stats appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top counters */}
|
||||
{topCounters.length > 0 && (
|
||||
<section className="bg-ctp-mantle rounded-2xl p-6">
|
||||
<h2 className="text-base font-bold text-ctp-text mb-5">Most Active Counters</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{topCounters.map(counter => (
|
||||
<div key={counter.id}>
|
||||
<div className="flex items-center justify-between mb-1.5 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{counter.image_path ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/uploads/${counter.image_path}`}
|
||||
alt=""
|
||||
className="w-14 h-14 rounded-xl object-cover shrink-0 bg-ctp-surface0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-xl bg-ctp-surface1 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-ctp-text truncate">
|
||||
{counter.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-ctp-green shrink-0 tabular-nums">
|
||||
+{counter.increments}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-ctp-surface0 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-ctp-mauve h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${(counter.increments / maxIncrements) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Activity calendar */}
|
||||
{totalIncrements > 0 && (
|
||||
<section className="bg-ctp-mantle rounded-2xl p-6">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="text-base font-bold text-ctp-text">Activity — Last 12 Months</h2>
|
||||
<span className="text-xs text-ctp-overlay1 tabular-nums">{totalIncrements} total increments</span>
|
||||
</div>
|
||||
<CalendarView dailyCounters={dailyCounters} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- app_data:/data
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,46 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? path.join(process.cwd(), '.data');
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads');
|
||||
const DB_PATH = path.join(DATA_DIR, 'tally.db');
|
||||
|
||||
// Ensure directories exist
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH, { timeout: 5000 });
|
||||
|
||||
// Performance: WAL mode for concurrent reads + writes
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Schema migrations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
order_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
value INTEGER NOT NULL DEFAULT 0,
|
||||
image_path TEXT,
|
||||
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
order_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
counter_id INTEGER NOT NULL REFERENCES counters(id) ON DELETE CASCADE,
|
||||
delta INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_counter ON events(counter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_date ON events(created_at);
|
||||
`);
|
||||
|
||||
export { UPLOADS_DIR };
|
||||
export default db;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Images are served via our own /api/uploads route handler, so no external hostname needed.
|
||||
// next/image requires a remotePatterns entry only for external domains.
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "tally-counter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/tailwindcss": "^1.0.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user