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

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