From 214f8a201923373973f4f993375148a3ab2f1668 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 10 May 2026 13:04:37 +0200 Subject: [PATCH] feat: object label per object + {obj:UUID} sentence placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Annotate: per-object single label input (M2M via db_objects_db_words), auto-save on blur, remove picture-level word section - Generate: object chips insert {obj:UUID} at cursor position in question/statement textarea - Live preview resolves {obj:UUID} → actual object label - PairsList display also resolves placeholders - Remove F/A/B word chip system from pair form (replaced by object placeholders) - Backend: POST /api/directus/db-objects//words replaces existing word with single label Co-Authored-By: Claude Sonnet 4.6 --- app.py | 58 +++-- frontend/src/api.ts | 13 + frontend/src/pages/DrawIt.tsx | 221 ++++------------ frontend/src/pages/GenerateIt.tsx | 414 +++++++++++------------------- frontend/src/types.ts | 2 + 5 files changed, 252 insertions(+), 456 deletions(-) diff --git a/app.py b/app.py index dd464ec..dfc8785 100644 --- a/app.py +++ b/app.py @@ -1895,31 +1895,45 @@ def directus_db_object_pairs(obj_id): return jsonify({"ok": True, "pair_id": pair_id, "statement_id": stmt_id, "question_id": q_id}) -@app.route("/api/directus/db-objects//words", methods=["GET"]) +@app.route("/api/directus/db-objects//words", methods=["GET", "POST"]) def directus_db_object_words(obj_id): """Gibt alle db_words zurück, die via db_objects_db_words mit dem Objekt verknüpft sind.""" token = request.headers.get("Authorization", "") - data, s = _directus( - "GET", - f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}" - f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500", - token, - ) - if s != 200: - return jsonify({"data": []}) - items = [] - for entry in (data.get("data") or []): - word = entry.get("db_words_id") or {} - if not isinstance(word, dict) or not word.get("id"): - continue - if word.get("status") == "archived": - continue - items.append({ - "word_id": word["id"], - "titel_de": word.get("titel_de", ""), - "level": word.get("level") or 50, - }) - return jsonify({"data": items}) + if request.method == "GET": + data, s = _directus( + "GET", + f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}" + f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500", + token, + ) + if s != 200: + return jsonify({"data": []}) + items = [] + for entry in (data.get("data") or []): + word = entry.get("db_words_id") or {} + if not isinstance(word, dict) or not word.get("id"): + continue + if word.get("status") == "archived": + continue + items.append({ + "word_id": word["id"], + "titel_de": word.get("titel_de", ""), + "level": word.get("level") or 50, + }) + return jsonify({"data": items}) + else: # POST — replace with single word + 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}) + 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}) @app.route("/api/directus/db-pairs/", methods=["PATCH", "DELETE"]) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7b88167..26c672d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -463,6 +463,19 @@ export async function getDbObjectWords(objectId: string, token: string): Promise return data.data as DbWord[] } +export async function saveDbObjectWord( + objectId: string, + word: { titel_de: string; level: number } | null, + 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: '' }), + }) + return res.json() +} + export async function updateDbPair( pairId: string, payload: { diff --git a/frontend/src/pages/DrawIt.tsx b/frontend/src/pages/DrawIt.tsx index 04a454b..2878572 100644 --- a/frontend/src/pages/DrawIt.tsx +++ b/frontend/src/pages/DrawIt.tsx @@ -9,12 +9,12 @@ import { createDbObject, updateDbObject, deleteDbObject, - getDbPictureWords, - saveDbPictureWords, + getDbObjectWords, + saveDbObjectWord, directusAssetUrl, } from '../api' import { useAuth } from '../context/AuthContext' -import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types' +import type { DbPicture, DbObject, Selection, CanvasObject } from '../types' const ChevronLeftIcon = () => ( @@ -42,15 +42,10 @@ export default function DrawIt() { const [selectedObjectId, setSelectedObjectId] = useState(null) const [currentSelections, setCurrentSelections] = useState([]) const [userNotes, setUserNotes] = useState('') - // pending words (not yet saved) - const [pendingWords, setPendingWords] = useState<{ titel_de: string; level: number }[]>([]) - const [wordInput, setWordInput] = useState('') - const [wordLevel, setWordLevel] = useState(50) - const [wordInputVisible, setWordInputVisible] = useState(false) - const wordInputRef = useRef(null) - // saved words from Directus - const [pictureWords, setPictureWords] = useState([]) - const [savingWords, setSavingWords] = useState(false) + // per-object labels: objectId → titel_de + const [objectLabels, setObjectLabels] = useState>({}) + // track which label was last saved to detect changes + const savedLabelsRef = useRef>({}) const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null) const [mode, setMode] = useState<'rect' | 'polygon'>('polygon') const [hasSelection, setHasSelection] = useState(false) @@ -68,10 +63,6 @@ export default function DrawIt() { return () => clearTimeout(t) }, [currentIndex]) - useEffect(() => { - if (wordInputVisible) wordInputRef.current?.focus() - }, [wordInputVisible]) - const currentPicture: DbPicture | null = debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null @@ -91,19 +82,31 @@ export default function DrawIt() { .catch(console.error) }, [token]) - // Load objects + words when picture changes + // Load objects when picture changes, then load each object's word useEffect(() => { if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null) - setPictureWords([]); setPendingWords([]) + setObjectLabels({}) + savedLabelsRef.current = {} setImageLoaded(false) return } getDbObjects(currentPicture.id, token) - .then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) }) - .catch(console.error) - getDbPictureWords(currentPicture.id, token) - .then(setPictureWords) + .then(objs => { + setObjects(objs.map(o => ({ ...o, visible: true }))) + setSelectedObjectId(null) + // Load word for each object + const newLabels: Record = {} + const promises = objs.map(obj => + getDbObjectWords(obj.id, token) + .then(words => { newLabels[obj.id] = words[0]?.titel_de ?? '' }) + .catch(() => { newLabels[obj.id] = '' }) + ) + Promise.all(promises).then(() => { + setObjectLabels(newLabels) + savedLabelsRef.current = { ...newLabels } + }) + }) .catch(console.error) }, [currentPicture?.id, token]) @@ -113,30 +116,16 @@ export default function DrawIt() { const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), []) - const addWord = () => { - const titel = wordInput.trim() - if (!titel || pendingWords.some(w => w.titel_de === titel) || pictureWords.some(w => w.titel_de === titel)) { - setWordInput(''); return - } - setPendingWords(prev => [...prev, { titel_de: titel, level: wordLevel }]) - setWordInput('') - setWordLevel(50) - setWordInputVisible(false) - } - - const saveWords = async () => { - if (!currentPicture || !token || pendingWords.length === 0) return - setSavingWords(true) + const handleLabelBlur = async (objId: string) => { + if (!token) return + const current = objectLabels[objId] ?? '' + const saved = savedLabelsRef.current[objId] ?? '' + if (current === saved) return try { - await saveDbPictureWords(currentPicture.id, pendingWords, token) - const updated = await getDbPictureWords(currentPicture.id, token) - setPictureWords(updated) - setPendingWords([]) - showStatus('Wörter gespeichert.') + await saveDbObjectWord(objId, { titel_de: current, level: 50 }, token) + savedLabelsRef.current[objId] = current } catch (e) { - showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true) - } finally { - setSavingWords(false) + showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Bezeichnung.', true) } } @@ -158,6 +147,8 @@ 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] = '' setCurrentSelections([]) setUserNotes('') canvasRef.current?.resetSelection() @@ -203,6 +194,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] if (selectedObjectId === objId) setSelectedObjectId(null) showStatus('Objekt gelöscht.') } catch (e) { @@ -294,6 +287,24 @@ export default function DrawIt() { )} + + {/* Per-object label input */} +
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', + }} + /> +
))} @@ -431,128 +442,6 @@ export default function DrawIt() { {statusMsg &&
{statusMsg}
} - - {/* Words sidebar */} - ) diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx index cd1bbdf..f666e93 100644 --- a/frontend/src/pages/GenerateIt.tsx +++ b/frontend/src/pages/GenerateIt.tsx @@ -6,14 +6,14 @@ import { getDbPictures, getDbObjects, getDbObjectPairs, - getDbPictureWords, + getDbObjectWords, createDbPair, updateDbPair, deleteDbPair, directusAssetUrl, } from '../api' import { useAuth } from '../context/AuthContext' -import type { DbPicture, DbObject, DbPair, DbPairWordEntry, CanvasObject } from '../types' +import type { DbPicture, DbObject, DbPair, CanvasObject, ObjectChip } from '../types' const ChevronLeftIcon = () => ( @@ -28,37 +28,45 @@ const ChevronRightIcon = () => ( // ── PairForm ────────────────────────────────────────────────────────────────── -interface PendingWord { - titel_de: string - level: number - link_to: 'question' | 'statement' | 'both' -} - interface PairFormProps { objectId: string token: string - suggestions: { titel_de: string; level: number }[] + objectChips: ObjectChip[] onSaved: () => void onCancel: () => void } -function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormProps) { +function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormProps) { const [level, setLevel] = useState(50) const [questionDe, setQuestionDe] = useState('') const [statementDe, setStatementDe] = useState('') - const [words, setWords] = useState([]) - const [wordInput, setWordInput] = useState('') - const [wordLevel, setWordLevel] = useState(50) const [saving, setSaving] = useState(false) const [error, setError] = useState('') - const wordInputRef = useRef(null) - const addWord = (titel_de?: string, lvl?: number, defaultLinkTo: 'question' | 'statement' | 'both' = 'both') => { - const t = (titel_de ?? wordInput).trim() - const l = lvl ?? wordLevel - if (!t || words.some(w => w.titel_de === t)) { if (!titel_de) setWordInput(''); return } - setWords(prev => [...prev, { titel_de: t, level: l, link_to: defaultLinkTo }]) - if (!titel_de) { setWordInput(''); setWordLevel(50); wordInputRef.current?.focus() } + const questionRef = useRef(null) + 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 insertAtCursor = (objectId: string, _label: 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 newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end) + if (lastFocusedRef.current === 'question') setQuestionDe(newVal) + else setStatementDe(newVal) + requestAnimationFrame(() => { + ta.focus() + ta.setSelectionRange(start + placeholder.length, start + placeholder.length) + }) } const handleSave = async () => { @@ -70,7 +78,7 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP question_de: questionDe.trim() || undefined, statement_de: statementDe.trim(), level, - words, + words: [], }, token) onSaved() } catch (e) { @@ -94,14 +102,34 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP /> + {/* Object chips for insertion */} + {objectChips.length > 0 && ( +
+ {objectChips.map(chip => ( + + ))} +
+ )} + {/* Question (optional) */}