From a02705734d3f55b75f71a7be319f0913ff062a27 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 21 May 2026 22:43:48 +0200 Subject: [PATCH] Add relation manager: link/unlink records directly in modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/RecordModal.jsx | 181 ++++++++++++++++++++++++--------- src/lib/api.js | 12 +++ src/lib/tables.js | 58 ++++++++++- 3 files changed, 202 insertions(+), 49 deletions(-) diff --git a/src/components/RecordModal.jsx b/src/components/RecordModal.jsx index 61b0ebb..9c07307 100644 --- a/src/components/RecordModal.jsx +++ b/src/components/RecordModal.jsx @@ -1,5 +1,5 @@ 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'; 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 ( +
+
+

{rel.label}

+ +
+ + {/* Search box */} + {showSearch && ( +
+ 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 &&

Suche…

} + {searchResults && searchResults.length === 0 && search && !searching && ( +

Keine Ergebnisse

+ )} + {searchResults && searchResults.length > 0 && ( +
+ {searchResults.map(r => ( + + ))} +
+ )} +
+ )} + + {/* Linked items */} +
+ {items === undefined && ( +
+ {[1,2,3].map(i =>
)} +
+ )} + {items === null && Fehler beim Laden} + {Array.isArray(items) && items.length === 0 && ( + Keine verknüpften Einträge + )} + {Array.isArray(items) && items.length > 0 && ( +
+ {items.map(item => ( + + {rel.display(item)} + + + ))} +
+ )} + {error &&

{error}

} +
+
+ ); +} + export default function RecordModal({ record, meta, onClose, onSaved }) { const [values, setValues] = useState({}); const [dirty, setDirty] = useState({}); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); - const [related, setRelated] = useState({}); // Init values from record useEffect(() => { @@ -117,22 +248,8 @@ export default function RecordModal({ record, meta, onClose, onSaved }) { setValues({ ...record }); setDirty({}); setSaveError(''); - setRelated({}); }, [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) => { setValues(prev => ({ ...prev, [key]: val })); setDirty(prev => ({ ...prev, [key]: true })); @@ -249,37 +366,9 @@ export default function RecordModal({ record, meta, onClose, onSaved }) { )} - {/* Related data (fetched separately) */} + {/* Related data with link/unlink */} {meta.fetchRelated?.map(rel => ( -
-

- {rel.label} -

-
- {related[rel.key] === undefined && ( -
- {[1,2,3].map(i => ( -
- ))} -
- )} - {related[rel.key] === null && ( - Fehler beim Laden - )} - {Array.isArray(related[rel.key]) && related[rel.key].length === 0 && ( - Keine Einträge - )} - {Array.isArray(related[rel.key]) && related[rel.key].length > 0 && ( -
- {related[rel.key].map((item, i) => ( - - {rel.display(item)} - - ))} -
- )} -
-
+ ))} {/* Read-only metadata */} diff --git a/src/lib/api.js b/src/lib/api.js index fd58c85..1389aac 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -73,6 +73,18 @@ export async function apiFetchOne(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) export async function apiUpload(path, formData) { const token = getToken(); diff --git a/src/lib/tables.js b/src/lib/tables.js index 751220a..911ef8d 100644 --- a/src/lib/tables.js +++ b/src/lib/tables.js @@ -10,10 +10,32 @@ export const TABLES = { editableFields: { titel_de: { type: 'text' }, titel_en: { type: 'text' }, + titel_sv: { type: 'text' }, status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] }, 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: { label: 'Bilder', @@ -30,7 +52,16 @@ export const TABLES = { blurhash: { type: 'text' }, }, 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: { @@ -45,7 +76,28 @@ export const TABLES = { notes: { type: 'textarea' }, 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: { label: 'Pairs',