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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user