Add create modal for words and pictures tables

- + Button in TableView for tables with a create form (words, pictures)
- Words: form with titel_de/en/sv + difficulty_level → POST /words
- Pictures: design field + image uploader → POST /pictures then POST /pictures/:id/upload
- Image drag-drop area with preview before upload, sends multipart to Hetzner via API
- New record prepended to table on success
- apiPost + apiUpload helpers added to api.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:17:55 +02:00
parent e6c86a97fc
commit a8ff541117
3 changed files with 249 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
import { useState, useRef } from 'react';
import { apiPost, apiUpload } from '../lib/api';
// Per-table create form definitions
const CREATE_FORMS = {
words: {
title: 'Neues Wort',
fields: [
{ key: 'titel_de', label: 'Titel DE', type: 'text', required: true, placeholder: 'z.B. Apfel' },
{ key: 'titel_en', label: 'Titel EN', type: 'text', required: false, placeholder: 'z.B. Apple' },
{ key: 'titel_sv', label: 'Titel SV', type: 'text', required: false, placeholder: 'z.B. Äpple' },
{ key: 'difficulty_level', label: 'Schwierigkeitsgrad', type: 'number', required: false, min: 1, max: 10 },
],
},
pictures: {
title: 'Neues Bild',
fields: [
{ key: 'design', label: 'Design', type: 'text', required: true, placeholder: 'z.B. red apple on white background' },
],
hasImageUpload: true,
},
};
export function hasCreateForm(tableKey) {
return !!CREATE_FORMS[tableKey];
}
function FieldInput({ field, value, onChange }) {
if (field.type === 'number') {
return (
<input
type="number"
value={value ?? ''}
min={field.min}
max={field.max}
required={field.required}
onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder={field.placeholder}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
);
}
return (
<input
type="text"
value={value ?? ''}
required={field.required}
onChange={e => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
);
}
export default function CreateModal({ tableKey, meta, onClose, onCreated }) {
const formDef = CREATE_FORMS[tableKey];
const [values, setValues] = useState({});
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const fileRef = useRef();
if (!formDef) return null;
function handleFileChange(e) {
const f = e.target.files[0];
if (!f) return;
setFile(f);
const url = URL.createObjectURL(f);
setPreview(url);
}
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSaving(true);
try {
// Build payload — skip nulls/empty strings for optional fields
const body = {};
formDef.fields.forEach(f => {
if (values[f.key] != null && values[f.key] !== '') {
body[f.key] = values[f.key];
}
});
// Step 1: Create record
const created = await apiPost(meta.endpoint, body);
// Step 2: Upload image if provided
let final = created;
if (formDef.hasImageUpload && file) {
const fd = new FormData();
fd.append('file', file);
final = await apiUpload(`${meta.endpoint}/${created.id}/upload`, fd);
}
onCreated(final);
onClose();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 backdrop-blur-sm p-4 overflow-y-auto">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md my-8">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div>
<span className="text-xl mr-2">{meta.icon}</span>
<span className="font-semibold text-slate-800">{formDef.title}</span>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 text-2xl leading-none"
>
×
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="px-6 py-5 space-y-4">
{formDef.fields.map(field => (
<div key={field.key}>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<FieldInput
field={field}
value={values[field.key]}
onChange={val => setValues(prev => ({ ...prev, [field.key]: val }))}
/>
</div>
))}
{formDef.hasImageUpload && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Bild hochladen
</label>
<div
onClick={() => fileRef.current?.click()}
className="border-2 border-dashed border-slate-300 rounded-xl p-4 text-center cursor-pointer hover:border-indigo-400 hover:bg-indigo-50 transition-colors"
>
{preview ? (
<div className="space-y-2">
<img
src={preview}
alt="Vorschau"
className="mx-auto max-h-32 rounded-lg object-contain"
/>
<p className="text-xs text-slate-500">{file.name}</p>
</div>
) : (
<div className="space-y-1">
<div className="text-3xl">🖼</div>
<p className="text-sm text-slate-500">Klicken zum Auswählen</p>
<p className="text-xs text-slate-400">JPG, PNG, WebP · max 20 MB</p>
</div>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</div>
)}
{error && (
<div className="bg-red-50 text-red-700 text-sm rounded-lg px-3 py-2">{error}</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-300 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white font-medium rounded-lg transition-colors"
>
{saving ? 'Anlegen…' : 'Anlegen'}
</button>
</div>
</form>
</div>
</div>
);
}