feat: object words in left sidebar + suggestions in pair form

- Backend: GET /api/directus/db-objects/<id>/words via db_objects_db_words
- GenerateIt: load objectWords on object select, show as chips in left sidebar
- PairForm: show object words as clickable suggestion chips above word input
  (click to add, greyed out if already added)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:07:42 +02:00
parent 8bcb3b9168
commit 2e6cf094cb
3 changed files with 110 additions and 13 deletions

27
app.py
View File

@@ -1877,6 +1877,33 @@ 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"])
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})
@app.route("/api/directus/db-pairs/<pair_id>", methods=["PATCH", "DELETE"])
def directus_db_pair(pair_id):
"""PATCH: level + question/statement inline aktualisieren.

View File

@@ -454,6 +454,15 @@ export async function createDbPair(
return data
}
export async function getDbObjectWords(objectId: string, token: string): Promise<DbWord[]> {
const res = await fetch(`/api/directus/db-objects/${objectId}/words`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error('Fehler beim Laden der Objekt-Wörter')
return data.data as DbWord[]
}
export async function updateDbPair(
pairId: string,
payload: { level?: number; question_de?: string; statement_de?: string },

View File

@@ -6,6 +6,7 @@ import {
getDbPictures,
getDbObjects,
getDbObjectPairs,
getDbObjectWords,
createDbPair,
updateDbPair,
deleteDbPair,
@@ -32,14 +33,17 @@ interface PendingWord {
level: number
}
import type { DbWord } from '../types'
interface PairFormProps {
objectId: string
token: string
suggestions: DbWord[]
onSaved: () => void
onCancel: () => void
}
function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormProps) {
const [level, setLevel] = useState(50)
const [questionDe, setQuestionDe] = useState('')
const [statementDe, setStatementDe] = useState('')
@@ -50,13 +54,12 @@ function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
const [error, setError] = useState('')
const wordInputRef = useRef<HTMLInputElement>(null)
const addWord = () => {
const t = wordInput.trim()
if (!t || words.some(w => w.titel_de === t)) { setWordInput(''); return }
setWords(prev => [...prev, { titel_de: t, level: wordLevel }])
setWordInput('')
setWordLevel(50)
wordInputRef.current?.focus()
const addWord = (titel_de?: string, lvl?: number) => {
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 }])
if (!titel_de) { setWordInput(''); setWordLevel(50); wordInputRef.current?.focus() }
}
const handleSave = async () => {
@@ -135,13 +138,41 @@ function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
Wörter (werden an Frage + Aussage verknüpft)
</label>
{/* Vorschläge aus dem Objekt */}
{suggestions.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
{suggestions.map(s => {
const already = words.some(w => w.titel_de === s.titel_de)
return (
<button
key={s.word_id}
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="titel_de…"
placeholder="Wort…"
style={{
flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
@@ -157,7 +188,7 @@ function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12, textAlign: 'center',
}}
/>
<button className="btn-ghost btn-sm" onClick={addWord}>+</button>
<button className="btn-ghost btn-sm" onClick={() => addWord()}>+</button>
</div>
{/* Word chips */}
{words.length > 0 && (
@@ -204,10 +235,11 @@ interface PairsListProps {
loading: boolean
objectId: string | null
token: string
suggestions: DbWord[]
onRefresh: () => void
}
function PairsList({ pairs, loading, objectId, token, onRefresh }: PairsListProps) {
function PairsList({ pairs, loading, objectId, token, suggestions, onRefresh }: PairsListProps) {
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editLevel, setEditLevel] = useState(50)
@@ -333,6 +365,7 @@ function PairsList({ pairs, loading, objectId, token, onRefresh }: PairsListProp
<PairForm
objectId={objectId}
token={token}
suggestions={suggestions}
onSaved={handleSaved}
onCancel={() => setShowForm(false)}
/>
@@ -355,6 +388,7 @@ export default function GenerateIt() {
const [imageLoaded, setImageLoaded] = useState(false)
const [pairs, setPairs] = useState<DbPair[]>([])
const [pairsLoading, setPairsLoading] = useState(false)
const [objectWords, setObjectWords] = useState<DbWord[]>([])
const currentPicture: DbPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
@@ -389,14 +423,17 @@ export default function GenerateIt() {
.catch(console.error)
}, [currentPicture?.id, token])
// Load pairs when selected object changes
// Load pairs + words when selected object changes
useEffect(() => {
if (!selectedObjId || !token) { setPairs([]); return }
if (!selectedObjId || !token) { setPairs([]); setObjectWords([]); return }
setPairsLoading(true)
getDbObjectPairs(selectedObjId, token)
.then(setPairs)
.catch(console.error)
.finally(() => setPairsLoading(false))
getDbObjectWords(selectedObjId, token)
.then(setObjectWords)
.catch(console.error)
}, [selectedObjId, token])
const refreshPairs = () => {
@@ -468,6 +505,29 @@ export default function GenerateIt() {
</div>
)}
</div>
{/* Wörter des gewählten Objekts */}
{objectWords.length > 0 && (
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Wörter
<span className="badge">{objectWords.length}</span>
</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{objectWords.map(w => (
<span key={w.word_id} 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 */}
@@ -515,6 +575,7 @@ export default function GenerateIt() {
loading={pairsLoading}
objectId={selectedObjId}
token={token ?? ''}
suggestions={objectWords}
onRefresh={refreshPairs}
/>
</div>