Files
tally-counter/app/components/CounterModal.tsx
T
2026-06-06 17:14:53 +02:00

240 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
import { Counter } from './CounterCard';
export interface Group {
id: number;
name: string;
order_index: number;
}
interface Props {
open: boolean;
initial?: Counter | null;
groups: Group[];
onClose: () => void;
onSave: (data: {
name: string;
value: number;
group_id: number | null;
image_path: string | null;
}) => void;
onDelete?: (id: number) => void;
}
export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false);
const deleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [name, setName] = useState('');
const [value, setValue] = useState(0);
const [groupId, setGroupId] = useState<number | null>(null);
const [imagePath, setImagePath] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const nameRef = useRef<HTMLInputElement>(null);
// Revoke blob URLs when previewUrl changes (prevents memory leaks)
useEffect(() => {
const url = previewUrl;
return () => { if (url?.startsWith('blob:')) URL.revokeObjectURL(url); };
}, [previewUrl]);
useEffect(() => {
if (open) {
setName(initial?.name ?? '');
setValue(initial?.value ?? 0);
setGroupId(initial?.group_id ?? null);
setImagePath(initial?.image_path ?? null);
setPreviewUrl(initial?.image_path ? `/api/uploads/${initial.image_path}` : null);
setError('');
setConfirmDelete(false);
if (deleteTimer.current) clearTimeout(deleteTimer.current);
setTimeout(() => nameRef.current?.focus(), 50);
}
}, [open, initial]);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPreviewUrl(URL.createObjectURL(file));
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd });
if (!res.ok) {
const body = await res.json();
setError(body.error ?? 'Upload failed');
return;
}
const { filename } = await res.json();
setImagePath(filename);
} catch {
setError('Upload failed');
} finally {
setUploading(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) { setError('Name is required'); return; }
if (uploading) { setError('Please wait for the image to finish uploading'); return; }
onSave({ name: name.trim(), value, group_id: groupId, image_path: imagePath });
}
function handleBackdrop(e: React.MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={handleBackdrop}
>
<div className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-ctp-surface1">
<h2 className="text-lg font-semibold text-ctp-text">
{initial ? 'Edit Counter' : 'New Counter'}
</h2>
<button onClick={onClose} className="text-ctp-overlay0 hover:text-ctp-text text-xl leading-none">×</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
{error && (
<p className="text-sm text-ctp-red bg-ctp-red/10 rounded-lg px-3 py-2">{error}</p>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Name</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
placeholder="Counter name"
/>
</div>
{/* Initial value */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">
{initial ? 'Value' : 'Initial Value'}
</label>
<input
type="number"
value={value}
onChange={e => setValue(Number(e.target.value))}
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
/>
</div>
{/* Group */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Group</label>
<select
value={groupId ?? ''}
onChange={e => setGroupId(e.target.value ? Number(e.target.value) : null)}
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
>
<option value=""> No group </option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
{/* Image upload */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Photo</label>
{previewUrl && (
<div className="relative w-full mb-2 rounded-lg overflow-hidden bg-ctp-crust flex items-center justify-center" style={{ minHeight: '8rem', maxHeight: '12rem' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewUrl} alt="Preview" className="max-w-full max-h-48 object-contain py-2" />
<button
type="button"
onClick={() => { setPreviewUrl(null); setImagePath(null); }}
className="absolute top-1 right-1 bg-ctp-crust/80 text-ctp-text rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-ctp-surface0"
>
×
</button>
</div>
)}
<label className={`flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-dashed text-sm font-medium transition-colors ${
uploading
? 'border-ctp-surface1 text-ctp-overlay0 cursor-wait'
: 'border-ctp-surface2 text-ctp-subtext1 hover:border-ctp-mauve hover:text-ctp-mauve hover:bg-ctp-mauve/5'
}`}>
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" disabled={uploading} />
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{uploading ? 'Uploading…' : previewUrl ? 'Replace photo' : 'Upload photo'}
</label>
</div>
<div className="flex items-center justify-between gap-3 pt-2">
{/* Delete — only shown when editing an existing counter */}
{initial && onDelete ? (
<button
type="button"
onClick={() => {
if (confirmDelete) {
if (deleteTimer.current) clearTimeout(deleteTimer.current);
onDelete(initial.id);
onClose();
} else {
setConfirmDelete(true);
deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
}
}}
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
confirmDelete
? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
: 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
}`}
>
{confirmDelete ? 'Confirm delete' : 'Delete counter'}
</button>
) : <span />}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender disabled:opacity-50 text-ctp-base font-medium transition-colors"
>
{initial ? 'Save Changes' : 'Create Counter'}
</button>
</div>
</div>
</form>
</div>
</div>
);
}