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

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>