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:
58
app.py
58
app.py
@@ -1895,31 +1895,45 @@ def directus_db_object_pairs(obj_id):
|
||||
return jsonify({"ok": True, "pair_id": pair_id, "statement_id": stmt_id, "question_id": q_id})
|
||||
|
||||
|
||||
@app.route("/api/directus/db-objects/<obj_id>/words", methods=["GET"])
|
||||
@app.route("/api/directus/db-objects/<obj_id>/words", methods=["GET", "POST"])
|
||||
def directus_db_object_words(obj_id):
|
||||
"""Gibt alle db_words zurück, die via db_objects_db_words mit dem Objekt verknüpft sind."""
|
||||
token = request.headers.get("Authorization", "")
|
||||
data, s = _directus(
|
||||
"GET",
|
||||
f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}"
|
||||
f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500",
|
||||
token,
|
||||
)
|
||||
if s != 200:
|
||||
return jsonify({"data": []})
|
||||
items = []
|
||||
for entry in (data.get("data") or []):
|
||||
word = entry.get("db_words_id") or {}
|
||||
if not isinstance(word, dict) or not word.get("id"):
|
||||
continue
|
||||
if word.get("status") == "archived":
|
||||
continue
|
||||
items.append({
|
||||
"word_id": word["id"],
|
||||
"titel_de": word.get("titel_de", ""),
|
||||
"level": word.get("level") or 50,
|
||||
})
|
||||
return jsonify({"data": items})
|
||||
if request.method == "GET":
|
||||
data, s = _directus(
|
||||
"GET",
|
||||
f"/items/db_objects_db_words?filter[db_objects_id][_eq]={obj_id}"
|
||||
f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500",
|
||||
token,
|
||||
)
|
||||
if s != 200:
|
||||
return jsonify({"data": []})
|
||||
items = []
|
||||
for entry in (data.get("data") or []):
|
||||
word = entry.get("db_words_id") or {}
|
||||
if not isinstance(word, dict) or not word.get("id"):
|
||||
continue
|
||||
if word.get("status") == "archived":
|
||||
continue
|
||||
items.append({
|
||||
"word_id": word["id"],
|
||||
"titel_de": word.get("titel_de", ""),
|
||||
"level": word.get("level") or 50,
|
||||
})
|
||||
return jsonify({"data": items})
|
||||
else: # POST — replace with single word
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
titel_de = (body.get("titel_de") or "").strip()
|
||||
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:
|
||||
return jsonify({"ok": True, "cleared": True})
|
||||
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})
|
||||
return jsonify({"ok": True, "word_id": wid})
|
||||
|
||||
|
||||
@app.route("/api/directus/db-pairs/<pair_id>", methods=["PATCH", "DELETE"])
|
||||
|
||||
@@ -463,6 +463,19 @@ export async function getDbObjectWords(objectId: string, token: string): Promise
|
||||
return data.data as DbWord[]
|
||||
}
|
||||
|
||||
export async function saveDbObjectWord(
|
||||
objectId: string,
|
||||
word: { titel_de: string; level: number } | null,
|
||||
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: '' }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function updateDbPair(
|
||||
pairId: string,
|
||||
payload: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
getDbPictures,
|
||||
getDbObjects,
|
||||
getDbObjectPairs,
|
||||
getDbPictureWords,
|
||||
getDbObjectWords,
|
||||
createDbPair,
|
||||
updateDbPair,
|
||||
deleteDbPair,
|
||||
directusAssetUrl,
|
||||
} from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { DbPicture, DbObject, DbPair, DbPairWordEntry, CanvasObject } from '../types'
|
||||
import type { DbPicture, DbObject, 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">
|
||||
@@ -28,37 +28,45 @@ const ChevronRightIcon = () => (
|
||||
|
||||
// ── PairForm ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PendingWord {
|
||||
titel_de: string
|
||||
level: number
|
||||
link_to: 'question' | 'statement' | 'both'
|
||||
}
|
||||
|
||||
interface PairFormProps {
|
||||
objectId: string
|
||||
token: string
|
||||
suggestions: { titel_de: string; level: number }[]
|
||||
objectChips: ObjectChip[]
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormProps) {
|
||||
function PairForm({ objectId, token, objectChips, onSaved, onCancel }: PairFormProps) {
|
||||
const [level, setLevel] = useState(50)
|
||||
const [questionDe, setQuestionDe] = useState('')
|
||||
const [statementDe, setStatementDe] = useState('')
|
||||
const [words, setWords] = useState<PendingWord[]>([])
|
||||
const [wordInput, setWordInput] = useState('')
|
||||
const [wordLevel, setWordLevel] = useState(50)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const wordInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const addWord = (titel_de?: string, lvl?: number, defaultLinkTo: 'question' | 'statement' | 'both' = 'both') => {
|
||||
const t = (titel_de ?? wordInput).trim()
|
||||
const l = lvl ?? wordLevel
|
||||
if (!t || words.some(w => w.titel_de === t)) { if (!titel_de) setWordInput(''); return }
|
||||
setWords(prev => [...prev, { titel_de: t, level: l, link_to: defaultLinkTo }])
|
||||
if (!titel_de) { setWordInput(''); setWordLevel(50); wordInputRef.current?.focus() }
|
||||
const questionRef = useRef<HTMLTextAreaElement>(null)
|
||||
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 insertAtCursor = (objectId: string, _label: 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 newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end)
|
||||
if (lastFocusedRef.current === 'question') setQuestionDe(newVal)
|
||||
else setStatementDe(newVal)
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(start + placeholder.length, start + placeholder.length)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -70,7 +78,7 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP
|
||||
question_de: questionDe.trim() || undefined,
|
||||
statement_de: statementDe.trim(),
|
||||
level,
|
||||
words,
|
||||
words: [],
|
||||
}, token)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
@@ -94,14 +102,34 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Question (optional) */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||||
Frage (optional)
|
||||
</label>
|
||||
<textarea
|
||||
ref={questionRef}
|
||||
value={questionDe}
|
||||
onChange={e => setQuestionDe(e.target.value)}
|
||||
onFocus={() => { lastFocusedRef.current = 'question' }}
|
||||
rows={2}
|
||||
placeholder="question_de…"
|
||||
style={{
|
||||
@@ -111,6 +139,11 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP
|
||||
fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{questionDe && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2, fontStyle: 'italic' }}>
|
||||
{resolveTemplate(questionDe)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statement (required) */}
|
||||
@@ -119,8 +152,10 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP
|
||||
Aussage <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
ref={statementRef}
|
||||
value={statementDe}
|
||||
onChange={e => setStatementDe(e.target.value)}
|
||||
onFocus={() => { lastFocusedRef.current = 'statement' }}
|
||||
rows={2}
|
||||
placeholder="statement_de…"
|
||||
style={{
|
||||
@@ -130,98 +165,10 @@ function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormP
|
||||
fontFamily: 'var(--font)', fontSize: 12, boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Words input */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||||
Wörter
|
||||
</label>
|
||||
|
||||
{/* Vorschläge aus dem Bild */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
||||
{suggestions.map((s, idx) => {
|
||||
const already = words.some(w => w.titel_de === s.titel_de)
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => addWord(s.titel_de, s.level)}
|
||||
disabled={already}
|
||||
style={{
|
||||
padding: '2px 7px', borderRadius: 'var(--r-full)', fontSize: 11,
|
||||
border: '1px solid var(--border)', cursor: already ? 'default' : 'pointer',
|
||||
background: already ? 'var(--surface-2)' : 'var(--surface)',
|
||||
color: already ? 'var(--text-2)' : 'var(--text-1)',
|
||||
textDecoration: already ? 'line-through' : 'none',
|
||||
opacity: already ? 0.5 : 1,
|
||||
}}
|
||||
title={`L${s.level} — klicken zum Hinzufügen`}
|
||||
>
|
||||
{s.titel_de}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input
|
||||
ref={wordInputRef}
|
||||
value={wordInput}
|
||||
onChange={e => setWordInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addWord() }}
|
||||
placeholder="Wort…"
|
||||
style={{
|
||||
flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number" min={1} max={100} value={wordLevel}
|
||||
onChange={e => setWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||||
style={{
|
||||
width: 50, padding: '4px 4px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12, textAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
<button className="btn-ghost btn-sm" onClick={() => addWord()}>+</button>
|
||||
</div>
|
||||
{/* Word chips */}
|
||||
{words.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{words.map((w, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 4px 2px 7px', borderRadius: 'var(--r-full)',
|
||||
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
fontSize: 11, color: 'var(--primary)',
|
||||
}}>
|
||||
{w.titel_de}
|
||||
<span style={{ color: 'var(--text-2)', fontSize: 10 }}>L{w.level}</span>
|
||||
{/* Link-to Toggle */}
|
||||
{(['question', 'statement', 'both'] as const).map(lt => (
|
||||
<button
|
||||
key={lt}
|
||||
onClick={() => setWords(prev => prev.map((x, j) => j === i ? { ...x, link_to: lt } : x))}
|
||||
style={{
|
||||
padding: '0 3px', borderRadius: 2, fontSize: 9, border: '1px solid currentColor',
|
||||
cursor: 'pointer', lineHeight: 1.4,
|
||||
background: w.link_to === lt ? 'var(--primary)' : 'transparent',
|
||||
color: w.link_to === lt ? '#fff' : 'var(--primary)',
|
||||
}}
|
||||
title={lt === 'question' ? 'Nur Frage' : lt === 'statement' ? 'Nur Aussage' : 'Beide'}
|
||||
>{lt === 'question' ? 'F' : lt === 'statement' ? 'A' : 'B'}</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setWords(prev => prev.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 12 }}
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{statementDe && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 2, fontStyle: 'italic' }}>
|
||||
{resolveTemplate(statementDe)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -249,11 +196,11 @@ interface PairsListProps {
|
||||
loading: boolean
|
||||
objectId: string | null
|
||||
token: string
|
||||
suggestions: { titel_de: string; level: number }[]
|
||||
objectChips: ObjectChip[]
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }: PairsListProps) {
|
||||
function PairsList({ pairs, loading, objectId, token, objectChips, onRefresh }: PairsListProps) {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editLevel, setEditLevel] = useState(50)
|
||||
@@ -261,40 +208,37 @@ function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }:
|
||||
const [editStatement, setEditStatement] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Word-edit state
|
||||
const [editWords, setEditWords] = useState<(DbPairWordEntry & { link_to: 'question' | 'statement' | 'both' })[]>([])
|
||||
const [editWordInput, setEditWordInput] = useState('')
|
||||
const [editWordLevel, setEditWordLevel] = useState(50)
|
||||
const [editWordLinkTo, setEditWordLinkTo] = useState<'question' | 'statement' | 'both'>('both')
|
||||
const [wordsToRemove, setWordsToRemove] = useState<{ junction_id: number; link_to: 'question' | 'statement' | 'both' }[]>([])
|
||||
const [newWords, setNewWords] = useState<PendingWord[]>([])
|
||||
const editQuestionRef = useRef<HTMLTextAreaElement>(null)
|
||||
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 insertAtCursor = (objectId: string, _label: 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 newVal = ta.value.slice(0, start) + placeholder + ta.value.slice(end)
|
||||
if (editLastFocusedRef.current === 'question') setEditQuestion(newVal)
|
||||
else setEditStatement(newVal)
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(start + placeholder.length, start + placeholder.length)
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (pair: DbPair) => {
|
||||
setEditingId(pair.id)
|
||||
setEditLevel(pair.level)
|
||||
setEditStatement(pair.statements[0]?.statement_de ?? '')
|
||||
setEditQuestion(pair.questions[0]?.question_de ?? '')
|
||||
// Wörter aus Question und Statement kombinieren (dedupliziert nach word_id)
|
||||
const qWords = (pair.questions[0]?.words ?? []).map(w => ({ ...w, link_to: 'question' as const }))
|
||||
const sWords = (pair.statements[0]?.words ?? []).map(w => ({ ...w, link_to: 'statement' as const }))
|
||||
// Kombiniere: wenn Wort in beiden → link_to = 'both'
|
||||
const combined: (DbPairWordEntry & { link_to: 'question' | 'statement' | 'both' })[] = []
|
||||
const seen = new Map<string, number>()
|
||||
for (const w of [...qWords, ...sWords]) {
|
||||
const idx = seen.get(w.word_id)
|
||||
if (idx !== undefined) {
|
||||
combined[idx] = { ...combined[idx], link_to: 'both' }
|
||||
} else {
|
||||
seen.set(w.word_id, combined.length)
|
||||
combined.push({ ...w })
|
||||
}
|
||||
}
|
||||
setEditWords(combined)
|
||||
setWordsToRemove([])
|
||||
setNewWords([])
|
||||
setEditWordInput('')
|
||||
setEditWordLevel(50)
|
||||
setEditWordLinkTo('both')
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
@@ -305,8 +249,6 @@ function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }:
|
||||
level: editLevel,
|
||||
statement_de: editStatement,
|
||||
question_de: editQuestion,
|
||||
words_add: newWords,
|
||||
words_remove: wordsToRemove,
|
||||
}, token)
|
||||
setEditingId(null)
|
||||
onRefresh()
|
||||
@@ -346,106 +288,53 @@ function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }:
|
||||
onChange={e => setEditLevel(Number(e.target.value))}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={editStatementRef}
|
||||
value={editStatement}
|
||||
onChange={e => setEditStatement(e.target.value)}
|
||||
onFocus={() => { editLastFocusedRef.current = 'statement' }}
|
||||
rows={2}
|
||||
placeholder="Aussage (Pflicht)…"
|
||||
style={{ width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }}
|
||||
/>
|
||||
{editStatement && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: -4, fontStyle: 'italic' }}>
|
||||
{resolveTemplate(editStatement)}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
ref={editQuestionRef}
|
||||
value={editQuestion}
|
||||
onChange={e => setEditQuestion(e.target.value)}
|
||||
onFocus={() => { editLastFocusedRef.current = 'question' }}
|
||||
rows={2}
|
||||
placeholder="Frage (optional)…"
|
||||
style={{ width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }}
|
||||
/>
|
||||
|
||||
{/* Wörter im Edit-Modus */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>Wörter</label>
|
||||
{/* Vorhandene Wörter */}
|
||||
{editWords.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
||||
{editWords.map((w) => (
|
||||
<span key={w.junction_id} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 4px 2px 7px', borderRadius: 'var(--r-full)',
|
||||
background: wordsToRemove.some(r => r.junction_id === w.junction_id) ? 'var(--surface-2)' : 'var(--primary-muted)',
|
||||
border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
fontSize: 11, color: wordsToRemove.some(r => r.junction_id === w.junction_id) ? 'var(--text-2)' : 'var(--primary)',
|
||||
textDecoration: wordsToRemove.some(r => r.junction_id === w.junction_id) ? 'line-through' : 'none',
|
||||
opacity: wordsToRemove.some(r => r.junction_id === w.junction_id) ? 0.5 : 1,
|
||||
}}>
|
||||
{w.titel_de} <span style={{ fontSize: 10, color: 'var(--text-2)' }}>L{w.level}</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-2)', marginLeft: 2 }}>
|
||||
{w.link_to === 'question' ? 'F' : w.link_to === 'statement' ? 'A' : 'B'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (wordsToRemove.some(r => r.junction_id === w.junction_id)) {
|
||||
setWordsToRemove(prev => prev.filter(r => r.junction_id !== w.junction_id))
|
||||
} else {
|
||||
setWordsToRemove(prev => [...prev, { junction_id: w.junction_id, link_to: w.link_to }])
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 12 }}
|
||||
title={wordsToRemove.some(r => r.junction_id === w.junction_id) ? 'Wiederherstellen' : 'Entfernen'}
|
||||
>{wordsToRemove.some(r => r.junction_id === w.junction_id) ? '↩' : '×'}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Neues Wort hinzufügen */}
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
value={editWordInput}
|
||||
onChange={e => setEditWordInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const t = editWordInput.trim()
|
||||
if (t) { setNewWords(prev => [...prev, { titel_de: t, level: editWordLevel, link_to: editWordLinkTo }]); setEditWordInput('') }
|
||||
}
|
||||
}}
|
||||
placeholder="Wort…"
|
||||
style={{ flex: 1, minWidth: 80, padding: '4px 7px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12 }}
|
||||
/>
|
||||
<input
|
||||
type="number" min={1} max={100} value={editWordLevel}
|
||||
onChange={e => setEditWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||||
style={{ width: 44, padding: '4px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 12, textAlign: 'center' }}
|
||||
/>
|
||||
<select
|
||||
value={editWordLinkTo}
|
||||
onChange={e => setEditWordLinkTo(e.target.value as 'question' | 'statement' | 'both')}
|
||||
style={{ padding: '4px 4px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontSize: 11 }}
|
||||
>
|
||||
<option value="both">Beide</option>
|
||||
<option value="question">Frage</option>
|
||||
<option value="statement">Aussage</option>
|
||||
</select>
|
||||
<button className="btn-ghost btn-sm" onClick={() => {
|
||||
const t = editWordInput.trim()
|
||||
if (t) { setNewWords(prev => [...prev, { titel_de: t, level: editWordLevel, link_to: editWordLinkTo }]); setEditWordInput('') }
|
||||
}}>+</button>
|
||||
</div>
|
||||
{newWords.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
|
||||
{newWords.map((w, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 4px 2px 7px', borderRadius: 'var(--r-full)',
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
fontSize: 11, color: 'var(--text-1)',
|
||||
}}>
|
||||
{w.titel_de} <span style={{ fontSize: 10, color: 'var(--text-2)' }}>L{w.level} {w.link_to === 'question' ? 'F' : w.link_to === 'statement' ? 'A' : 'B'}</span>
|
||||
<button onClick={() => setNewWords(prev => prev.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 12, lineHeight: 1 }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editQuestion && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-2)', marginTop: -4, fontStyle: 'italic' }}>
|
||||
{resolveTemplate(editQuestion)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="btn-primary btn-sm" style={{ flex: 1 }} onClick={saveEdit} disabled={saving || !editStatement.trim()}>
|
||||
@@ -473,12 +362,12 @@ function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }:
|
||||
</div>
|
||||
{pair.statements.map(s => (
|
||||
<div key={s.id} style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 3, paddingLeft: 4 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Aussage:</span>{s.statement_de}
|
||||
<span style={{ fontSize: 10, color: 'var(--text-2)', marginRight: 4 }}>Aussage:</span>{resolveTemplate(s.statement_de)}
|
||||
</div>
|
||||
))}
|
||||
{pair.questions.map(q => (
|
||||
<div key={q.id} style={{ fontSize: 12, color: 'var(--text-2)', fontStyle: 'italic', paddingLeft: 4 }}>
|
||||
<span style={{ fontSize: 10, marginRight: 4, fontStyle: 'normal' }}>Frage:</span>{q.question_de}
|
||||
<span style={{ fontSize: 10, marginRight: 4, fontStyle: 'normal' }}>Frage:</span>{resolveTemplate(q.question_de)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -497,7 +386,7 @@ function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }:
|
||||
<PairForm
|
||||
objectId={objectId}
|
||||
token={token}
|
||||
suggestions={suggestions}
|
||||
objectChips={objectChips}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
@@ -520,7 +409,8 @@ export default function GenerateIt() {
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [pairs, setPairs] = useState<DbPair[]>([])
|
||||
const [pairsLoading, setPairsLoading] = useState(false)
|
||||
const [pictureWords, setPictureWords] = useState<{ titel_de: string; level: number }[]>([])
|
||||
// per-object labels: objectId → titel_de
|
||||
const [objectLabels, setObjectLabels] = useState<Record<string, string>>({})
|
||||
|
||||
const currentPicture: DbPicture | null =
|
||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||
@@ -533,6 +423,13 @@ 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,
|
||||
}))
|
||||
|
||||
// Load db_pictures with status=objects_created
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
@@ -541,28 +438,29 @@ export default function GenerateIt() {
|
||||
.catch(console.error)
|
||||
}, [token])
|
||||
|
||||
// Load db_objects when picture changes
|
||||
// Load db_objects when picture changes, then load each object's word
|
||||
useEffect(() => {
|
||||
if (!currentPicture || !token) {
|
||||
setDbObjects([]); setSelectedObjId(null); setPairs([]); setImageLoaded(false)
|
||||
setObjectLabels({})
|
||||
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> = {}
|
||||
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))
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [currentPicture?.id, token])
|
||||
|
||||
// Load picture words when picture changes
|
||||
useEffect(() => {
|
||||
if (!currentPicture || !token) { setPictureWords([]); return }
|
||||
getDbPictureWords(currentPicture.id, token)
|
||||
.then(words => setPictureWords(words.map(w => ({ titel_de: w.titel_de, level: w.level }))))
|
||||
.catch(console.error)
|
||||
}, [currentPicture?.id, token])
|
||||
|
||||
// Load pairs when selected object changes
|
||||
useEffect(() => {
|
||||
if (!selectedObjId || !token) { setPairs([]); return }
|
||||
@@ -629,6 +527,9 @@ 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>
|
||||
)}
|
||||
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,29 +543,6 @@ export default function GenerateIt() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wörter des aktuellen Bildes */}
|
||||
{pictureWords.length > 0 && (
|
||||
<div className="sidebar-panel">
|
||||
<h3 className="sidebar-heading">
|
||||
Bild-Wörter
|
||||
<span className="badge">{pictureWords.length}</span>
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{pictureWords.map((w, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 'var(--r-full)',
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
fontSize: 11, color: 'var(--text-1)',
|
||||
}}>
|
||||
{w.titel_de}
|
||||
<span style={{ fontSize: 10, color: 'var(--text-2)' }}>L{w.level}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
@@ -712,7 +590,7 @@ export default function GenerateIt() {
|
||||
loading={pairsLoading}
|
||||
objectId={selectedObjId}
|
||||
token={token ?? ''}
|
||||
suggestions={pictureWords}
|
||||
objectChips={objectChips}
|
||||
onRefresh={refreshPairs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface DbPair {
|
||||
statements: DbPairStatement[]
|
||||
}
|
||||
|
||||
export interface ObjectChip { objectId: string; label: string; index: number }
|
||||
|
||||
// Legacy — still used by GenerateIt
|
||||
export interface ObjectMeta {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user