feat: implement counter management application with drag-and-drop functionality
- Add main application component (App.tsx) to manage counters - Create Counter component for individual counter display and editing - Implement CreateCounter component for adding new counters - Add API utility for handling server requests - Set up Vite configuration with proxy for API calls - Introduce TypeScript configuration for app and node environments - Style application with global CSS for consistent design
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Node modules (global catch-all)
|
||||
**/node_modules/
|
||||
|
||||
# Frontend build outputs
|
||||
/frontend/dist/
|
||||
/frontend/build/
|
||||
/frontend/.vite/
|
||||
|
||||
# Backend runtime data (DB + uploads)
|
||||
backend/data/
|
||||
backend/data/**
|
||||
|
||||
# Keep prisma migrations tracked (commit these)
|
||||
!/backend/prisma/migrations/
|
||||
!/backend/prisma/migrations/**
|
||||
|
||||
# Database files
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Environment (do NOT commit secrets)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
pnpm-debug.log*
|
||||
*.log
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
1809
backend/package-lock.json
generated
Normal file
1809
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/package.json
Normal file
22
backend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "tally-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server.js",
|
||||
"start": "node server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev --name init"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11",
|
||||
"prisma": "^6.19.0"
|
||||
}
|
||||
}
|
||||
10
backend/prisma/migrations/20251112222542_init/migration.sql
Normal file
10
backend/prisma/migrations/20251112222542_init/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Counter" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT,
|
||||
"value" INTEGER NOT NULL DEFAULT 0,
|
||||
"imageUrl" TEXT,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
18
backend/prisma/schema.prisma
Normal file
18
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,18 @@
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:../data/sqlite.db"
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model Counter {
|
||||
id Int @id @default(autoincrement())
|
||||
name String? // human-friendly name for the counter
|
||||
value Int @default(0)
|
||||
imageUrl String? // nullable URL for uploaded image
|
||||
position Int @default(0) // used for ordering
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
173
backend/server.js
Normal file
173
backend/server.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const express = require('express')
|
||||
const multer = require('multer')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const cors = require('cors')
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// small, configurable logger used across the app
|
||||
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase() // 'error'|'info'|'debug'
|
||||
const levels = { error: 0, info: 1, debug: 2 }
|
||||
const log = {
|
||||
error: (...args) => {
|
||||
if (levels[LOG_LEVEL] >= levels.error && process.env.NODE_ENV !== 'test') console.error(new Date().toISOString(), '[ERROR]', ...args)
|
||||
},
|
||||
info: (...args) => {
|
||||
if (levels[LOG_LEVEL] >= levels.info && process.env.NODE_ENV !== 'test') console.log(new Date().toISOString(), '[INFO]', ...args)
|
||||
},
|
||||
debug: (...args) => {
|
||||
if (levels[LOG_LEVEL] >= levels.debug && process.env.NODE_ENV !== 'test') console.log(new Date().toISOString(), '[DEBUG]', ...args)
|
||||
},
|
||||
}
|
||||
|
||||
// store all runtime data under backend/data
|
||||
const DATA_DIR = path.join(__dirname, 'data')
|
||||
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
|
||||
|
||||
// ensure data and upload dirs exist
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
const upload = multer({ dest: UPLOAD_DIR }) // local storage for uploads
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// minimal request logger: only log responses with status >= 400
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now()
|
||||
res.on('finish', () => {
|
||||
const elapsed = Date.now() - start
|
||||
if (res.statusCode >= 400) {
|
||||
log.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${elapsed}ms`)
|
||||
}
|
||||
})
|
||||
next()
|
||||
})
|
||||
|
||||
// serve uploaded files statically at /uploads/<filename>
|
||||
app.use('/uploads', express.static(UPLOAD_DIR))
|
||||
|
||||
// small helper to parse numeric id param and return early 400 if invalid
|
||||
const parseId = (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (!Number.isFinite(id) || Number.isNaN(id)) {
|
||||
res.status(400).json({ error: 'invalid id' })
|
||||
return null
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// file upload -> returns { url }
|
||||
app.post('/api/upload', upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
log.error('upload failed: no file in request')
|
||||
return res.status(400).json({ error: 'file required' })
|
||||
}
|
||||
// minimal debug log for successful upload (visible when LOG_LEVEL=debug)
|
||||
log.debug('upload saved', req.file.filename)
|
||||
const url = `/uploads/${req.file.filename}`
|
||||
res.json({ url })
|
||||
})
|
||||
|
||||
// COUNTERS CRUD
|
||||
app.get('/api/counters', async (req, res) => {
|
||||
const rows = await prisma.counter.findMany({
|
||||
orderBy: { position: 'asc' },
|
||||
})
|
||||
res.json(rows)
|
||||
})
|
||||
|
||||
app.post('/api/counters', async (req, res) => {
|
||||
let { name = null, value = 0, imageUrl = null, position = 0 } = req.body
|
||||
const finalName = typeof name === 'string' && name.trim() ? name.trim() : 'New Counter'
|
||||
try {
|
||||
const row = await prisma.counter.create({
|
||||
data: { name: finalName, value, imageUrl, position },
|
||||
})
|
||||
log.debug('counter created', row.name ?? null)
|
||||
res.status(201).json(row)
|
||||
} catch (err) {
|
||||
log.error('create counter failed', err && err.message)
|
||||
res.status(500).json({ error: 'create failed' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/counters/:id', async (req, res) => {
|
||||
const id = parseId(req, res)
|
||||
if (id === null) return
|
||||
const row = await prisma.counter.findUnique({ where: { id } })
|
||||
if (!row) return res.status(404).json({ error: 'not found' })
|
||||
res.json(row)
|
||||
})
|
||||
|
||||
app.patch('/api/counters/reorder', async (req, res) => {
|
||||
const updates = req.body
|
||||
if (!Array.isArray(updates)) return res.status(400).json({ error: 'expected array' })
|
||||
|
||||
try {
|
||||
const ops = updates.map((u) =>
|
||||
prisma.counter.update({
|
||||
where: { id: Number(u.id) },
|
||||
data: { position: Number(u.position) },
|
||||
})
|
||||
)
|
||||
await prisma.$transaction(ops)
|
||||
const rows = await prisma.counter.findMany({ orderBy: { position: 'asc' } })
|
||||
log.debug('reorder completed', updates.length)
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
log.error('reorder failed', err && err.message)
|
||||
res.status(500).json({ error: 'reorder failed' })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/api/counters/:id', async (req, res) => {
|
||||
const id = parseId(req, res)
|
||||
if (id === null) return
|
||||
const { value, name, imageUrl, position } = req.body
|
||||
try {
|
||||
const row = await prisma.counter.update({
|
||||
where: { id },
|
||||
data: { value, name, imageUrl, position },
|
||||
})
|
||||
log.debug('counter updated', row.id, row.name ?? null)
|
||||
res.json(row)
|
||||
} catch (err) {
|
||||
log.error('update failed', id, err && err.message)
|
||||
res.status(404).json({ error: 'not found or invalid update' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/counters/:id', async (req, res) => {
|
||||
const id = parseId(req, res)
|
||||
if (id === null) return
|
||||
try {
|
||||
const deleted = await prisma.counter.delete({ where: { id } })
|
||||
log.debug('counter deleted', deleted.id, deleted.name ?? null)
|
||||
res.status(204).end()
|
||||
} catch (err) {
|
||||
log.error('delete failed', id, err && err.message)
|
||||
res.status(500).json({ error: 'delete failed' })
|
||||
}
|
||||
})
|
||||
|
||||
// graceful shutdown: ensure Prisma disconnects
|
||||
process.on('SIGINT', async () => {
|
||||
log.info('shutting down')
|
||||
try { await prisma.$disconnect() } catch {}
|
||||
process.exit(0)
|
||||
})
|
||||
process.on('SIGTERM', async () => {
|
||||
log.info('shutting down')
|
||||
try { await prisma.$disconnect() } catch {}
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
app.listen(PORT, () => {
|
||||
log.info(`Server listening on port ${PORT}`)
|
||||
})
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tally counter</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3485
frontend/package-lock.json
generated
Normal file
3485
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
440
frontend/src/App.css
Normal file
440
frontend/src/App.css
Normal file
@@ -0,0 +1,440 @@
|
||||
/* Clean, readable App stylesheet — uses variables from ./styles/theme.css (fallbacks provided) */
|
||||
|
||||
/* ---------------------------
|
||||
Tokens / defaults
|
||||
--------------------------- */
|
||||
:root {
|
||||
/* Catppuccin — Mocha (dark) inspired palette */
|
||||
--cp-rosewater: #f5e0dc;
|
||||
--cp-flamingo: #f2cdcd;
|
||||
--cp-pink: #f5c2e7;
|
||||
--cp-mauve: #cba6f7;
|
||||
--cp-red: #f38ba8;
|
||||
--cp-maroon: #eba0ac;
|
||||
--cp-peach: #fab387;
|
||||
--cp-yellow: #f9e2af;
|
||||
--cp-green: #a6e3a1;
|
||||
--cp-teal: #94e2d5;
|
||||
--cp-sky: #89dceb;
|
||||
--cp-sapphire: #74c7ec;
|
||||
--cp-blue: #89b4fa;
|
||||
--cp-lavender: #b4befe;
|
||||
|
||||
/* UI tokens mapped to Catppuccin */
|
||||
--bg: #1e1e2e;
|
||||
--card: #313244;
|
||||
--text: #cdd6f4;
|
||||
--subtext: #bac2de;
|
||||
--muted: #94a1b2;
|
||||
--accent: var(--cp-blue);
|
||||
--accent-2: var(--cp-mauve);
|
||||
--danger: var(--cp-red);
|
||||
--confirm: var(--cp-green);
|
||||
--danger-rgb: 220,38,38;
|
||||
--border: #45475a;
|
||||
--radius: 10px;
|
||||
|
||||
/* helper for rgba() with accent */
|
||||
--accent-rgb: 137, 180, 250;
|
||||
/* rgb({--cp-blue}) approximation */
|
||||
|
||||
/* motion / elevations */
|
||||
--transition-fast: 150ms;
|
||||
--elevation-1: 0 6px 20px rgba(2, 6, 23, 0.6);
|
||||
|
||||
--max-width: 1500px;
|
||||
|
||||
/* counter sizing token (adjustable) */
|
||||
--counter-height: 300px;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Global / base
|
||||
--------------------------- */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: var(--max-width);
|
||||
margin: 28px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Header / title / actions
|
||||
--------------------------- */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 0;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--subtext);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Prominent create button */
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 140px;
|
||||
padding: 12px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
border-radius: 10px;
|
||||
background: var(--cp-teal);
|
||||
color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(2,6,23,0.08);
|
||||
transition: transform var(--transition-fast), filter var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.create-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.06);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(2,6,23,0.10);
|
||||
}
|
||||
.create-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* ---------------------------
|
||||
Generic card marker (no visuals)
|
||||
Components provide visuals; .card remains as semantic marker
|
||||
--------------------------- */
|
||||
.card {
|
||||
border-radius: var(--radius);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Modal
|
||||
--------------------------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2,6,23,0.5);
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: var(--card);
|
||||
color: inherit;
|
||||
padding: 20px;
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
box-shadow: var(--elevation-1);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.modal-title { margin: 0; font-size: 18px; font-weight: 700; }
|
||||
|
||||
.modal-body { display: grid; gap: 10px; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.modal-actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Forms / inputs / buttons
|
||||
--------------------------- */
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center; /* centers pretty file input in modals */
|
||||
}
|
||||
|
||||
.label-text {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--subtext);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.text-input:focus {
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.06);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Primary / secondary button styles (reusable) */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--card);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-confirm {
|
||||
background: var(--confirm);
|
||||
color: var(--card);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--card);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Counter card (component-owned visuals)
|
||||
--------------------------- */
|
||||
.counter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
box-shadow: var(--elevation-1);
|
||||
width: 100%;
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
height: var(--counter-height);
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Title, image, value, controls order */
|
||||
.counter__label {
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.counter__image {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.counter__image img {
|
||||
border-radius: 8px;
|
||||
max-width: 180px;
|
||||
max-height: calc(var(--counter-height) * 0.35);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.counter__value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
min-width: 72px;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.counter__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.counter__button {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
width: 56px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.counter__button:hover:not(:disabled) {
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.counter__button:active:not(:disabled){ transform: translateY(0); }
|
||||
.counter__button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
.counter__edit-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.counter__edit-button:hover:not(:disabled) {
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Pretty file input (Create/Edit)
|
||||
--------------------------- */
|
||||
.file-input-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--subtext);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.file-input-label input[type="file"] { display: none; }
|
||||
|
||||
.file-input-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
box-shadow: none;
|
||||
flex: 0 0 56px;
|
||||
}
|
||||
.file-input-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--subtext);
|
||||
flex: 0 0 20px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.file-input-text {
|
||||
font-size: 13px;
|
||||
color: var(--subtext);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-input-label:hover,
|
||||
.file-input-label:focus-within {
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
color: var(--text);
|
||||
background: rgba(var(--accent-rgb), 0.03);
|
||||
}
|
||||
|
||||
|
||||
/* drag visuals for counter cards */
|
||||
.counter-card.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.98);
|
||||
transition: transform 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
.counter-card.drag-over {
|
||||
outline: 3px dashed rgba(var(--accent-rgb), 0.9);
|
||||
outline-offset: 8px;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Utilities / footer
|
||||
--------------------------- */
|
||||
.app button { font-family: inherit; }
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--subtext);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------------------------
|
||||
Responsive tweaks
|
||||
--------------------------- */
|
||||
@media (max-width: 520px) {
|
||||
.app { padding: 14px; margin: 18px auto; }
|
||||
.modal { padding: 14px; border-radius: 10px; }
|
||||
.create-btn { padding: 8px 10px; }
|
||||
:root { --counter-height: 300px; }
|
||||
.counter { padding: 14px; height: var(--counter-height); }
|
||||
.file-input-thumb { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 8px; }
|
||||
}
|
||||
300
frontend/src/App.tsx
Normal file
300
frontend/src/App.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import Counter from './components/Counter'
|
||||
import CreateCounter from './components/CreateCounter'
|
||||
import { apiUrl } from './utils/api'
|
||||
import './App.css'
|
||||
|
||||
type CounterRecord = { id: number; value: number; name?: string; imageUrl?: string; position?: number }
|
||||
|
||||
export default function App() {
|
||||
const [counters, setCounters] = useState<CounterRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingIds, setSavingIds] = useState<Set<number>>(new Set())
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(apiUrl('/api/counters'))
|
||||
const list: CounterRecord[] = await res.json()
|
||||
if (!mounted) return
|
||||
setCounters(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
console.error('failed to load counters', err)
|
||||
if (mounted) setCounters([])
|
||||
} finally {
|
||||
if (mounted) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// swap two items in an array (keeps array length and only swaps the two indexes)
|
||||
const swapInArray = (arr: CounterRecord[], i: number, j: number) => {
|
||||
const copy = arr.slice()
|
||||
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
||||
return copy
|
||||
}
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
}, [])
|
||||
|
||||
const sendReorderToServer = useCallback(async (ordered: CounterRecord[]) => {
|
||||
const body = ordered.map((c) => ({ id: c.id, position: c.position ?? 0 }))
|
||||
const res = await fetch(apiUrl('/api/counters/reorder'), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(`reorder failed (${res.status})`)
|
||||
const updated = await res.json().catch(() => null)
|
||||
return Array.isArray(updated) ? updated : null
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, id: number) => {
|
||||
setDraggingId(id)
|
||||
try {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(id))
|
||||
const img = document.createElement('canvas')
|
||||
img.width = 1
|
||||
img.height = 1
|
||||
e.dataTransfer.setDragImage(img, 0, 0)
|
||||
} catch {
|
||||
/* ignore potential browser exceptions */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, id: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverId(id)
|
||||
}, [])
|
||||
|
||||
const handleDropOnCard = useCallback(
|
||||
async (e: React.DragEvent, targetId: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const raw = e.dataTransfer.getData('text/plain')
|
||||
const fromId = Number(raw || draggingId)
|
||||
|
||||
if (!fromId || fromId === targetId) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const fromIndex = counters.findIndex((c) => c.id === fromId)
|
||||
const toIndex = counters.findIndex((c) => c.id === targetId)
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const swapped = swapInArray(counters, fromIndex, toIndex).map((c, i) => ({ ...c, position: i }))
|
||||
setCounters(swapped)
|
||||
clearDragState()
|
||||
|
||||
try {
|
||||
const updated = await sendReorderToServer(swapped)
|
||||
if (Array.isArray(updated)) setCounters(updated)
|
||||
} catch (err) {
|
||||
console.error('reorder failed', err)
|
||||
try {
|
||||
const res = await fetch(apiUrl('/api/counters'))
|
||||
const list: CounterRecord[] = await res.json()
|
||||
setCounters(Array.isArray(list) ? list : [])
|
||||
} catch (e) {
|
||||
console.error('failed to reload counters', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
[counters, draggingId, clearDragState, sendReorderToServer]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
clearDragState()
|
||||
}, [clearDragState])
|
||||
|
||||
const handleChange = async (id: number, next: number) => {
|
||||
// enforce non-negative values
|
||||
const safeNext = Math.max(0, next)
|
||||
|
||||
const idx = counters.findIndex((c) => c.id === id)
|
||||
if (idx === -1) return
|
||||
|
||||
const prev = counters[idx].value
|
||||
// optimistic update (use safeNext)
|
||||
setCounters((prevList) => prevList.map((c) => (c.id === id ? { ...c, value: safeNext } : c)))
|
||||
setSavingIds((s) => {
|
||||
const n = new Set(s)
|
||||
n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/counters/${id}`), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: safeNext }),
|
||||
})
|
||||
if (!res.ok) throw new Error('patch failed')
|
||||
const updated: CounterRecord = await res.json().catch(() => null)
|
||||
if (updated) {
|
||||
setCounters((prevList) => prevList.map((c) => (c.id === updated.id ? updated : c)))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('save failed', err)
|
||||
// revert
|
||||
setCounters((prevList) => prevList.map((c) => (c.id === id ? { ...c, value: prev } : c)))
|
||||
} finally {
|
||||
setSavingIds((s) => {
|
||||
const n = new Set(s)
|
||||
n.delete(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreated = (c: CounterRecord) => {
|
||||
setCounters((prev) => [...prev, c])
|
||||
}
|
||||
|
||||
// onUpdate: upload file (if present), then PATCH name/imageUrl for given id
|
||||
const handleUpdate = async (id: number, payload: { name?: string; file?: File | null }) => {
|
||||
setSavingIds((s) => {
|
||||
const n = new Set(s)
|
||||
n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
try {
|
||||
let imageUrl: string | null | undefined = undefined
|
||||
|
||||
if (payload.file) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', payload.file)
|
||||
const up = await fetch(apiUrl('/api/upload'), { method: 'POST', body: fd })
|
||||
if (!up.ok) throw new Error('upload failed')
|
||||
const body = await up.json().catch(() => null)
|
||||
imageUrl = body?.url ?? null
|
||||
}
|
||||
|
||||
const patchBody: any = {}
|
||||
if (payload.name !== undefined) patchBody.name = payload.name
|
||||
if (payload.file) patchBody.imageUrl = imageUrl
|
||||
|
||||
if (Object.keys(patchBody).length === 0) {
|
||||
// nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl(`/api/counters/${id}`), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
if (!res.ok) throw new Error(`update failed (${res.status})`)
|
||||
const updated: CounterRecord = await res.json().catch(() => null)
|
||||
if (updated) {
|
||||
setCounters((prevList) => prevList.map((c) => (c.id === updated.id ? updated : c)))
|
||||
} else {
|
||||
// best-effort local apply if server didn't return the updated object
|
||||
setCounters((prevList) =>
|
||||
prevList.map((c) => (c.id === id ? { ...c, name: payload.name ?? c.name, imageUrl: imageUrl ?? c.imageUrl } : c))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('update failed', err)
|
||||
throw err
|
||||
} finally {
|
||||
setSavingIds((s) => {
|
||||
const n = new Set(s)
|
||||
n.delete(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// added delete handler
|
||||
const handleDelete = async (id: number) => {
|
||||
setSavingIds((s) => {
|
||||
const n = new Set(s)
|
||||
n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/counters/${id}`), { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`delete failed (${res.status})`)
|
||||
// remove locally
|
||||
setCounters((prev) => prev.filter((c) => c.id !== id))
|
||||
} catch (err) {
|
||||
console.error('delete failed', err)
|
||||
throw err
|
||||
} finally {
|
||||
setSavingIds((s) => {
|
||||
const n = new Set(s)
|
||||
n.delete(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">Loading counters…</div>
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1 className="title">Tally counter</h1>
|
||||
</div>
|
||||
|
||||
<div className="actions" aria-label="Counters">
|
||||
<CreateCounter onCreated={handleCreated} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
{counters.length === 0 ? (
|
||||
<div>
|
||||
<h3>No counters yet</h3>
|
||||
<p>Create your first counter to begin tracking.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="counters-grid" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||
{counters.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`counter-card card ${draggingId === c.id ? 'dragging' : ''} ${dragOverId === c.id ? 'drag-over' : ''}`}
|
||||
draggable={true}
|
||||
onDragStart={(e) => handleDragStart(e, c.id)}
|
||||
onDragEnter={(e) => handleDragOver(e, c.id)}
|
||||
onDragOver={(e) => handleDragOver(e, c.id)}
|
||||
onDrop={(e) => handleDropOnCard(e, c.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Counter
|
||||
id={String(c.id)}
|
||||
label={c.name ?? 'Counter'}
|
||||
value={c.value}
|
||||
min={0}
|
||||
isSaving={savingIds.has(c.id)}
|
||||
imageUrl={c.imageUrl}
|
||||
onChange={(next) => handleChange(c.id, next)}
|
||||
onUpdate={(payload) => handleUpdate(c.id, payload)}
|
||||
onDelete={() => handleDelete(c.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
289
frontend/src/components/Counter.tsx
Normal file
289
frontend/src/components/Counter.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface CounterProps {
|
||||
id?: string
|
||||
label?: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
className?: string
|
||||
|
||||
imageUrl?: string
|
||||
isSaving?: boolean
|
||||
|
||||
onUpdate?: (payload: { name?: string; file?: File | null }) => Promise<void> | void
|
||||
onDelete?: () => Promise<void> | void // added delete prop
|
||||
}
|
||||
|
||||
const clamp = (v: number, min?: number, max?: number) => {
|
||||
if (typeof min === 'number' && v < min) return min
|
||||
if (typeof max === 'number' && v > max) return max
|
||||
return v
|
||||
}
|
||||
|
||||
const Counter: React.FC<CounterProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
step = 1,
|
||||
min,
|
||||
max,
|
||||
className,
|
||||
imageUrl,
|
||||
isSaving = false,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const setValue = useCallback(
|
||||
(next: number) => {
|
||||
const clamped = clamp(next, min, max)
|
||||
if (clamped !== value) onChange(clamped)
|
||||
},
|
||||
[min, max, value, onChange]
|
||||
)
|
||||
|
||||
const handleIncrement = useCallback(() => setValue(value + step), [setValue, value, step])
|
||||
const handleDecrement = useCallback(() => setValue(value - step), [setValue, value, step])
|
||||
|
||||
const canDecrement = useMemo(() => !(typeof min === 'number' && value <= min), [min, value])
|
||||
const canIncrement = useMemo(() => !(typeof max === 'number' && value >= max), [max, value])
|
||||
const disabledDecrement = isSaving || !canDecrement
|
||||
const disabledIncrement = isSaving || !canIncrement
|
||||
|
||||
// Modal edit state (replaces inline edit)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(label ?? '')
|
||||
const [editFile, setEditFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [savingLocal, setSavingLocal] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const nameInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEditName(label ?? '')
|
||||
}, [label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editFile) {
|
||||
setPreviewUrl(null)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(editFile)
|
||||
setPreviewUrl(url)
|
||||
return () => {
|
||||
URL.revokeObjectURL(url)
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}, [editFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) nameInputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
// handle Escape and click-outside
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [editing])
|
||||
|
||||
const openEdit = useCallback(() => {
|
||||
setEditName(label ?? '')
|
||||
setEditFile(null)
|
||||
setEditing(true)
|
||||
}, [label])
|
||||
|
||||
const closeEdit = useCallback(() => {
|
||||
setEditing(false)
|
||||
setEditFile(null)
|
||||
setPreviewUrl(null)
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!onUpdate) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setSavingLocal(true)
|
||||
await onUpdate({ name: editName || undefined, file: editFile })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
console.error('update failed', err)
|
||||
} finally {
|
||||
setSavingLocal(false)
|
||||
}
|
||||
}, [onUpdate, editName, editFile])
|
||||
|
||||
const handleDeleteClick = useCallback(async () => {
|
||||
if (!onDelete) return
|
||||
try {
|
||||
setSavingLocal(true)
|
||||
await onDelete()
|
||||
// close modal locally (parent will remove the card)
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
console.error('delete failed', err)
|
||||
} finally {
|
||||
setSavingLocal(false)
|
||||
}
|
||||
}, [onDelete])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className ?? 'counter'}
|
||||
role="group"
|
||||
aria-labelledby={id ? `${id}-label` : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* name / title on top */}
|
||||
{label && (
|
||||
<div id={id ? `${id}-label` : undefined} className="counter__label">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* image below name */}
|
||||
{previewUrl ? (
|
||||
<div className="counter__image">
|
||||
<img src={previewUrl} alt={editName || label || 'preview'} />
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<div className="counter__image">
|
||||
<img src={imageUrl} alt={label ?? 'counter image'} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* value below image */}
|
||||
<div className="counter__value" aria-live="polite" style={{ marginTop: 8 }}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{/* controls row: decrement - edit - increment */}
|
||||
<div className="counter__controls" style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="decrement"
|
||||
onClick={handleDecrement}
|
||||
disabled={disabledDecrement}
|
||||
className="counter__button"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="edit"
|
||||
onClick={openEdit}
|
||||
className="counter__button"
|
||||
disabled={savingLocal}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="increment"
|
||||
onClick={handleIncrement}
|
||||
disabled={disabledIncrement}
|
||||
className="counter__button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editing && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit counter"
|
||||
onClick={closeEdit}
|
||||
>
|
||||
<form
|
||||
className="modal"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-busy={savingLocal}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Edit counter</h3>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<label className="form-field">
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="text-input"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Counter name"
|
||||
aria-label="Edit counter name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="form-field">
|
||||
<div>
|
||||
<label className="file-input-label">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setEditFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
{previewUrl || imageUrl ? (
|
||||
<img
|
||||
className="file-input-thumb"
|
||||
src={previewUrl ?? imageUrl}
|
||||
alt={editName || label || 'thumbnail'}
|
||||
/>
|
||||
) : (
|
||||
<svg className="file-input-icon" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 7h3l2-2h6l2 2h3v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="12" cy="13" r="3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<span className="file-input-text">{editFile || imageUrl ? 'Change image…' : 'Select image…'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={savingLocal}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions-right">
|
||||
<button type="submit" className="btn-confirm" disabled={savingLocal}>
|
||||
{savingLocal ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Counter
|
||||
168
frontend/src/components/CreateCounter.tsx
Normal file
168
frontend/src/components/CreateCounter.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { apiUrl } from '../utils/api'
|
||||
|
||||
type CounterRecord = { id: number; value: number; name?: string; imageUrl?: string }
|
||||
|
||||
interface Props {
|
||||
initialName?: string
|
||||
onCreated?: (c: CounterRecord) => void
|
||||
}
|
||||
|
||||
export default function CreateCounter({ initialName = '', onCreated }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState(initialName)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
// keep name synced with initialName when modal opens
|
||||
useEffect(() => {
|
||||
if (open) setName(initialName)
|
||||
}, [open, initialName])
|
||||
|
||||
// lock background scroll while modal is open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// create object URL for selected file and clean up when file changes/unmount
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewUrl(null)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
setPreviewUrl(url)
|
||||
return () => {
|
||||
URL.revokeObjectURL(url)
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}, [file])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setError(null)
|
||||
setFile(null)
|
||||
setPreviewUrl(null)
|
||||
setName(initialName)
|
||||
}, [initialName])
|
||||
|
||||
const handleFileChange = useCallback((f: File | null) => {
|
||||
setFile(f)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
let imageUrl: string | null = null
|
||||
if (file) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const up = await fetch(apiUrl('/api/upload'), { method: 'POST', body: fd })
|
||||
if (!up.ok) throw new Error('upload failed')
|
||||
const body = await up.json().catch(() => ({}))
|
||||
imageUrl = body?.url ?? null
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl('/api/counters'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name || null, value: 0, imageUrl, position: 0 }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`create failed (${res.status})`)
|
||||
const created: CounterRecord = await res.json().catch(() => null)
|
||||
if (created) onCreated?.(created)
|
||||
close()
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err?.message ?? 'Create failed')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
},
|
||||
[file, name, onCreated, close]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="create-btn" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}>
|
||||
Create counter
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Create counter"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') close()
|
||||
}}
|
||||
>
|
||||
<form className="modal" onSubmit={handleSubmit} aria-busy={creating}>
|
||||
<h3 className="modal-title">Create new counter</h3>
|
||||
|
||||
<label className="form-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-input"
|
||||
aria-label="Counter name"
|
||||
placeholder="Counter name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="file-input-label">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFileChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<img className="file-input-thumb" src={previewUrl} alt={name ?? 'preview'} />
|
||||
) : (
|
||||
<svg className="file-input-icon" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 7h3l2-2h6l2 2h3v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="12" cy="13" r="3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="file-input-text">{previewUrl ? 'Change image…' : 'Select image…'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={close} disabled={creating}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
frontend/src/index.css
Normal file
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
5
frontend/src/utils/api.ts
Normal file
5
frontend/src/utils/api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function apiUrl(path: string) {
|
||||
// always return a relative path so production uses same origin
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
export default apiUrl
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// use a fixed dev proxy target; frontend will use relative paths in production
|
||||
export default defineConfig(() => {
|
||||
const backend = 'http://localhost:3000'
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backend,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/uploads': {
|
||||
target: backend,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user