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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user