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:
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}`)
|
||||
})
|
||||
Reference in New Issue
Block a user