diff --git a/app.py b/app.py index dfc8785..b5b0bdc 100644 --- a/app.py +++ b/app.py @@ -1916,24 +1916,37 @@ def directus_db_object_words(obj_id): if word.get("status") == "archived": continue items.append({ + "junction_id": entry.get("id"), "word_id": word["id"], "titel_de": word.get("titel_de", ""), "level": word.get("level") or 50, + "status": word.get("status", ""), }) return jsonify({"data": items}) - else: # POST — replace with single word + else: # POST — add a single word to the object (M2M, allows multiple) body = request.get_json(force=True, silent=True) or {} titel_de = (body.get("titel_de") or "").strip() level = int(body.get("level") or 50) - # Delete all existing junctions for this object - existing, _ = _directus("GET", f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}&fields=id&limit=20", token) - for e in (existing.get("data") or []): - _directus("DELETE", f"/items/db_objects_db_words/{e['id']}", token) if not titel_de: - return jsonify({"ok": True, "cleared": True}) + return jsonify({"error": "titel_de required"}), 400 wid, _ = _find_or_create_db_word(titel_de, level, token) - _directus("POST", "/items/db_objects_db_words", token, {"db_objects_id": obj_id, "db_words_id": wid}) - return jsonify({"ok": True, "word_id": wid}) + # Check if already linked to avoid duplicates + existing, _ = _directus("GET", + f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}&filter[db_words_id][_eq]={wid}&fields=id&limit=1", + token) + if existing.get("data"): + return jsonify({"ok": True, "already_exists": True, "word_id": wid, "junction_id": existing["data"][0]["id"]}) + resp, s = _directus("POST", "/items/db_objects_db_words", token, + {"db_objects_id": obj_id, "db_words_id": wid}) + junction_id = resp["data"]["id"] if s in (200, 201) else None + return jsonify({"ok": True, "word_id": wid, "junction_id": junction_id}) + + +@app.route("/api/directus/db-objects//words/", methods=["DELETE"]) +def directus_db_object_word_delete(obj_id, junction_id): + token = request.headers.get("Authorization", "") + _directus("DELETE", f"/items/db_objects_db_words/{junction_id}", token) + return jsonify({"ok": True}) @app.route("/api/directus/db-pairs/", methods=["PATCH", "DELETE"]) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 26c672d..fb5fb95 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -463,15 +463,29 @@ export async function getDbObjectWords(objectId: string, token: string): Promise return data.data as DbWord[] } -export async function saveDbObjectWord( +// Add a single word to an object (M2M) +export async function addDbObjectWord( objectId: string, - word: { titel_de: string; level: number } | null, + word: { titel_de: string; level: number }, token: string ) { const res = await fetch(`/api/directus/db-objects/${objectId}/words`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: token }, - body: JSON.stringify(word ? { titel_de: word.titel_de, level: word.level } : { titel_de: '' }), + body: JSON.stringify(word), + }) + return res.json() +} + +// Remove a single word from an object by junction ID +export async function deleteDbObjectWord( + objectId: string, + junctionId: string | number, + token: string +) { + const res = await fetch(`/api/directus/db-objects/${objectId}/words/${junctionId}`, { + method: 'DELETE', + headers: { Authorization: token }, }) return res.json() } diff --git a/frontend/src/pages/DrawIt.tsx b/frontend/src/pages/DrawIt.tsx index 2878572..c5c1769 100644 --- a/frontend/src/pages/DrawIt.tsx +++ b/frontend/src/pages/DrawIt.tsx @@ -10,11 +10,12 @@ import { updateDbObject, deleteDbObject, getDbObjectWords, - saveDbObjectWord, + addDbObjectWord, + deleteDbObjectWord, directusAssetUrl, } from '../api' import { useAuth } from '../context/AuthContext' -import type { DbPicture, DbObject, Selection, CanvasObject } from '../types' +import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types' const ChevronLeftIcon = () => ( @@ -42,10 +43,10 @@ export default function DrawIt() { const [selectedObjectId, setSelectedObjectId] = useState(null) const [currentSelections, setCurrentSelections] = useState([]) const [userNotes, setUserNotes] = useState('') - // per-object labels: objectId → titel_de - const [objectLabels, setObjectLabels] = useState>({}) - // track which label was last saved to detect changes - const savedLabelsRef = useRef>({}) + // per-object words: objectId → DbWord[] + const [objectWords, setObjectWords] = useState>({}) + // per-object word input values: objectId → current input text + const [wordInputs, setWordInputs] = useState>({}) const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null) const [mode, setMode] = useState<'rect' | 'polygon'>('polygon') const [hasSelection, setHasSelection] = useState(false) @@ -86,8 +87,8 @@ export default function DrawIt() { useEffect(() => { if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null) - setObjectLabels({}) - savedLabelsRef.current = {} + setObjectWords({}) + setWordInputs({}) setImageLoaded(false) return } @@ -95,16 +96,15 @@ export default function DrawIt() { .then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))) setSelectedObjectId(null) - // Load word for each object - const newLabels: Record = {} + // Load words for each object + const newWords: Record = {} const promises = objs.map(obj => getDbObjectWords(obj.id, token) - .then(words => { newLabels[obj.id] = words[0]?.titel_de ?? '' }) - .catch(() => { newLabels[obj.id] = '' }) + .then(words => { newWords[obj.id] = words }) + .catch(() => { newWords[obj.id] = [] }) ) Promise.all(promises).then(() => { - setObjectLabels(newLabels) - savedLabelsRef.current = { ...newLabels } + setObjectWords(newWords) }) }) .catch(console.error) @@ -116,16 +116,32 @@ export default function DrawIt() { const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), []) - const handleLabelBlur = async (objId: string) => { + const handleAddObjectWord = async (objId: string) => { if (!token) return - const current = objectLabels[objId] ?? '' - const saved = savedLabelsRef.current[objId] ?? '' - if (current === saved) return + const titel_de = (wordInputs[objId] || '').trim() + if (!titel_de) return try { - await saveDbObjectWord(objId, { titel_de: current, level: 50 }, token) - savedLabelsRef.current[objId] = current + const result = await addDbObjectWord(objId, { titel_de, level: 50 }, token) + if (result.ok) { + const words = await getDbObjectWords(objId, token) + setObjectWords(prev => ({ ...prev, [objId]: words })) + setWordInputs(prev => ({ ...prev, [objId]: '' })) + } } catch (e) { - showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Bezeichnung.', true) + showStatus(e instanceof Error ? e.message : 'Fehler beim Hinzufügen des Worts.', true) + } + } + + const handleRemoveObjectWord = async (objId: string, junctionId: string | number) => { + if (!token) return + try { + await deleteDbObjectWord(objId, junctionId, token) + setObjectWords(prev => ({ + ...prev, + [objId]: (prev[objId] || []).filter(w => w.junction_id !== junctionId) + })) + } catch (e) { + showStatus(e instanceof Error ? e.message : 'Fehler beim Entfernen des Worts.', true) } } @@ -147,8 +163,7 @@ export default function DrawIt() { user_notes: userNotes.trim() || null, }, token) setObjects(prev => [...prev, { ...obj, visible: true }]) - setObjectLabels(prev => ({ ...prev, [obj.id]: '' })) - savedLabelsRef.current[obj.id] = '' + setObjectWords(prev => ({ ...prev, [obj.id]: [] })) setCurrentSelections([]) setUserNotes('') canvasRef.current?.resetSelection() @@ -194,8 +209,8 @@ export default function DrawIt() { try { await deleteDbObject(objId, token) setObjects(prev => prev.filter(o => o.id !== objId)) - setObjectLabels(prev => { const n = { ...prev }; delete n[objId]; return n }) - delete savedLabelsRef.current[objId] + setObjectWords(prev => { const n = { ...prev }; delete n[objId]; return n }) + setWordInputs(prev => { const n = { ...prev }; delete n[objId]; return n }) if (selectedObjectId === objId) setSelectedObjectId(null) showStatus('Objekt gelöscht.') } catch (e) { @@ -288,22 +303,43 @@ export default function DrawIt() { )} - {/* Per-object label input */} + {/* Per-object multi-word chips */}
e.stopPropagation()}> - - setObjectLabels(prev => ({ ...prev, [obj.id]: e.target.value }))} - onBlur={() => handleLabelBlur(obj.id)} - placeholder="Bezeichnung…" - style={{ - width: '100%', padding: '4px 7px', borderRadius: 'var(--r-sm)', - border: '1px solid var(--border)', background: 'var(--surface-2)', - color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12, - boxSizing: 'border-box', - }} - /> + +
+ {(objectWords[obj.id] || []).map(w => ( + + {w.titel_de} + + + ))} +
+
+ setWordInputs(prev => ({ ...prev, [obj.id]: e.target.value }))} + onKeyDown={e => { if (e.key === 'Enter') handleAddObjectWord(obj.id) }} + placeholder="Wort hinzufügen…" + style={{ + flex: 1, padding: '3px 7px', borderRadius: 'var(--r-sm)', + border: '1px solid var(--border)', background: 'var(--surface-2)', + color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12, + }} + /> + +
))} diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx index f666e93..f479795 100644 --- a/frontend/src/pages/GenerateIt.tsx +++ b/frontend/src/pages/GenerateIt.tsx @@ -13,7 +13,7 @@ import { directusAssetUrl, } from '../api' import { useAuth } from '../context/AuthContext' -import type { DbPicture, DbObject, DbPair, CanvasObject, ObjectChip } from '../types' +import type { DbPicture, DbObject, DbWord, DbPair, CanvasObject, ObjectChip } from '../types' const ChevronLeftIcon = () => ( @@ -47,19 +47,19 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP const statementRef = useRef(null) const lastFocusedRef = useRef<'question' | 'statement'>('statement') - const resolveTemplate = (text: string) => - text.replace(/\{obj:([^}]+)\}/g, (_, id) => { - const chip = objectChips.find(c => c.objectId === id) - return chip?.label ? chip.label : '{?}' + const resolveTemplate = (text: string): string => + text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => { + const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid) + return chip ? chip.label : '{?}' }) - const insertAtCursor = (objectId: string, _label: string) => { + const insertAtCursor = (oid: string, wordId: string) => { const ref = lastFocusedRef.current === 'question' ? questionRef : statementRef const ta = ref.current if (!ta) return const start = ta.selectionStart ?? ta.value.length const end = ta.selectionEnd ?? ta.value.length - const placeholder = `{obj:${objectId}}` + const placeholder = `{${oid}.${wordId}}` const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end) if (lastFocusedRef.current === 'question') setQuestionDe(newVal) else setStatementDe(newVal) @@ -104,19 +104,22 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP {/* Object chips for insertion */} {objectChips.length > 0 && ( -
- {objectChips.map(chip => ( - - ))} +
+

Objekt-Wörter einfügen:

+
+ {objectChips.map(chip => ( + + ))} +
)} @@ -212,19 +215,19 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }: const editStatementRef = useRef(null) const editLastFocusedRef = useRef<'question' | 'statement'>('statement') - const resolveTemplate = (text: string) => - text.replace(/\{obj:([^}]+)\}/g, (_, id) => { - const chip = objectChips.find(c => c.objectId === id) - return chip?.label ? chip.label : '{?}' + const resolveTemplate = (text: string): string => + text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => { + const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid) + return chip ? chip.label : '{?}' }) - const insertAtCursor = (objectId: string, _label: string) => { + const insertAtCursor = (oid: string, wordId: string) => { const ref = editLastFocusedRef.current === 'question' ? editQuestionRef : editStatementRef const ta = ref.current if (!ta) return const start = ta.selectionStart ?? ta.value.length const end = ta.selectionEnd ?? ta.value.length - const placeholder = `{obj:${objectId}}` + const placeholder = `{${oid}.${wordId}}` const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end) if (editLastFocusedRef.current === 'question') setEditQuestion(newVal) else setEditStatement(newVal) @@ -291,19 +294,22 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }: {/* Object chips for edit mode */} {objectChips.length > 0 && ( -
- {objectChips.map(chip => ( - - ))} +
+

Objekt-Wörter einfügen:

+
+ {objectChips.map(chip => ( + + ))} +
)} @@ -409,8 +415,8 @@ export default function GenerateIt() { const [imageLoaded, setImageLoaded] = useState(false) const [pairs, setPairs] = useState([]) const [pairsLoading, setPairsLoading] = useState(false) - // per-object labels: objectId → titel_de - const [objectLabels, setObjectLabels] = useState>({}) + // per-object words: objectId → DbWord[] + const [objectWords, setObjectWords] = useState>({}) const currentPicture: DbPicture | null = currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null @@ -423,12 +429,17 @@ export default function GenerateIt() { hierarchy: 1, })) - // Build object chips from dbObjects + objectLabels - const objectChips: ObjectChip[] = dbObjects.map((obj, i) => ({ - objectId: obj.id, - label: objectLabels[obj.id] ?? '', - index: i + 1, - })) + // Build object chips from dbObjects + objectWords (one chip per word) + const objectChips: ObjectChip[] = dbObjects.flatMap((obj, i) => { + const words = objectWords[obj.id] || [] + return words.map(w => ({ + objectId: obj.id, + wordId: w.word_id, + junctionId: w.junction_id!, + label: w.titel_de, + objectIndex: i + 1, + })) + }) // Load db_pictures with status=objects_created useEffect(() => { @@ -442,21 +453,21 @@ export default function GenerateIt() { useEffect(() => { if (!currentPicture || !token) { setDbObjects([]); setSelectedObjId(null); setPairs([]); setImageLoaded(false) - setObjectLabels({}) + setObjectWords({}) return } getDbObjects(currentPicture.id, token) .then(objs => { setDbObjects(objs) setSelectedObjId(objs.length > 0 ? objs[0].id : null) - // Load word/label for each object - const newLabels: Record = {} + // Load words for each object + const newWords: Record = {} const promises = objs.map(obj => getDbObjectWords(obj.id, token) - .then(words => { newLabels[obj.id] = words[0]?.titel_de ?? '' }) - .catch(() => { newLabels[obj.id] = '' }) + .then(words => { newWords[obj.id] = words }) + .catch(() => { newWords[obj.id] = [] }) ) - Promise.all(promises).then(() => setObjectLabels(newLabels)) + Promise.all(promises).then(() => setObjectWords(newWords)) }) .catch(console.error) }, [currentPicture?.id, token]) @@ -527,10 +538,13 @@ export default function GenerateIt() {
Objekt {i + 1} - {objectLabels[obj.id] && ( - {objectLabels[obj.id]} + {(objectWords[obj.id] || []).length > 0 ? ( + + {(objectWords[obj.id] || []).map(w => w.titel_de).join(', ')} + + ) : ( + {obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'} )} - {obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'}
{obj.user_notes && ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b3215a3..f3a368c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -111,7 +111,13 @@ export interface DbPair { statements: DbPairStatement[] } -export interface ObjectChip { objectId: string; label: string; index: number } +export interface ObjectChip { + objectId: string + wordId: string + junctionId: string | number + label: string // titel_de + objectIndex: number // the object's index number for display +} // Legacy — still used by GenerateIt export interface ObjectMeta {