feat: multi-word per object + {objectID.wordID} placeholders
- Annotate: multiple words per object via db_objects_db_words M2M, word chips with add/remove per object card
- Generate sidebar: objects shown with comma-separated word list as display name
- Generate pair form: all object words as suggestion chips, click inserts {objectId.wordId} at cursor
- Preview resolves {objectId.wordId} → actual word text
- Backend: POST adds single word (no replace), new DELETE /db-objects/<id>/words/<junctionId>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
29
app.py
29
app.py
@@ -1916,24 +1916,37 @@ def directus_db_object_words(obj_id):
|
|||||||
if word.get("status") == "archived":
|
if word.get("status") == "archived":
|
||||||
continue
|
continue
|
||||||
items.append({
|
items.append({
|
||||||
|
"junction_id": entry.get("id"),
|
||||||
"word_id": word["id"],
|
"word_id": word["id"],
|
||||||
"titel_de": word.get("titel_de", ""),
|
"titel_de": word.get("titel_de", ""),
|
||||||
"level": word.get("level") or 50,
|
"level": word.get("level") or 50,
|
||||||
|
"status": word.get("status", ""),
|
||||||
})
|
})
|
||||||
return jsonify({"data": items})
|
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 {}
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
titel_de = (body.get("titel_de") or "").strip()
|
titel_de = (body.get("titel_de") or "").strip()
|
||||||
level = int(body.get("level") or 50)
|
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:
|
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)
|
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})
|
# Check if already linked to avoid duplicates
|
||||||
return jsonify({"ok": True, "word_id": wid})
|
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/<obj_id>/words/<junction_id>", 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/<pair_id>", methods=["PATCH", "DELETE"])
|
@app.route("/api/directus/db-pairs/<pair_id>", methods=["PATCH", "DELETE"])
|
||||||
|
|||||||
@@ -463,15 +463,29 @@ export async function getDbObjectWords(objectId: string, token: string): Promise
|
|||||||
return data.data as DbWord[]
|
return data.data as DbWord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveDbObjectWord(
|
// Add a single word to an object (M2M)
|
||||||
|
export async function addDbObjectWord(
|
||||||
objectId: string,
|
objectId: string,
|
||||||
word: { titel_de: string; level: number } | null,
|
word: { titel_de: string; level: number },
|
||||||
token: string
|
token: string
|
||||||
) {
|
) {
|
||||||
const res = await fetch(`/api/directus/db-objects/${objectId}/words`, {
|
const res = await fetch(`/api/directus/db-objects/${objectId}/words`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: token },
|
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()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
updateDbObject,
|
updateDbObject,
|
||||||
deleteDbObject,
|
deleteDbObject,
|
||||||
getDbObjectWords,
|
getDbObjectWords,
|
||||||
saveDbObjectWord,
|
addDbObjectWord,
|
||||||
|
deleteDbObjectWord,
|
||||||
directusAssetUrl,
|
directusAssetUrl,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { DbPicture, DbObject, Selection, CanvasObject } from '../types'
|
import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types'
|
||||||
|
|
||||||
const ChevronLeftIcon = () => (
|
const ChevronLeftIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -42,10 +43,10 @@ export default function DrawIt() {
|
|||||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||||
const [userNotes, setUserNotes] = useState('')
|
const [userNotes, setUserNotes] = useState('')
|
||||||
// per-object labels: objectId → titel_de
|
// per-object words: objectId → DbWord[]
|
||||||
const [objectLabels, setObjectLabels] = useState<Record<string, string>>({})
|
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
|
||||||
// track which label was last saved to detect changes
|
// per-object word input values: objectId → current input text
|
||||||
const savedLabelsRef = useRef<Record<string, string>>({})
|
const [wordInputs, setWordInputs] = useState<Record<string, string>>({})
|
||||||
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
||||||
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
|
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
|
||||||
const [hasSelection, setHasSelection] = useState(false)
|
const [hasSelection, setHasSelection] = useState(false)
|
||||||
@@ -86,8 +87,8 @@ export default function DrawIt() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture || !token) {
|
if (!currentPicture || !token) {
|
||||||
setObjects([]); setSelectedObjectId(null)
|
setObjects([]); setSelectedObjectId(null)
|
||||||
setObjectLabels({})
|
setObjectWords({})
|
||||||
savedLabelsRef.current = {}
|
setWordInputs({})
|
||||||
setImageLoaded(false)
|
setImageLoaded(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,16 +96,15 @@ export default function DrawIt() {
|
|||||||
.then(objs => {
|
.then(objs => {
|
||||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||||||
setSelectedObjectId(null)
|
setSelectedObjectId(null)
|
||||||
// Load word for each object
|
// Load words for each object
|
||||||
const newLabels: Record<string, string> = {}
|
const newWords: Record<string, DbWord[]> = {}
|
||||||
const promises = objs.map(obj =>
|
const promises = objs.map(obj =>
|
||||||
getDbObjectWords(obj.id, token)
|
getDbObjectWords(obj.id, token)
|
||||||
.then(words => { newLabels[obj.id] = words[0]?.titel_de ?? '' })
|
.then(words => { newWords[obj.id] = words })
|
||||||
.catch(() => { newLabels[obj.id] = '' })
|
.catch(() => { newWords[obj.id] = [] })
|
||||||
)
|
)
|
||||||
Promise.all(promises).then(() => {
|
Promise.all(promises).then(() => {
|
||||||
setObjectLabels(newLabels)
|
setObjectWords(newWords)
|
||||||
savedLabelsRef.current = { ...newLabels }
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -116,16 +116,32 @@ export default function DrawIt() {
|
|||||||
|
|
||||||
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
||||||
|
|
||||||
const handleLabelBlur = async (objId: string) => {
|
const handleAddObjectWord = async (objId: string) => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
const current = objectLabels[objId] ?? ''
|
const titel_de = (wordInputs[objId] || '').trim()
|
||||||
const saved = savedLabelsRef.current[objId] ?? ''
|
if (!titel_de) return
|
||||||
if (current === saved) return
|
|
||||||
try {
|
try {
|
||||||
await saveDbObjectWord(objId, { titel_de: current, level: 50 }, token)
|
const result = await addDbObjectWord(objId, { titel_de, level: 50 }, token)
|
||||||
savedLabelsRef.current[objId] = current
|
if (result.ok) {
|
||||||
|
const words = await getDbObjectWords(objId, token)
|
||||||
|
setObjectWords(prev => ({ ...prev, [objId]: words }))
|
||||||
|
setWordInputs(prev => ({ ...prev, [objId]: '' }))
|
||||||
|
}
|
||||||
} catch (e) {
|
} 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,
|
user_notes: userNotes.trim() || null,
|
||||||
}, token)
|
}, token)
|
||||||
setObjects(prev => [...prev, { ...obj, visible: true }])
|
setObjects(prev => [...prev, { ...obj, visible: true }])
|
||||||
setObjectLabels(prev => ({ ...prev, [obj.id]: '' }))
|
setObjectWords(prev => ({ ...prev, [obj.id]: [] }))
|
||||||
savedLabelsRef.current[obj.id] = ''
|
|
||||||
setCurrentSelections([])
|
setCurrentSelections([])
|
||||||
setUserNotes('')
|
setUserNotes('')
|
||||||
canvasRef.current?.resetSelection()
|
canvasRef.current?.resetSelection()
|
||||||
@@ -194,8 +209,8 @@ export default function DrawIt() {
|
|||||||
try {
|
try {
|
||||||
await deleteDbObject(objId, token)
|
await deleteDbObject(objId, token)
|
||||||
setObjects(prev => prev.filter(o => o.id !== objId))
|
setObjects(prev => prev.filter(o => o.id !== objId))
|
||||||
setObjectLabels(prev => { const n = { ...prev }; delete n[objId]; return n })
|
setObjectWords(prev => { const n = { ...prev }; delete n[objId]; return n })
|
||||||
delete savedLabelsRef.current[objId]
|
setWordInputs(prev => { const n = { ...prev }; delete n[objId]; return n })
|
||||||
if (selectedObjectId === objId) setSelectedObjectId(null)
|
if (selectedObjectId === objId) setSelectedObjectId(null)
|
||||||
showStatus('Objekt gelöscht.')
|
showStatus('Objekt gelöscht.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -288,22 +303,43 @@ export default function DrawIt() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per-object label input */}
|
{/* Per-object multi-word chips */}
|
||||||
<div style={{ padding: '6px 8px', borderTop: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
|
<div style={{ padding: '6px 8px', borderTop: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
|
||||||
<label style={{ fontSize: 10, color: 'var(--text-2)', display: 'block', marginBottom: 3 }}>Bezeichnung</label>
|
<label style={{ fontSize: 10, color: 'var(--text-2)', display: 'block', marginBottom: 3 }}>Wörter</label>
|
||||||
<input
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 4 }}>
|
||||||
type="text"
|
{(objectWords[obj.id] || []).map(w => (
|
||||||
value={objectLabels[obj.id] ?? ''}
|
<span key={w.junction_id} style={{
|
||||||
onChange={e => setObjectLabels(prev => ({ ...prev, [obj.id]: e.target.value }))}
|
display: 'flex', alignItems: 'center', gap: 3,
|
||||||
onBlur={() => handleLabelBlur(obj.id)}
|
padding: '2px 8px', background: '#e0e7ff', color: '#3730a3',
|
||||||
placeholder="Bezeichnung…"
|
borderRadius: 9999, fontSize: 11,
|
||||||
style={{
|
}}>
|
||||||
width: '100%', padding: '4px 7px', borderRadius: 'var(--r-sm)',
|
{w.titel_de}
|
||||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
<button
|
||||||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
onClick={() => handleRemoveObjectWord(obj.id, w.junction_id!)}
|
||||||
boxSizing: 'border-box',
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#818cf8', padding: 0, lineHeight: 1, fontSize: 13 }}
|
||||||
}}
|
title="Entfernen"
|
||||||
/>
|
>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wordInputs[obj.id] || ''}
|
||||||
|
onChange={e => 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAddObjectWord(obj.id)}
|
||||||
|
style={{ padding: '3px 8px', borderRadius: 'var(--r-sm)', background: '#6366f1', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
directusAssetUrl,
|
directusAssetUrl,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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 = () => (
|
const ChevronLeftIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -47,19 +47,19 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
|
|||||||
const statementRef = useRef<HTMLTextAreaElement>(null)
|
const statementRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const lastFocusedRef = useRef<'question' | 'statement'>('statement')
|
const lastFocusedRef = useRef<'question' | 'statement'>('statement')
|
||||||
|
|
||||||
const resolveTemplate = (text: string) =>
|
const resolveTemplate = (text: string): string =>
|
||||||
text.replace(/\{obj:([^}]+)\}/g, (_, id) => {
|
text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => {
|
||||||
const chip = objectChips.find(c => c.objectId === id)
|
const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid)
|
||||||
return chip?.label ? chip.label : '{?}'
|
return chip ? chip.label : '{?}'
|
||||||
})
|
})
|
||||||
|
|
||||||
const insertAtCursor = (objectId: string, _label: string) => {
|
const insertAtCursor = (oid: string, wordId: string) => {
|
||||||
const ref = lastFocusedRef.current === 'question' ? questionRef : statementRef
|
const ref = lastFocusedRef.current === 'question' ? questionRef : statementRef
|
||||||
const ta = ref.current
|
const ta = ref.current
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
const start = ta.selectionStart ?? ta.value.length
|
const start = ta.selectionStart ?? ta.value.length
|
||||||
const end = ta.selectionEnd ?? 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)
|
const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end)
|
||||||
if (lastFocusedRef.current === 'question') setQuestionDe(newVal)
|
if (lastFocusedRef.current === 'question') setQuestionDe(newVal)
|
||||||
else setStatementDe(newVal)
|
else setStatementDe(newVal)
|
||||||
@@ -104,19 +104,22 @@ function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormP
|
|||||||
|
|
||||||
{/* Object chips for insertion */}
|
{/* Object chips for insertion */}
|
||||||
{objectChips.length > 0 && (
|
{objectChips.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mb-2" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
<div>
|
||||||
{objectChips.map(chip => (
|
<p style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 4 }}>Objekt-Wörter einfügen:</p>
|
||||||
<button key={chip.objectId} type="button"
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
onClick={() => insertAtCursor(chip.objectId, chip.label)}
|
{objectChips.map(chip => (
|
||||||
style={{
|
<button key={`${chip.objectId}.${chip.wordId}`} type="button"
|
||||||
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
|
onClick={() => insertAtCursor(chip.objectId, chip.wordId)}
|
||||||
border: '1px solid #93c5fd', cursor: 'pointer',
|
style={{
|
||||||
background: '#eff6ff', color: '#1d4ed8',
|
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
|
||||||
}}
|
border: '1px solid #93c5fd', cursor: 'pointer',
|
||||||
>
|
background: '#eff6ff', color: '#1d4ed8',
|
||||||
{chip.index}. {chip.label || '?'}
|
}}
|
||||||
</button>
|
>
|
||||||
))}
|
{chip.objectIndex}. {chip.label || '?'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -212,19 +215,19 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
|
|||||||
const editStatementRef = useRef<HTMLTextAreaElement>(null)
|
const editStatementRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const editLastFocusedRef = useRef<'question' | 'statement'>('statement')
|
const editLastFocusedRef = useRef<'question' | 'statement'>('statement')
|
||||||
|
|
||||||
const resolveTemplate = (text: string) =>
|
const resolveTemplate = (text: string): string =>
|
||||||
text.replace(/\{obj:([^}]+)\}/g, (_, id) => {
|
text.replace(/\{([^.}]+)\.([^}]+)\}/g, (_, oid, wid) => {
|
||||||
const chip = objectChips.find(c => c.objectId === id)
|
const chip = objectChips.find(c => c.objectId === oid && c.wordId === wid)
|
||||||
return chip?.label ? chip.label : '{?}'
|
return chip ? chip.label : '{?}'
|
||||||
})
|
})
|
||||||
|
|
||||||
const insertAtCursor = (objectId: string, _label: string) => {
|
const insertAtCursor = (oid: string, wordId: string) => {
|
||||||
const ref = editLastFocusedRef.current === 'question' ? editQuestionRef : editStatementRef
|
const ref = editLastFocusedRef.current === 'question' ? editQuestionRef : editStatementRef
|
||||||
const ta = ref.current
|
const ta = ref.current
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
const start = ta.selectionStart ?? ta.value.length
|
const start = ta.selectionStart ?? ta.value.length
|
||||||
const end = ta.selectionEnd ?? 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)
|
const newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end)
|
||||||
if (editLastFocusedRef.current === 'question') setEditQuestion(newVal)
|
if (editLastFocusedRef.current === 'question') setEditQuestion(newVal)
|
||||||
else setEditStatement(newVal)
|
else setEditStatement(newVal)
|
||||||
@@ -291,19 +294,22 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
|
|||||||
|
|
||||||
{/* Object chips for edit mode */}
|
{/* Object chips for edit mode */}
|
||||||
{objectChips.length > 0 && (
|
{objectChips.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
<div>
|
||||||
{objectChips.map(chip => (
|
<p style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 4 }}>Objekt-Wörter einfügen:</p>
|
||||||
<button key={chip.objectId} type="button"
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
onClick={() => insertAtCursor(chip.objectId, chip.label)}
|
{objectChips.map(chip => (
|
||||||
style={{
|
<button key={`${chip.objectId}.${chip.wordId}`} type="button"
|
||||||
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
|
onClick={() => insertAtCursor(chip.objectId, chip.wordId)}
|
||||||
border: '1px solid #93c5fd', cursor: 'pointer',
|
style={{
|
||||||
background: '#eff6ff', color: '#1d4ed8',
|
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
|
||||||
}}
|
border: '1px solid #93c5fd', cursor: 'pointer',
|
||||||
>
|
background: '#eff6ff', color: '#1d4ed8',
|
||||||
{chip.index}. {chip.label || '?'}
|
}}
|
||||||
</button>
|
>
|
||||||
))}
|
{chip.objectIndex}. {chip.label || '?'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -409,8 +415,8 @@ export default function GenerateIt() {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false)
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
const [pairs, setPairs] = useState<DbPair[]>([])
|
const [pairs, setPairs] = useState<DbPair[]>([])
|
||||||
const [pairsLoading, setPairsLoading] = useState(false)
|
const [pairsLoading, setPairsLoading] = useState(false)
|
||||||
// per-object labels: objectId → titel_de
|
// per-object words: objectId → DbWord[]
|
||||||
const [objectLabels, setObjectLabels] = useState<Record<string, string>>({})
|
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
|
||||||
|
|
||||||
const currentPicture: DbPicture | null =
|
const currentPicture: DbPicture | null =
|
||||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||||
@@ -423,12 +429,17 @@ export default function GenerateIt() {
|
|||||||
hierarchy: 1,
|
hierarchy: 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Build object chips from dbObjects + objectLabels
|
// Build object chips from dbObjects + objectWords (one chip per word)
|
||||||
const objectChips: ObjectChip[] = dbObjects.map((obj, i) => ({
|
const objectChips: ObjectChip[] = dbObjects.flatMap((obj, i) => {
|
||||||
objectId: obj.id,
|
const words = objectWords[obj.id] || []
|
||||||
label: objectLabels[obj.id] ?? '',
|
return words.map(w => ({
|
||||||
index: i + 1,
|
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
|
// Load db_pictures with status=objects_created
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -442,21 +453,21 @@ export default function GenerateIt() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture || !token) {
|
if (!currentPicture || !token) {
|
||||||
setDbObjects([]); setSelectedObjId(null); setPairs([]); setImageLoaded(false)
|
setDbObjects([]); setSelectedObjId(null); setPairs([]); setImageLoaded(false)
|
||||||
setObjectLabels({})
|
setObjectWords({})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
getDbObjects(currentPicture.id, token)
|
getDbObjects(currentPicture.id, token)
|
||||||
.then(objs => {
|
.then(objs => {
|
||||||
setDbObjects(objs)
|
setDbObjects(objs)
|
||||||
setSelectedObjId(objs.length > 0 ? objs[0].id : null)
|
setSelectedObjId(objs.length > 0 ? objs[0].id : null)
|
||||||
// Load word/label for each object
|
// Load words for each object
|
||||||
const newLabels: Record<string, string> = {}
|
const newWords: Record<string, DbWord[]> = {}
|
||||||
const promises = objs.map(obj =>
|
const promises = objs.map(obj =>
|
||||||
getDbObjectWords(obj.id, token)
|
getDbObjectWords(obj.id, token)
|
||||||
.then(words => { newLabels[obj.id] = words[0]?.titel_de ?? '' })
|
.then(words => { newWords[obj.id] = words })
|
||||||
.catch(() => { newLabels[obj.id] = '' })
|
.catch(() => { newWords[obj.id] = [] })
|
||||||
)
|
)
|
||||||
Promise.all(promises).then(() => setObjectLabels(newLabels))
|
Promise.all(promises).then(() => setObjectWords(newWords))
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [currentPicture?.id, token])
|
}, [currentPicture?.id, token])
|
||||||
@@ -527,10 +538,13 @@ export default function GenerateIt() {
|
|||||||
<div className="object-item-header">
|
<div className="object-item-header">
|
||||||
<div className="object-item-text">
|
<div className="object-item-text">
|
||||||
<strong>Objekt {i + 1}</strong>
|
<strong>Objekt {i + 1}</strong>
|
||||||
{objectLabels[obj.id] && (
|
{(objectWords[obj.id] || []).length > 0 ? (
|
||||||
<span style={{ color: 'var(--primary)', fontWeight: 500 }}>{objectLabels[obj.id]}</span>
|
<span style={{ color: 'var(--primary)', fontWeight: 500 }}>
|
||||||
|
{(objectWords[obj.id] || []).map(w => w.titel_de).join(', ')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'}</span>
|
||||||
)}
|
)}
|
||||||
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{obj.user_notes && (
|
{obj.user_notes && (
|
||||||
|
|||||||
@@ -111,7 +111,13 @@ export interface DbPair {
|
|||||||
statements: DbPairStatement[]
|
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
|
// Legacy — still used by GenerateIt
|
||||||
export interface ObjectMeta {
|
export interface ObjectMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user