Add relation manager: link/unlink records directly in modal

- RelationManager component: shows linked items as removable tags,
  live search to find and add new links (×-button to unlink)
- tables.js: full fetchRelated config with linkEndpoint + searchEndpoint
  for words↔pictures, words↔categories, objects↔words, objects↔pictures
- api.js: add apiLink, apiUnlink, apiDelete helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:43:48 +02:00
parent a8ff541117
commit a02705734d
3 changed files with 202 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { apiPatch, apiFetchOne } from '../lib/api'; import { apiPatch, apiFetchOne, apiFetch, apiLink, apiUnlink } from '../lib/api';
import { STATUS_COLORS } from '../lib/tables'; import { STATUS_COLORS } from '../lib/tables';
const READ_ONLY_FIELDS = new Set(['id', 'created_at', 'updated_at']); const READ_ONLY_FIELDS = new Set(['id', 'created_at', 'updated_at']);
@@ -104,12 +104,143 @@ function EditableField({ fieldKey, value, fieldDef, onChange }) {
); );
} }
function RelationManager({ recordId, rel }) {
const [items, setItems] = useState(undefined); // undefined = loading
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState(null);
const [searching, setSearching] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [error, setError] = useState('');
// Load linked items
useEffect(() => {
setItems(undefined);
apiFetchOne(rel.endpoint(recordId))
.then(data => setItems(Array.isArray(data) ? data : []))
.catch(() => setItems(null));
}, [recordId, rel]);
// Search available items
useEffect(() => {
if (!showSearch) return;
if (!search.trim()) { setSearchResults([]); return; }
const timer = setTimeout(() => {
setSearching(true);
apiFetch(`${rel.searchEndpoint}?limit=20&offset=0`)
.then(data => {
const arr = Array.isArray(data) ? data : [];
const q = search.toLowerCase();
const linked = new Set((items || []).map(i => i.id));
setSearchResults(
arr.filter(r => rel.searchLabel(r).toLowerCase().includes(q) && !linked.has(r.id))
);
})
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 250);
return () => clearTimeout(timer);
}, [search, showSearch, items, rel]);
async function handleLink(targetId) {
try {
await apiLink(rel.linkEndpoint(recordId, targetId));
// refresh
const data = await apiFetchOne(rel.endpoint(recordId));
setItems(Array.isArray(data) ? data : []);
setSearch('');
setSearchResults([]);
setShowSearch(false);
} catch (e) { setError(e.message); }
}
async function handleUnlink(targetId) {
try {
await apiUnlink(rel.linkEndpoint(recordId, targetId));
setItems(prev => (prev || []).filter(i => i.id !== targetId));
} catch (e) { setError(e.message); }
}
return (
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">{rel.label}</h3>
<button
onClick={() => { setShowSearch(s => !s); setSearch(''); setSearchResults([]); }}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1"
>
{showSearch ? '✕ Schließen' : '+ Verknüpfen'}
</button>
</div>
{/* Search box */}
{showSearch && (
<div className="mb-2">
<input
autoFocus
type="text"
placeholder={`${rel.label} suchen…`}
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{searching && <p className="text-xs text-slate-400 mt-1">Suche</p>}
{searchResults && searchResults.length === 0 && search && !searching && (
<p className="text-xs text-slate-400 mt-1">Keine Ergebnisse</p>
)}
{searchResults && searchResults.length > 0 && (
<div className="mt-1 border border-slate-200 rounded-lg overflow-hidden max-h-36 overflow-y-auto">
{searchResults.map(r => (
<button
key={r.id}
onClick={() => handleLink(r.id)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-indigo-50 hover:text-indigo-700 transition-colors border-b border-slate-100 last:border-0"
>
{rel.searchLabel(r)}
</button>
))}
</div>
)}
</div>
)}
{/* Linked items */}
<div className="bg-slate-50 rounded-lg p-3 min-h-[40px]">
{items === undefined && (
<div className="flex gap-1">
{[1,2,3].map(i => <div key={i} className="h-6 w-16 bg-slate-200 rounded animate-pulse" />)}
</div>
)}
{items === null && <span className="text-xs text-red-500">Fehler beim Laden</span>}
{Array.isArray(items) && items.length === 0 && (
<span className="text-sm text-slate-400">Keine verknüpften Einträge</span>
)}
{Array.isArray(items) && items.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{items.map(item => (
<span key={item.id} className="group flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 rounded-full pl-2.5 pr-1 py-1 font-medium">
{rel.display(item)}
<button
onClick={() => handleUnlink(item.id)}
className="text-indigo-400 hover:text-red-500 hover:bg-red-50 rounded-full w-4 h-4 flex items-center justify-center transition-colors"
title="Verknüpfung entfernen"
>
×
</button>
</span>
))}
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
</section>
);
}
export default function RecordModal({ record, meta, onClose, onSaved }) { export default function RecordModal({ record, meta, onClose, onSaved }) {
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [dirty, setDirty] = useState({}); const [dirty, setDirty] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(''); const [saveError, setSaveError] = useState('');
const [related, setRelated] = useState({});
// Init values from record // Init values from record
useEffect(() => { useEffect(() => {
@@ -117,22 +248,8 @@ export default function RecordModal({ record, meta, onClose, onSaved }) {
setValues({ ...record }); setValues({ ...record });
setDirty({}); setDirty({});
setSaveError(''); setSaveError('');
setRelated({});
}, [record]); }, [record]);
// Fetch related data (e.g. pictures → words)
useEffect(() => {
if (!record || !meta.fetchRelated?.length) return;
meta.fetchRelated.forEach(rel => {
apiFetchOne(rel.endpoint(record.id))
.then(data => {
const arr = Array.isArray(data) ? data : [];
setRelated(prev => ({ ...prev, [rel.key]: arr }));
})
.catch(() => setRelated(prev => ({ ...prev, [rel.key]: null })));
});
}, [record, meta]);
const handleChange = useCallback((key, val) => { const handleChange = useCallback((key, val) => {
setValues(prev => ({ ...prev, [key]: val })); setValues(prev => ({ ...prev, [key]: val }));
setDirty(prev => ({ ...prev, [key]: true })); setDirty(prev => ({ ...prev, [key]: true }));
@@ -249,37 +366,9 @@ export default function RecordModal({ record, meta, onClose, onSaved }) {
</section> </section>
)} )}
{/* Related data (fetched separately) */} {/* Related data with link/unlink */}
{meta.fetchRelated?.map(rel => ( {meta.fetchRelated?.map(rel => (
<section key={rel.key}> <RelationManager key={rel.key} recordId={record.id} rel={rel} />
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">
{rel.label}
</h3>
<div className="bg-slate-50 rounded-lg p-3">
{related[rel.key] === undefined && (
<div className="flex gap-1">
{[1,2,3].map(i => (
<div key={i} className="h-6 w-16 bg-slate-200 rounded animate-pulse" />
))}
</div>
)}
{related[rel.key] === null && (
<span className="text-xs text-red-500">Fehler beim Laden</span>
)}
{Array.isArray(related[rel.key]) && related[rel.key].length === 0 && (
<span className="text-sm text-slate-400">Keine Einträge</span>
)}
{Array.isArray(related[rel.key]) && related[rel.key].length > 0 && (
<div className="flex flex-wrap gap-1.5">
{related[rel.key].map((item, i) => (
<span key={item.id || i} className="text-xs bg-indigo-50 text-indigo-700 rounded-full px-2.5 py-1 font-medium">
{rel.display(item)}
</span>
))}
</div>
)}
</div>
</section>
))} ))}
{/* Read-only metadata */} {/* Read-only metadata */}

View File

@@ -73,6 +73,18 @@ export async function apiFetchOne(path) {
return apiFetch(path); return apiFetch(path);
} }
export async function apiDelete(endpoint, id) {
return apiFetch(`${endpoint}/${id}`, { method: 'DELETE' });
}
export async function apiLink(path) {
return apiFetch(path, { method: 'POST', body: '{}' });
}
export async function apiUnlink(path) {
return apiFetch(path, { method: 'DELETE' });
}
// Multipart upload — does NOT set Content-Type (browser sets boundary automatically) // Multipart upload — does NOT set Content-Type (browser sets boundary automatically)
export async function apiUpload(path, formData) { export async function apiUpload(path, formData) {
const token = getToken(); const token = getToken();

View File

@@ -10,10 +10,32 @@ export const TABLES = {
editableFields: { editableFields: {
titel_de: { type: 'text' }, titel_de: { type: 'text' },
titel_en: { type: 'text' }, titel_en: { type: 'text' },
titel_sv: { type: 'text' },
status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] }, status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] },
difficulty_level:{ type: 'number', min: 1, max: 10 }, difficulty_level:{ type: 'number', min: 1, max: 10 },
}, },
fetchRelated: [], fetchRelated: [
{
key: 'pictures',
label: 'Bilder',
endpoint: id => `/words/${id}/pictures`,
display: p => p.design || p.id,
targetTable: 'pictures',
linkEndpoint: (id, targetId) => `/words/${id}/pictures/${targetId}`,
searchEndpoint: '/pictures',
searchLabel: p => p.design || p.id,
},
{
key: 'categories',
label: 'Kategorien',
endpoint: id => `/words/${id}/categories`,
display: c => c.name_de || c.id,
targetTable: 'categories',
linkEndpoint: (id, targetId) => `/words/${id}/categories/${targetId}`,
searchEndpoint: '/categories',
searchLabel: c => c.name_de || c.id,
},
],
}, },
pictures: { pictures: {
label: 'Bilder', label: 'Bilder',
@@ -30,7 +52,16 @@ export const TABLES = {
blurhash: { type: 'text' }, blurhash: { type: 'text' },
}, },
fetchRelated: [ fetchRelated: [
{ key: 'words', label: 'Wörter', endpoint: id => `/pictures/${id}/words`, display: w => w.titel_de || w.id }, {
key: 'words',
label: 'Wörter',
endpoint: id => `/pictures/${id}/words`,
display: w => w.titel_de || w.id,
targetTable: 'words',
linkEndpoint: (id, targetId) => `/pictures/${id}/words/${targetId}`,
searchEndpoint: '/words',
searchLabel: w => w.titel_de || w.id,
},
], ],
}, },
objects: { objects: {
@@ -45,7 +76,28 @@ export const TABLES = {
notes: { type: 'textarea' }, notes: { type: 'textarea' },
status: { type: 'select', options: ['published', 'blocked', 'draft'] }, status: { type: 'select', options: ['published', 'blocked', 'draft'] },
}, },
fetchRelated: [], fetchRelated: [
{
key: 'words',
label: 'Wörter',
endpoint: id => `/objects/${id}/words`,
display: w => w.titel_de || w.id,
targetTable: 'words',
linkEndpoint: (id, targetId) => `/objects/${id}/words/${targetId}`,
searchEndpoint: '/words',
searchLabel: w => w.titel_de || w.id,
},
{
key: 'pictures',
label: 'Bilder',
endpoint: id => `/objects/${id}/pictures`,
display: p => p.design || p.id,
targetTable: 'pictures',
linkEndpoint: (id, targetId) => `/objects/${id}/pictures/${targetId}`,
searchEndpoint: '/pictures',
searchLabel: p => p.design || p.id,
},
],
}, },
pairs: { pairs: {
label: 'Pairs', label: 'Pairs',