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