feat: object words in right sidebar + design dropdown

- Words panel moved to right sidebar: shows selected object's words or pending words for new object
- Pending words auto-saved to object after creation
- Remove word chips from left sidebar object cards
- Design dropdown in left sidebar (above Fertigstellen), loads choices dynamically from Directus field metadata
- Include design field in db_pictures GET response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 18:26:38 +02:00
parent 40c36182f1
commit 17918a414b
4 changed files with 147 additions and 40 deletions

View File

@@ -330,6 +330,14 @@ export async function purgeAllOrphans(token: string): Promise<{ orphans_removed:
import type { DbPicture, DbObject, DbWord, DbPair } from './types'
export async function getDesignOptions(token: string): Promise<{ text: string; value: string; color?: string }[]> {
const res = await fetch('/api/directus/db-pictures/design-options', {
headers: { Authorization: token },
})
const data = await res.json()
return data.choices || []
}
export async function getDbPictures(token: string, status = 'draft'): Promise<DbPicture[]> {
const res = await fetch(`/api/directus/db-pictures?status=${status}`, {
headers: { Authorization: `Bearer ${token}` },

View File

@@ -13,6 +13,7 @@ import {
addDbObjectWord,
deleteDbObjectWord,
directusAssetUrl,
getDesignOptions,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types'
@@ -47,6 +48,9 @@ export default function DrawIt() {
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
// per-object word input values: objectId → current input text
const [wordInputs, setWordInputs] = useState<Record<string, string>>({})
const [pendingWords, setPendingWords] = useState<string[]>([])
const [newWordInput, setNewWordInput] = useState('')
const [designOptions, setDesignOptions] = useState<{ text: string; value: string }[]>([])
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
const [hasSelection, setHasSelection] = useState(false)
@@ -83,6 +87,12 @@ export default function DrawIt() {
.catch(console.error)
}, [token])
// Load design options once on mount
useEffect(() => {
if (!token) return
getDesignOptions(token).then(setDesignOptions).catch(console.error)
}, [token])
// Load objects when picture changes, then load each object's word
useEffect(() => {
if (!currentPicture || !token) {
@@ -132,6 +142,13 @@ export default function DrawIt() {
}
}
const handleAddPendingWord = () => {
const w = newWordInput.trim()
if (!w || pendingWords.includes(w)) return
setPendingWords(prev => [...prev, w])
setNewWordInput('')
}
const handleRemoveObjectWord = async (objId: string, junctionId: string | number) => {
if (!token) return
try {
@@ -162,10 +179,22 @@ export default function DrawIt() {
selections: currentSelections,
user_notes: userNotes.trim() || null,
}, token)
// Save pending words to the new object
const savedWords: DbWord[] = []
for (const w of pendingWords) {
try {
const result = await addDbObjectWord(obj.id, { titel_de: w, level: 50 }, token)
if (result.ok) {
savedWords.push({ junction_id: result.junction_id, word_id: result.word_id, titel_de: w, level: 50, status: 'draft' })
}
} catch {}
}
setObjects(prev => [...prev, { ...obj, visible: true }])
setObjectWords(prev => ({ ...prev, [obj.id]: [] }))
setObjectWords(prev => ({ ...prev, [obj.id]: savedWords }))
setCurrentSelections([])
setUserNotes('')
setPendingWords([])
setNewWordInput('')
canvasRef.current?.resetSelection()
showStatus('Objekt gespeichert.')
} catch (e) {
@@ -303,50 +332,43 @@ export default function DrawIt() {
</div>
)}
{/* Per-object multi-word chips */}
<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 }}>Wörter</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 4 }}>
{(objectWords[obj.id] || []).map(w => (
<span key={w.junction_id} style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 8px', background: '#e0e7ff', color: '#3730a3',
borderRadius: 9999, fontSize: 11,
}}>
{w.titel_de}
<button
onClick={() => handleRemoveObjectWord(obj.id, w.junction_id!)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#818cf8', padding: 0, lineHeight: 1, fontSize: 13 }}
title="Entfernen"
>×</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={wordInputs[obj.id] || ''}
onChange={e => setWordInputs(prev => ({ ...prev, [obj.id]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter') handleAddObjectWord(obj.id) }}
placeholder="Wort hinzufügen…"
style={{
flex: 1, padding: '3px 7px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
}}
/>
<button
onClick={() => handleAddObjectWord(obj.id)}
style={{ padding: '3px 8px', borderRadius: 'var(--r-sm)', background: '#6366f1', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12 }}
>+</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{currentPicture && designOptions.length > 0 && (
<div className="sidebar-panel">
<h3 className="sidebar-heading">Design</h3>
<select
value={currentPicture.design || ''}
onChange={async e => {
const value = e.target.value
// Optimistic update
setPictureList(prev => prev.map(p => p.id === currentPicture.id ? { ...p, design: value || null } : p))
try {
await fetch(`/api/directus/db-pictures/${currentPicture.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: token ?? '' },
body: JSON.stringify({ design: value || null }),
})
} catch (e) { console.error(e) }
}}
style={{
width: '100%', padding: '6px 8px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 13, cursor: 'pointer',
}}
>
<option value=""> kein Design </option>
{designOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.text}</option>
))}
</select>
</div>
)}
{objects.length > 0 && (
<div className="sidebar-panel">
<button
@@ -477,6 +499,72 @@ export default function DrawIt() {
</div>
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Wörter
{selectedObjectId
? ` (Objekt ${objects.findIndex(o => o.id === selectedObjectId) + 1})`
: ' (neues Objekt)'}
</h3>
{/* Existing word chips for selected object */}
{selectedObjectId && (
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
{(objectWords[selectedObjectId] || []).map(w => (
<span key={w.junction_id} style={{
display:'flex', alignItems:'center', gap:3,
padding:'2px 8px', background:'#e0e7ff', color:'#3730a3',
borderRadius:9999, fontSize:11,
}}>
{w.titel_de}
<button onClick={() => handleRemoveObjectWord(selectedObjectId, w.junction_id!)}
style={{ background:'none', border:'none', cursor:'pointer', color:'#818cf8', padding:0, fontSize:13 }}>×</button>
</span>
))}
{(objectWords[selectedObjectId] || []).length === 0 && (
<span style={{ fontSize:11, color:'var(--text-2)' }}>Noch keine Wörter</span>
)}
</div>
)}
{/* Pending words for new object */}
{!selectedObjectId && pendingWords.length > 0 && (
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
{pendingWords.map((w, i) => (
<span key={i} style={{
display:'flex', alignItems:'center', gap:3,
padding:'2px 8px', background:'#fef3c7', color:'#92400e',
borderRadius:9999, fontSize:11,
}}>
{w}
<button onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
style={{ background:'none', border:'none', cursor:'pointer', color:'#d97706', padding:0, fontSize:13 }}>×</button>
</span>
))}
</div>
)}
{/* Add word input */}
<div style={{ display:'flex', gap:4 }}>
<input
type="text"
value={selectedObjectId ? (wordInputs[selectedObjectId] || '') : newWordInput}
onChange={e => selectedObjectId
? setWordInputs(prev => ({ ...prev, [selectedObjectId]: e.target.value }))
: setNewWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()
}}
placeholder="Wort hinzufügen…"
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 }}
/>
<button
onClick={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
style={{ padding:'4px 10px', borderRadius:'var(--r-sm)', background:'#6366f1', color:'#fff', border:'none', cursor:'pointer', fontSize:12 }}
>+</button>
</div>
</div>
</aside>
</div>
</div>

View File

@@ -61,6 +61,7 @@ export interface DbPicture {
picture: string // UUID → directus_files (für asset URL)
blurhash: string | null
status: string
design: string | null
}
export interface DbObject {