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

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>
)}
</>
)
}