12 Commits
Author SHA1 Message Date
sebastas eb71302e1c feat: add logging functionality for counter updates for incrementing on different dates (for stats)
Build and publish Docker image / build-and-push (release) Successful in 1m9s
2026-08-25 13:58:29 +02:00
sebastas 3df1e8d7fa feat: implement archiving functionality for counters and update related logic 2026-08-25 13:25:21 +02:00
sebastas 6ccf897fc4 chore: update next.js to version 16.3.2 2026-08-25 13:25:08 +02:00
sebastas b8e7570dc0 fix: update user and group modification logic in entrypoint script (again)
Build and publish Docker image / build-and-push (release) Successful in 19s
2026-07-02 16:36:23 +02:00
sebastas 624dc336a0 fix: update package version to 1.0.1 and resolve dependencies
Build and publish Docker image / build-and-push (release) Successful in 1m9s
2026-07-02 16:25:40 +02:00
sebastas 8eb97b4598 fix: add missing dependencies for build process in Dockerfile
Build and publish Docker image / build-and-push (release) Failing after 15s
2026-07-02 16:16:04 +02:00
sebastas 186b5840a4 fix: enhance user and group ID remapping logic in entrypoint script
Build and publish Docker image / build-and-push (release) Failing after 13s
2026-07-02 16:06:46 +02:00
sebastas 260c8475a4 fix: remove public COPY (directory not used)
Build and publish Docker image / build-and-push (release) Successful in 17s
2026-06-06 17:45:23 +02:00
sebastas dbbca3c13e Version 1.0.0
Build and publish Docker image / build-and-push (release) Failing after 3m35s
2026-06-06 17:39:20 +02:00
sebastas 1094b2c92b Added workflow for publishing package 2026-06-06 17:38:20 +02:00
sebastas 5ad2ad691f Add non-root user support and entrypoint script for Docker setup 2026-06-06 17:27:10 +02:00
sebastas 21620290e2 Add some docs 2026-06-06 17:18:26 +02:00
18 changed files with 1035 additions and 534 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Build and publish Docker image
on:
release:
types: [published]
env:
REGISTRY: gitea.kanawave.net
REGISTRY_USERNAME: sebastas
IMAGE_NAME: ${{ gitea.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
# Permissions are not yet supported in Gitea Actions -> https://github.com/go-gitea/gitea/issues/23642#issuecomment-2119876692
# permissions:
# contents: read
# packages: write
# attestations: write
# id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
id: push
uses: docker/build-push-action@v5
with:
context: .
# file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+9 -6
View File
@@ -1,6 +1,7 @@
# ── Stage 1: deps ─────────────────────────────────────────────────────────────
FROM node:24-alpine3.21 AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json ./
RUN npm ci
@@ -19,20 +20,22 @@ 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 && \
# Create a non-root user with default UID/GID (overridable at runtime via PUID/PGID)
RUN apk add --no-cache su-exec shadow && \
addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs --disabled-password --gecos "" 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
COPY --chmod=755 entrypoint.sh /entrypoint.sh
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV PUID=1001
ENV PGID=1001
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]
+45 -1
View File
@@ -1,3 +1,47 @@
# tally-counter
a tally counter app
A self-hosted tally counter web app. Create counters, organise them into groups, track history, and view stats.
## Features
- Create, edit, and delete counters with optional photos
- Organise counters into groups with drag-and-drop reordering
- Per-counter history modal — streak stats, 90-day chart, activity calendar
- Global stats page with top counters and 12-month calendar
- Counters can't go below 0
- Hover-to-prefetch on history modal for instant opens
- Fully persistent via SQLite (better-sqlite3, WAL mode)
## Tech stack
- **Next.js 16** — App Router, TypeScript, standalone output
- **Tailwind CSS v4** — Catppuccin Mocha theme
- **better-sqlite3** — embedded SQLite, no external database needed
- **@dnd-kit** — drag-and-drop for counters and groups
## Running locally
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
Data is stored in `.data/` (SQLite DB + uploaded images).
## Docker
```bash
docker build -t tally-counter .
docker run -p 3000:3000 -v tally_data:/data tally-counter
```
The `/data` volume holds both the database and uploaded images. Mount it to persist data across container restarts.
## Environment variables
| Variable | Default | Description |
|------------|----------------------|------------------------------------|
| `DATA_DIR` | `.data` (dev) / `/data` (Docker) | Directory for SQLite DB and uploads |
| `PORT` | `3000` | HTTP port |
+14
View File
@@ -0,0 +1,14 @@
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 existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const updated = db.prepare('UPDATE counters SET archived_at = ? WHERE id = ? RETURNING *').get(Date.now(), Number(id));
return NextResponse.json(updated);
}
+1 -1
View File
@@ -8,7 +8,7 @@ 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));
const row = db.prepare('UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 AND archived_at IS NULL 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;
});
+1 -1
View File
@@ -8,7 +8,7 @@ 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));
const row = db.prepare('UPDATE counters SET value = value + 1 WHERE id = ? AND archived_at IS NULL 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;
});
+39
View File
@@ -0,0 +1,39 @@
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(request: Request, { params }: Params) {
const { id } = await params;
const { date, delta } = await request.json();
if (!date || typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date' }, { status: 400 });
}
if (delta !== 1 && delta !== -1) {
return NextResponse.json({ error: 'Delta must be 1 or -1' }, { status: 400 });
}
const existing = db.prepare('SELECT * FROM counters WHERE id = ? AND archived_at IS NULL').get(Number(id)) as Record<string, unknown> | undefined;
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// Store at noon local server time so SQLite's 'localtime' grouping matches the picked date
const createdAt = new Date(`${date}T12:00:00`).getTime();
if (isNaN(createdAt)) return NextResponse.json({ error: 'Invalid date' }, { status: 400 });
const result = db.transaction(() => {
const updated = db.prepare(
delta === 1
? 'UPDATE counters SET value = value + 1 WHERE id = ? RETURNING *'
: 'UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 RETURNING *'
).get(Number(id)) as Record<string, unknown> | undefined;
if (!updated) return null;
db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, ?, ?)').run(Number(id), delta, createdAt);
return updated;
})();
if (!result) return NextResponse.json({ error: 'Counter cannot go below 0' }, { status: 400 });
return NextResponse.json(result);
}
+14
View File
@@ -0,0 +1,14 @@
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 existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const updated = db.prepare('UPDATE counters SET archived_at = NULL WHERE id = ? RETURNING *').get(Number(id));
return NextResponse.json(updated);
}
+6 -2
View File
@@ -3,8 +3,12 @@ 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();
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const archived = searchParams.get('archived') === 'true';
const counters = archived
? db.prepare('SELECT * FROM counters WHERE archived_at IS NOT NULL ORDER BY archived_at DESC').all()
: db.prepare('SELECT * FROM counters WHERE archived_at IS NULL ORDER BY order_index ASC, id ASC').all();
return NextResponse.json(counters);
}
+4 -2
View File
@@ -7,6 +7,7 @@ export interface Counter {
image_path: string | null;
group_id: number | null;
order_index: number;
archived_at?: number | null;
}
interface Props {
@@ -69,7 +70,7 @@ export default function CounterCard({ counter, onIncrement, onDecrement, onEdit,
<div className="flex items-center bg-ctp-surface1 rounded-2xl overflow-hidden">
<button
onClick={() => onDecrement(counter.id)}
disabled={counter.value <= 0}
disabled={counter.value <= 0 || !!counter.archived_at}
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"
>
@@ -83,7 +84,8 @@ export default function CounterCard({ counter, onIncrement, onDecrement, onEdit,
<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"
disabled={!!counter.archived_at}
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 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="12" y1="5" x2="12" y2="19" />
+71 -1
View File
@@ -15,6 +15,7 @@ interface Props {
counterId: number | null;
cache?: Map<number, HistoryData>;
onClose: () => void;
onLog?: (counter: Counter) => void;
}
function toLocalDateStr(d: Date): string {
@@ -47,12 +48,18 @@ function computeLongestStreak(sortedDates: string[]): number {
return best;
}
export default function CounterDetailModal({ counterId, cache, onClose }: Props) {
export default function CounterDetailModal({ counterId, cache, onClose, onLog }: Props) {
const [data, setData] = useState<HistoryData | null>(null);
const [loading, setLoading] = useState(false);
const [logDate, setLogDate] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 1); return toLocalDateStr(d); });
const [logging, setLogging] = useState(false);
const [logError, setLogError] = useState('');
useEffect(() => {
if (!counterId) return;
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1);
setLogDate(toLocalDateStr(yesterday));
setLogError('');
const cached = cache?.get(counterId);
if (cached) { setData(cached); setLoading(false); return; }
setData(null);
@@ -70,6 +77,33 @@ export default function CounterDetailModal({ counterId, cache, onClose }: Props)
return () => window.removeEventListener('keydown', handler);
}, [counterId, onClose]);
async function handleLog(delta: number) {
if (!counterId || !logDate) return;
setLogging(true);
setLogError('');
try {
const res = await fetch(`/api/counters/${counterId}/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: logDate, delta }),
});
if (!res.ok) {
const body = await res.json();
setLogError(body.error ?? 'Failed to log entry');
return;
}
const updated: Counter = await res.json();
cache?.delete(counterId);
const histRes = await fetch(`/api/counters/${counterId}/history`);
const d: HistoryData = await histRes.json();
cache?.set(counterId, d);
setData(d);
onLog?.(updated);
} finally {
setLogging(false);
}
}
if (!counterId) return null;
const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []);
@@ -165,6 +199,42 @@ export default function CounterDetailModal({ counterId, cache, onClose }: Props)
</div>
</>
)}
{/* Log past entry */}
<div className="border-t border-ctp-surface1 pt-4">
<h3 className="text-sm font-bold text-ctp-text mb-3">Log past entry</h3>
<div className="flex items-center gap-2 flex-wrap">
<input
type="date"
value={logDate}
max={toLocalDateStr(new Date())}
onChange={e => setLogDate(e.target.value)}
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={() => handleLog(-1)}
disabled={logging || !logDate || (data?.dailyActivity.find(d => d.date === logDate)?.value ?? 0) <= 0}
aria-label="Log decrement"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-ctp-surface1 text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 disabled:opacity-40 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.5" strokeLinecap="round">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
onClick={() => handleLog(1)}
disabled={logging || !logDate}
aria-label="Log increment"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-ctp-surface1 text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 disabled:opacity-40 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.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
{logError && <p className="text-xs text-ctp-red">{logError}</p>}
</div>
</div>
</>
)}
</div>
+45 -23
View File
@@ -21,9 +21,11 @@ interface Props {
image_path: string | null;
}) => void;
onDelete?: (id: number) => void;
onArchive?: (id: number) => void;
onUnarchive?: (id: number) => void;
}
export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete }: Props) {
export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete, onArchive, onUnarchive }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false);
const deleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [name, setName] = useState('');
@@ -191,28 +193,48 @@ export default function CounterModal({ open, initial, groups, onClose, onSave, o
</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>
{initial ? (
initial.archived_at ? (
<div className="flex gap-2">
<button
type="button"
onClick={() => { onUnarchive?.(initial.id); onClose(); }}
className="px-4 py-2 text-sm rounded-lg font-medium border border-ctp-green/40 text-ctp-green hover:bg-ctp-green/10 transition-colors"
>
Unarchive
</button>
{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'}
</button>
)}
</div>
) : (
<button
type="button"
onClick={() => { onArchive?.(initial.id); onClose(); }}
className="px-4 py-2 text-sm rounded-lg font-medium border border-ctp-overlay1/40 text-ctp-subtext1 hover:bg-ctp-surface1 transition-colors"
>
Archive
</button>
)
) : <span />}
<div className="flex gap-3">
+93 -5
View File
@@ -34,6 +34,8 @@ export default function Home() {
const [loading, setLoading] = useState(true);
const [historyCounterId, setHistoryCounterId] = useState<number | null>(null);
const [editMode, setEditMode] = useState(false);
const [archivedCounters, setArchivedCounters] = useState<Counter[]>([]);
const [showArchived, setShowArchived] = useState(false);
// Always-current ref so drag handlers never read stale closure state
const countersRef = useRef(counters);
@@ -65,6 +67,14 @@ export default function Home() {
useEffect(() => { load(); }, []);
useEffect(() => { router.prefetch('/stats'); }, [router]);
useEffect(() => {
if (showArchived) {
fetch('/api/counters?archived=true')
.then(r => r.json())
.then(setArchivedCounters)
.catch(() => {});
}
}, [showArchived]);
// ── Optimistic increment/decrement ─────────────────────────────────────────
const handleIncrement = useCallback(async (id: number) => {
@@ -86,7 +96,9 @@ export default function Home() {
if (editingCounter) {
// Optimistic update — close immediately, patch in background
const optimistic = { ...editingCounter, ...data };
setCounters(prev => prev.map(c => c.id === editingCounter.id ? optimistic : c));
const isArchived = !!editingCounter.archived_at;
const setTarget = isArchived ? setArchivedCounters : setCounters;
setTarget(prev => prev.map(c => c.id === editingCounter.id ? optimistic : c));
setModalOpen(false);
setEditingCounter(null);
const res = await fetch(`/api/counters/${editingCounter.id}`, {
@@ -96,10 +108,9 @@ export default function Home() {
});
if (res.ok) {
const updated = await res.json();
setCounters(prev => prev.map(c => c.id === updated.id ? updated : c));
setTarget(prev => prev.map(c => c.id === updated.id ? updated : c));
} else {
// Roll back
setCounters(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c));
setTarget(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c));
}
} else {
// Optimistic create with a temporary id
@@ -126,9 +137,41 @@ export default function Home() {
// ── Delete counter ─────────────────────────────────────────────────────────
async function handleDeleteCounter(id: number) {
const prev = counters.find(c => c.id === id);
const prevArchived = archivedCounters.find(c => c.id === id);
setCounters(cs => cs.filter(c => c.id !== id));
setArchivedCounters(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));
if (!res.ok) {
if (prev) setCounters(cs => [...cs, prev].sort((a, b) => a.order_index - b.order_index));
if (prevArchived) setArchivedCounters(cs => [...cs, prevArchived]);
}
}
// ── Archive / Unarchive counter ──────────────────────────────────────────────────────
async function handleArchiveCounter(id: number) {
const counter = counters.find(c => c.id === id);
if (!counter) return;
setCounters(cs => cs.filter(c => c.id !== id));
const res = await fetch(`/api/counters/${id}/archive`, { method: 'POST' });
if (res.ok) {
const archived = await res.json();
if (showArchived) setArchivedCounters(prev => [archived, ...prev]);
} else {
setCounters(cs => [...cs, counter].sort((a, b) => a.order_index - b.order_index));
}
}
async function handleUnarchiveCounter(id: number) {
const counter = archivedCounters.find(c => c.id === id);
if (!counter) return;
setArchivedCounters(prev => prev.filter(c => c.id !== id));
const res = await fetch(`/api/counters/${id}/unarchive`, { method: 'POST' });
if (res.ok) {
const unarchived = await res.json();
setCounters(prev => [...prev, unarchived].sort((a, b) => a.order_index - b.order_index));
} else {
setArchivedCounters(prev => [...prev, counter]);
}
}
// ── Groups ─────────────────────────────────────────────────────────────────
@@ -275,6 +318,21 @@ export default function Home() {
</svg>
Stats
</Link>
<button
onClick={() => setShowArchived(v => !v)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border transition-colors ${
showArchived
? 'border-ctp-yellow/50 bg-ctp-yellow/10 text-ctp-yellow hover:bg-ctp-yellow/20'
: 'border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0'
}`}
>
<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="21 8 21 21 3 21 3 8" />
<rect x="1" y="3" width="22" height="5" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
Archived
</button>
{addingGroup ? (
<div className="flex gap-2">
<input
@@ -396,6 +454,30 @@ export default function Home() {
</button>
</div>
)}
{showArchived && (
<div className="mt-10">
<h2 className="text-lg font-bold text-ctp-overlay1 mb-3">Archived</h2>
{archivedCounters.length === 0 ? (
<p className="text-ctp-overlay0 text-sm">No archived counters.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 opacity-60">
{archivedCounters.map(counter => (
<CounterCard
key={counter.id}
counter={counter}
onIncrement={() => {}}
onDecrement={() => {}}
onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
onHistory={setHistoryCounterId}
onPrefetch={handlePrefetch}
editMode={false}
/>
))}
</div>
)}
</div>
)}
</div>
<CounterModal
@@ -405,12 +487,18 @@ export default function Home() {
onClose={() => { setModalOpen(false); setEditingCounter(null); }}
onSave={handleSaveCounter}
onDelete={handleDeleteCounter}
onArchive={handleArchiveCounter}
onUnarchive={handleUnarchiveCounter}
/>
<CounterDetailModal
counterId={historyCounterId}
cache={historyCache.current}
onClose={() => setHistoryCounterId(null)}
onLog={updated => {
setCounters(prev => prev.map(c => c.id === updated.id ? { ...c, value: updated.value } : c));
setArchivedCounters(prev => prev.map(c => c.id === updated.id ? { ...c, value: updated.value } : c));
}}
/>
</main>
);
+2 -1
View File
@@ -6,7 +6,8 @@ services:
volumes:
- app_data:/data
environment:
- DATA_DIR=/data
PUID: 1000
PGID: 1000
restart: unless-stopped
volumes:
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
set -e
PUID=${PUID:-1001}
PGID=${PGID:-1001}
# Only remap if the requested IDs differ from the defaults baked into the image
if [ "$PGID" != "1001" ]; then
existing_group=$(getent group "$PGID" | cut -d: -f1)
if [ -n "$existing_group" ] && [ "$existing_group" != "nodejs" ]; then
groupmod -g "$((PGID + 50000))" "$existing_group"
fi
groupmod -g "$PGID" nodejs
fi
if [ "$PUID" != "1001" ]; then
existing_user=$(getent passwd "$PUID" | cut -d: -f1)
if [ -n "$existing_user" ] && [ "$existing_user" != "nextjs" ]; then
usermod -u "$((PUID + 50000))" "$existing_user"
fi
usermod -u "$PUID" nextjs
fi
chown -R nextjs:nodejs /data
exec su-exec nextjs "$@"
+8 -1
View File
@@ -29,7 +29,8 @@ db.exec(`
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
order_index INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER
);
CREATE TABLE IF NOT EXISTS events (
@@ -42,5 +43,11 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_events_date ON events(created_at);
`);
// Incremental migrations
const cols = (db.prepare("PRAGMA table_info(counters)").all() as { name: string }[]).map(c => c.name);
if (!cols.includes('archived_at')) {
db.exec('ALTER TABLE counters ADD COLUMN archived_at INTEGER');
}
export { UPLOADS_DIR };
export default db;
+608 -488
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tally-counter",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,7 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"better-sqlite3": "^12.10.0",
"next": "16.2.7",
"next": "^16.3.2",
"react": "19.2.4",
"react-dom": "19.2.4"
},