Web app ready
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.next
|
||||||
|
.data
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Dev / editor
|
||||||
|
.env*.local
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# Local dev data (SQLite DB + uploads)
|
||||||
|
/.data
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 904 B |
@@ -1,26 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "@catppuccin/tailwindcss/mocha.css";
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@layer base {
|
||||||
:root {
|
button, [role="button"], label, select {
|
||||||
--background: #0a0a0a;
|
cursor: pointer;
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -7,14 +7,9 @@ const geistSans = Geist({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Tally Counter",
|
||||||
description: "Generated by create next app",
|
description: "Keep track of your tally counters",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -25,7 +20,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,65 +1,417 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
|
import CounterCard, { Counter } from './components/CounterCard';
|
||||||
|
import CounterModal, { Group } from './components/CounterModal';
|
||||||
|
import CounterDetailModal, { HistoryData } from './components/CounterDetailModal';
|
||||||
|
import GroupSection from './components/GroupSection';
|
||||||
|
import SortableGroupSection from './components/SortableGroupSection';
|
||||||
|
|
||||||
export default function Home() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="min-h-screen bg-ctp-base px-4 py-8">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="max-w-7xl mx-auto">
|
||||||
<Image
|
<div className="flex items-center justify-between mb-8 flex-wrap gap-4">
|
||||||
className="dark:invert"
|
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Tally counters</h1>
|
||||||
src="/next.svg"
|
<div className="flex gap-3 items-center">
|
||||||
alt="Next.js logo"
|
{[16, 24, 16, 28].map((w, i) => (
|
||||||
width={100}
|
<div key={i} className={`h-9 w-${w} rounded-lg bg-ctp-surface0 animate-pulse`} />
|
||||||
height={20}
|
))}
|
||||||
priority
|
</div>
|
||||||
/>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
To get started, edit the page.tsx file.
|
<div key={i} className="bg-ctp-surface0 rounded-2xl h-52 animate-pulse" />
|
||||||
</h1>
|
))}
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-ctp-base px-4 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8 flex-wrap gap-4">
|
||||||
|
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Tally counters</h1>
|
||||||
|
<div className="flex gap-3 flex-wrap items-center">
|
||||||
|
<Link
|
||||||
|
href="/stats"
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
{addingGroup ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={e => setNewGroupName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateGroup(); if (e.key === 'Escape') { setAddingGroup(false); setNewGroupName(''); } }}
|
||||||
|
placeholder="Group name…"
|
||||||
|
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
|
||||||
|
/>
|
||||||
|
<button onClick={handleCreateGroup} className="px-3 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors">Add</button>
|
||||||
|
<button onClick={() => { setAddingGroup(false); setNewGroupName(''); }} className="px-3 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setAddingGroup(true)} className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors">
|
||||||
|
+ New Group
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (editMode) {
|
||||||
|
// Persist all reorder/group changes accumulated during edit mode
|
||||||
|
setEditMode(false);
|
||||||
|
fetch('/api/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
counters: counters.map(c => ({ id: c.id, order_index: c.order_index, group_id: c.group_id })),
|
||||||
|
groups: groups.map(g => ({ id: g.id, order_index: g.order_index })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditMode(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||||
|
editMode
|
||||||
|
? 'border-ctp-mauve bg-ctp-mauve/10 text-ctp-mauve hover:bg-ctp-mauve/20'
|
||||||
|
: 'border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editMode ? 'Done' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCounter(null); setModalOpen(true); }}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
+ Add Counter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{/* Groups */}
|
||||||
|
<SortableContext items={groups.map(g => `grp-${g.id}`)} strategy={verticalListSortingStrategy}>
|
||||||
|
{groups.map(group => (
|
||||||
|
<SortableGroupSection
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
counters={counters.filter(c => c.group_id === group.id)}
|
||||||
|
onIncrement={handleIncrement}
|
||||||
|
onDecrement={handleDecrement}
|
||||||
|
onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
|
||||||
|
onHistory={setHistoryCounterId}
|
||||||
|
onPrefetch={handlePrefetch}
|
||||||
|
editMode={editMode}
|
||||||
|
onRenameGroup={handleRenameGroup}
|
||||||
|
onDeleteGroup={handleDeleteGroup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<GroupSection
|
||||||
|
group={null}
|
||||||
|
counters={ungrouped}
|
||||||
|
onIncrement={handleIncrement}
|
||||||
|
onDecrement={handleDecrement}
|
||||||
|
onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
|
||||||
|
onHistory={setHistoryCounterId}
|
||||||
|
onPrefetch={handlePrefetch}
|
||||||
|
editMode={editMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeCounter && (
|
||||||
|
<div className="rotate-2 scale-105 shadow-2xl">
|
||||||
|
<CounterCard
|
||||||
|
counter={activeCounter}
|
||||||
|
onIncrement={() => {}}
|
||||||
|
onDecrement={() => {}}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onHistory={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeGroup && (
|
||||||
|
<div className="px-4 py-3 bg-ctp-mantle rounded-xl shadow-2xl ring-1 ring-ctp-mauve opacity-90">
|
||||||
|
<span className="font-bold text-ctp-text">{activeGroup.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{counters.length === 0 && (
|
||||||
|
<div className="text-center mt-20">
|
||||||
|
<p className="text-ctp-overlay0 text-lg">No counters yet.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="mt-4 px-6 py-3 rounded-xl bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors shadow"
|
||||||
|
>
|
||||||
|
Create your first counter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CounterModal
|
||||||
|
open={modalOpen}
|
||||||
|
initial={editingCounter}
|
||||||
|
groups={groups}
|
||||||
|
onClose={() => { setModalOpen(false); setEditingCounter(null); }}
|
||||||
|
onSave={handleSaveCounter}
|
||||||
|
onDelete={handleDeleteCounter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CounterDetailModal
|
||||||
|
counterId={historyCounterId}
|
||||||
|
cache={historyCache.current}
|
||||||
|
onClose={() => setHistoryCounterId(null)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "tally-counter-scaffold",
|
"name": "tally-counter",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tally-counter-scaffold",
|
"name": "tally-counter",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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",
|
"next": "16.2.7",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@catppuccin/tailwindcss": "^1.0.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -277,28 +283,68 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@catppuccin/tailwindcss": {
|
||||||
"version": "1.10.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@catppuccin/tailwindcss/-/tailwindcss-1.0.0.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-l8pOlcYe2ncGd8a1gUmL5AHmKlxR2+CHuG5kt4Me6IZwzntW1DoLmj89BH+DcsPHBsdDGLrTSv35emlYyU3FeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"engines": {
|
||||||
"peer": true,
|
"node": ">=22.0.0"
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "1.10.0",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
@@ -1547,6 +1593,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/better-sqlite3": {
|
||||||
|
"version": "7.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1569,9 +1625,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.42",
|
"version": "22.19.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
|
||||||
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
|
"integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2502,6 +2558,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.34",
|
"version": "2.10.34",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
||||||
@@ -2514,6 +2590,40 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/better-sqlite3": {
|
||||||
|
"version": "12.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
|
||||||
|
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^5.5.0",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
@@ -2573,6 +2683,30 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -2670,6 +2804,12 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -2811,6 +2951,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-extend": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -2858,7 +3022,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2906,6 +3069,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.23.0",
|
"version": "5.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
||||||
@@ -3312,6 +3484,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3527,6 +3700,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expand-template": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||||
|
"license": "(MIT OR WTFPL)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3601,6 +3783,12 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -3668,6 +3856,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-constants": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -3799,6 +3993,12 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-from-package": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -3973,6 +4173,26 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4010,6 +4230,18 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -4970,6 +5202,18 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -4987,12 +5231,17 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp-classic": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -5018,6 +5267,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/napi-build-utils": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/napi-postinstall": {
|
"node_modules/napi-postinstall": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||||
@@ -5122,6 +5377,30 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abi": {
|
||||||
|
"version": "3.92.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
|
||||||
|
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-abi/node_modules/semver": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||||
@@ -5274,6 +5553,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -5440,6 +5728,33 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prebuild-install": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||||
|
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"expand-template": "^2.0.3",
|
||||||
|
"github-from-package": "0.0.0",
|
||||||
|
"minimist": "^1.2.3",
|
||||||
|
"mkdirp-classic": "^0.5.3",
|
||||||
|
"napi-build-utils": "^2.0.0",
|
||||||
|
"node-abi": "^3.3.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"rc": "^1.2.7",
|
||||||
|
"simple-get": "^4.0.0",
|
||||||
|
"tar-fs": "^2.0.0",
|
||||||
|
"tunnel-agent": "^0.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prebuild-install": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -5462,6 +5777,16 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5493,6 +5818,30 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/rc": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||||
|
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-extend": "^0.6.0",
|
||||||
|
"ini": "~1.3.0",
|
||||||
|
"minimist": "^1.2.0",
|
||||||
|
"strip-json-comments": "~2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rc": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rc/node_modules/strip-json-comments": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -5523,6 +5872,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5666,6 +6029,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -5923,6 +6306,51 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-concat": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/simple-get": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"once": "^1.3.1",
|
||||||
|
"simple-concat": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -5953,6 +6381,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -6160,6 +6597,34 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-fs": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^1.1.1",
|
||||||
|
"mkdirp-classic": "^0.5.2",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"tar-stream": "^2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-stream": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bl": "^4.0.3",
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"fs-constants": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -6267,6 +6732,18 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tunnel-agent": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -6502,6 +6979,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -6617,6 +7100,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "tally-counter-scaffold",
|
"name": "tally-counter",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,13 +9,19 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.2.7",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@catppuccin/tailwindcss": "^1.0.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |