Web app ready

This commit is contained in:
2026-06-06 17:08:04 +02:00
parent ff187a5bd4
commit 3e127afbae
39 changed files with 2622 additions and 123 deletions
+239
View File
@@ -0,0 +1,239 @@
'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>
);
}