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