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:
2025-11-13 00:30:45 +01:00
commit 338d7bc8dc
24 changed files with 7062 additions and 0 deletions

36
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

22
backend/package.json Normal file
View 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"
}
}

View 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
);

View 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"

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View 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
View 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
View 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
View 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>
)
}

View 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

View 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
View 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
View 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>
)

View 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

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
}
})