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:
2026-05-10 16:49:24 +02:00
parent 214f8a2019
commit 40c36182f1
5 changed files with 191 additions and 108 deletions

View File

@@ -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()
}

View File

@@ -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 = () => (
<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 [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [userNotes, setUserNotes] = useState('')
// per-object labels: objectId → titel_de
const [objectLabels, setObjectLabels] = useState<Record<string, string>>({})
// track which label was last saved to detect changes
const savedLabelsRef = useRef<Record<string, string>>({})
// per-object words: objectId → DbWord[]
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
// per-object word input values: objectId → current input text
const [wordInputs, setWordInputs] = useState<Record<string, string>>({})
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<string, string> = {}
// Load words for each object
const newWords: Record<string, DbWord[]> = {}
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() {
</div>
)}
{/* Per-object label input */}
{/* Per-object multi-word chips */}
<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>
<input
type="text"
value={objectLabels[obj.id] ?? ''}
onChange={e => 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',
}}
/>
<label style={{ fontSize: 10, color: 'var(--text-2)', display: 'block', marginBottom: 3 }}>Wörter</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 4 }}>
{(objectWords[obj.id] || []).map(w => (
<span key={w.junction_id} style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 8px', background: '#e0e7ff', color: '#3730a3',
borderRadius: 9999, fontSize: 11,
}}>
{w.titel_de}
<button
onClick={() => handleRemoveObjectWord(obj.id, w.junction_id!)}
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>
))}

View File

@@ -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 = () => (
<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 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 && (
<div className="flex flex-wrap gap-1 mb-2" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => (
<button key={chip.objectId} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.label)}
style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer',
background: '#eff6ff', color: '#1d4ed8',
}}
>
{chip.index}. {chip.label || '?'}
</button>
))}
<div>
<p style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 4 }}>Objekt-Wörter einfügen:</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => (
<button key={`${chip.objectId}.${chip.wordId}`} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.wordId)}
style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer',
background: '#eff6ff', color: '#1d4ed8',
}}
>
{chip.objectIndex}. {chip.label || '?'}
</button>
))}
</div>
</div>
)}
@@ -212,19 +215,19 @@ function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }:
const editStatementRef = useRef<HTMLTextAreaElement>(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 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => (
<button key={chip.objectId} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.label)}
style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer',
background: '#eff6ff', color: '#1d4ed8',
}}
>
{chip.index}. {chip.label || '?'}
</button>
))}
<div>
<p style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 4 }}>Objekt-Wörter einfügen:</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectChips.map(chip => (
<button key={`${chip.objectId}.${chip.wordId}`} type="button"
onClick={() => insertAtCursor(chip.objectId, chip.wordId)}
style={{
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
border: '1px solid #93c5fd', cursor: 'pointer',
background: '#eff6ff', color: '#1d4ed8',
}}
>
{chip.objectIndex}. {chip.label || '?'}
</button>
))}
</div>
</div>
)}
@@ -409,8 +415,8 @@ export default function GenerateIt() {
const [imageLoaded, setImageLoaded] = useState(false)
const [pairs, setPairs] = useState<DbPair[]>([])
const [pairsLoading, setPairsLoading] = useState(false)
// per-object labels: objectId → titel_de
const [objectLabels, setObjectLabels] = useState<Record<string, string>>({})
// per-object words: objectId → DbWord[]
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
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<string, string> = {}
// Load words for each object
const newWords: Record<string, DbWord[]> = {}
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() {
<div className="object-item-header">
<div className="object-item-text">
<strong>Objekt {i + 1}</strong>
{objectLabels[obj.id] && (
<span style={{ color: 'var(--primary)', fontWeight: 500 }}>{objectLabels[obj.id]}</span>
{(objectWords[obj.id] || []).length > 0 ? (
<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>
{obj.user_notes && (

View File

@@ -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 {