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:
203
src/components/CreateModal.jsx
Normal file
203
src/components/CreateModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
export async function apiFetchOne(path) {
|
||||||
return apiFetch(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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import RecordModal from '../components/RecordModal';
|
import RecordModal from '../components/RecordModal';
|
||||||
|
import CreateModal, { hasCreateForm } from '../components/CreateModal';
|
||||||
import { fetchAll } from '../lib/api';
|
import { fetchAll } from '../lib/api';
|
||||||
import { TABLES, STATUS_COLORS } from '../lib/tables';
|
import { TABLES, STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ export default function TableView() {
|
|||||||
const [textFilter, setTextFilter] = useState('');
|
const [textFilter, setTextFilter] = useState('');
|
||||||
const [highlightId, setHighlightId] = useState(null);
|
const [highlightId, setHighlightId] = useState(null);
|
||||||
const [modalRecord, setModalRecord] = useState(null);
|
const [modalRecord, setModalRecord] = useState(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
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)));
|
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>;
|
if (!meta) return <Layout back="/db"><p className="text-red-500">Unbekannte Tabelle: {tableKey}</p></Layout>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,6 +141,14 @@ export default function TableView() {
|
|||||||
({filtered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
|
({filtered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -226,6 +240,15 @@ export default function TableView() {
|
|||||||
onSaved={handleRecordSaved}
|
onSaved={handleRecordSaved}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateModal
|
||||||
|
tableKey={tableKey}
|
||||||
|
meta={meta}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={handleRecordCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user