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