feat: object label per object + {obj:UUID} sentence placeholders

- 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/<id>/words replaces existing word with single label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 13:04:37 +02:00
parent 2595b8d32e
commit 214f8a2019
5 changed files with 252 additions and 456 deletions

View File

@@ -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 = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -42,15 +42,10 @@ export default function DrawIt() {
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
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<HTMLInputElement>(null)
// saved words from Directus
const [pictureWords, setPictureWords] = useState<DbWord[]>([])
const [savingWords, setSavingWords] = useState(false)
// 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>>({})
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<string, string> = {}
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() {
</div>
</div>
)}
{/* Per-object label input */}
<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',
}}
/>
</div>
</div>
))}
</div>
@@ -431,128 +442,6 @@ export default function DrawIt() {
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
</div>
</aside>
{/* Words sidebar */}
<aside className="sidebar sidebar--words">
<div className="sidebar-panel">
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>
Words
{(pictureWords.length + pendingWords.length) > 0 && (
<span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + pendingWords.length}</span>
)}
</span>
<button
className="btn-icon"
style={{ width: 22, height: 22, borderRadius: 'var(--r-sm)', fontSize: 16, lineHeight: 1, padding: 0 }}
onClick={() => setWordInputVisible(v => !v)}
title="Word hinzufügen"
>+</button>
</h3>
{wordInputVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<input
ref={wordInputRef}
value={wordInput}
onChange={e => setWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') addWord()
if (e.key === 'Escape') { setWordInputVisible(false); setWordInput('') }
}}
placeholder="Wort (titel_de)…"
style={{
width: '100%', padding: '5px 8px', 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',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 11, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>Level</label>
<input
type="number"
min={1} max={100}
value={wordLevel}
onChange={e => setWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
style={{
flex: 1, padding: '4px 6px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
}}
/>
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addWord}></button>
</div>
</div>
)}
{/* Saved words from db_words_db_pictures */}
{pictureWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: pendingWords.length > 0 ? 8 : 0 }}>
{pictureWords.map(w => (
<div key={w.word_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '4px 8px', borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)',
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-1)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.titel_de}
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
</div>
))}
</div>
)}
{/* Pending new words (not yet saved) */}
{pendingWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{pendingWords.map((w, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.titel_de}
</span>
<input
type="number"
min={1} max={100}
value={w.level}
onChange={e => setPendingWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
style={{
width: 44, padding: '2px 4px', borderRadius: 'var(--r-sm)',
border: '1px solid color-mix(in srgb, var(--primary) 40%, transparent)',
background: 'var(--surface)', color: 'var(--primary)',
fontFamily: 'var(--font)', fontSize: 11, textAlign: 'center',
}}
/>
<button
onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 14, flexShrink: 0 }}
title="Entfernen"
>×</button>
</div>
))}
</div>
)}
{pictureWords.length === 0 && pendingWords.length === 0 && (
<div className="empty-state" style={{ fontSize: 12 }}>Noch keine Words</div>
)}
</div>
<div className="sidebar-panel">
<button
className="btn-primary btn-sm btn-block"
onClick={saveWords}
disabled={pendingWords.length === 0 || savingWords || !currentPicture}
>
{savingWords ? 'Speichere…' : `Save${pendingWords.length > 0 ? ` (${pendingWords.length})` : ''}`}
</button>
</div>
</aside>
</div>
</div>
)