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:
27
app.py
27
app.py
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user