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})
|
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"])
|
@app.route("/api/directus/db-pairs/<pair_id>", methods=["PATCH", "DELETE"])
|
||||||
def directus_db_pair(pair_id):
|
def directus_db_pair(pair_id):
|
||||||
"""PATCH: level + question/statement inline aktualisieren.
|
"""PATCH: level + question/statement inline aktualisieren.
|
||||||
|
|||||||
@@ -454,6 +454,15 @@ export async function createDbPair(
|
|||||||
return data
|
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(
|
export async function updateDbPair(
|
||||||
pairId: string,
|
pairId: string,
|
||||||
payload: { level?: number; question_de?: string; statement_de?: string },
|
payload: { level?: number; question_de?: string; statement_de?: string },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getDbPictures,
|
getDbPictures,
|
||||||
getDbObjects,
|
getDbObjects,
|
||||||
getDbObjectPairs,
|
getDbObjectPairs,
|
||||||
|
getDbObjectWords,
|
||||||
createDbPair,
|
createDbPair,
|
||||||
updateDbPair,
|
updateDbPair,
|
||||||
deleteDbPair,
|
deleteDbPair,
|
||||||
@@ -32,14 +33,17 @@ interface PendingWord {
|
|||||||
level: number
|
level: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { DbWord } from '../types'
|
||||||
|
|
||||||
interface PairFormProps {
|
interface PairFormProps {
|
||||||
objectId: string
|
objectId: string
|
||||||
token: string
|
token: string
|
||||||
|
suggestions: DbWord[]
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
|
function PairForm({ objectId, token, suggestions, onSaved, onCancel }: PairFormProps) {
|
||||||
const [level, setLevel] = useState(50)
|
const [level, setLevel] = useState(50)
|
||||||
const [questionDe, setQuestionDe] = useState('')
|
const [questionDe, setQuestionDe] = useState('')
|
||||||
const [statementDe, setStatementDe] = useState('')
|
const [statementDe, setStatementDe] = useState('')
|
||||||
@@ -50,13 +54,12 @@ function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const wordInputRef = useRef<HTMLInputElement>(null)
|
const wordInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const addWord = () => {
|
const addWord = (titel_de?: string, lvl?: number) => {
|
||||||
const t = wordInput.trim()
|
const t = (titel_de ?? wordInput).trim()
|
||||||
if (!t || words.some(w => w.titel_de === t)) { setWordInput(''); return }
|
const l = lvl ?? wordLevel
|
||||||
setWords(prev => [...prev, { titel_de: t, level: wordLevel }])
|
if (!t || words.some(w => w.titel_de === t)) { if (!titel_de) setWordInput(''); return }
|
||||||
setWordInput('')
|
setWords(prev => [...prev, { titel_de: t, level: l }])
|
||||||
setWordLevel(50)
|
if (!titel_de) { setWordInput(''); setWordLevel(50); wordInputRef.current?.focus() }
|
||||||
wordInputRef.current?.focus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
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 }}>
|
<label style={{ fontSize: 11, color: 'var(--text-2)', display: 'block', marginBottom: 4 }}>
|
||||||
Wörter (werden an Frage + Aussage verknüpft)
|
Wörter (werden an Frage + Aussage verknüpft)
|
||||||
</label>
|
</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 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||||
<input
|
<input
|
||||||
ref={wordInputRef}
|
ref={wordInputRef}
|
||||||
value={wordInput}
|
value={wordInput}
|
||||||
onChange={e => setWordInput(e.target.value)}
|
onChange={e => setWordInput(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') addWord() }}
|
onKeyDown={e => { if (e.key === 'Enter') addWord() }}
|
||||||
placeholder="titel_de…"
|
placeholder="Wort…"
|
||||||
style={{
|
style={{
|
||||||
flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
||||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
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',
|
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>
|
</div>
|
||||||
{/* Word chips */}
|
{/* Word chips */}
|
||||||
{words.length > 0 && (
|
{words.length > 0 && (
|
||||||
@@ -204,10 +235,11 @@ interface PairsListProps {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
objectId: string | null
|
objectId: string | null
|
||||||
token: string
|
token: string
|
||||||
|
suggestions: DbWord[]
|
||||||
onRefresh: () => void
|
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 [showForm, setShowForm] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [editLevel, setEditLevel] = useState(50)
|
const [editLevel, setEditLevel] = useState(50)
|
||||||
@@ -333,6 +365,7 @@ function PairsList({ pairs, loading, objectId, token, onRefresh }: PairsListProp
|
|||||||
<PairForm
|
<PairForm
|
||||||
objectId={objectId}
|
objectId={objectId}
|
||||||
token={token}
|
token={token}
|
||||||
|
suggestions={suggestions}
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onCancel={() => setShowForm(false)}
|
onCancel={() => setShowForm(false)}
|
||||||
/>
|
/>
|
||||||
@@ -355,6 +388,7 @@ export default function GenerateIt() {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false)
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
const [pairs, setPairs] = useState<DbPair[]>([])
|
const [pairs, setPairs] = useState<DbPair[]>([])
|
||||||
const [pairsLoading, setPairsLoading] = useState(false)
|
const [pairsLoading, setPairsLoading] = useState(false)
|
||||||
|
const [objectWords, setObjectWords] = useState<DbWord[]>([])
|
||||||
|
|
||||||
const currentPicture: DbPicture | null =
|
const currentPicture: DbPicture | null =
|
||||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||||
@@ -389,14 +423,17 @@ export default function GenerateIt() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [currentPicture?.id, token])
|
}, [currentPicture?.id, token])
|
||||||
|
|
||||||
// Load pairs when selected object changes
|
// Load pairs + words when selected object changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedObjId || !token) { setPairs([]); return }
|
if (!selectedObjId || !token) { setPairs([]); setObjectWords([]); return }
|
||||||
setPairsLoading(true)
|
setPairsLoading(true)
|
||||||
getDbObjectPairs(selectedObjId, token)
|
getDbObjectPairs(selectedObjId, token)
|
||||||
.then(setPairs)
|
.then(setPairs)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setPairsLoading(false))
|
.finally(() => setPairsLoading(false))
|
||||||
|
getDbObjectWords(selectedObjId, token)
|
||||||
|
.then(setObjectWords)
|
||||||
|
.catch(console.error)
|
||||||
}, [selectedObjId, token])
|
}, [selectedObjId, token])
|
||||||
|
|
||||||
const refreshPairs = () => {
|
const refreshPairs = () => {
|
||||||
@@ -468,6 +505,29 @@ export default function GenerateIt() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</aside>
|
||||||
|
|
||||||
{/* Center: Canvas */}
|
{/* Center: Canvas */}
|
||||||
@@ -515,6 +575,7 @@ export default function GenerateIt() {
|
|||||||
loading={pairsLoading}
|
loading={pairsLoading}
|
||||||
objectId={selectedObjId}
|
objectId={selectedObjId}
|
||||||
token={token ?? ''}
|
token={token ?? ''}
|
||||||
|
suggestions={objectWords}
|
||||||
onRefresh={refreshPairs}
|
onRefresh={refreshPairs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user