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

View File

@@ -62,6 +62,29 @@ export async function apiPatch(endpoint, id, body) {
});
}
export async function apiPost(endpoint, body) {
return apiFetch(endpoint, {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function apiFetchOne(path) {
return apiFetch(path);
}
// Multipart upload — does NOT set Content-Type (browser sets boundary automatically)
export async function apiUpload(path, formData) {
const token = getToken();
const res = await fetch(`${API_URL}${path}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (res.status === 401) { logout(); window.location.href = '/login'; return; }
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import RecordModal from '../components/RecordModal';
import CreateModal, { hasCreateForm } from '../components/CreateModal';
import { fetchAll } from '../lib/api';
import { TABLES, STATUS_COLORS } from '../lib/tables';
@@ -86,6 +87,7 @@ export default function TableView() {
const [textFilter, setTextFilter] = useState('');
const [highlightId, setHighlightId] = useState(null);
const [modalRecord, setModalRecord] = useState(null);
const [showCreate, setShowCreate] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -124,6 +126,10 @@ export default function TableView() {
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
}
function handleRecordCreated(newRecord) {
setRows(prev => [newRecord, ...prev]);
}
if (!meta) return <Layout back="/db"><p className="text-red-500">Unbekannte Tabelle: {tableKey}</p></Layout>;
return (
@@ -135,6 +141,14 @@ export default function TableView() {
({filtered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
</span>
</h2>
{hasCreateForm(tableKey) && (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
>
<span className="text-lg leading-none">+</span> Neu
</button>
)}
</div>
{/* Filters */}
@@ -226,6 +240,15 @@ export default function TableView() {
onSaved={handleRecordSaved}
/>
)}
{showCreate && (
<CreateModal
tableKey={tableKey}
meta={meta}
onClose={() => setShowCreate(false)}
onCreated={handleRecordCreated}
/>
)}
</Layout>
);
}