refactor: migrate to new db_* Directus collections

- DrawIt: load db_pictures (status=draft), create db_objects/db_words
  with blurhash placeholder, finish sets status=objects_created
- GenerateIt: load db_pictures (status=objects_created), right panel
  replaced with manual QA pairs (db_pairs + db_question + db_statement)
- Backend: new routes for db_pictures, db_objects, db_words, db_pairs
- Types/API: full db_* type definitions and API helpers
- Directus: user_notes field in db_objects, M2M db_words<->db_pictures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 08:03:23 +02:00
parent 5b99bef765
commit 7c983a7460
5 changed files with 811 additions and 564 deletions

View File

@@ -2,12 +2,18 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import Topbar from '../components/Topbar'
import {
getDirectusPictures, directusAssetUrl, type DirectusPicture,
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
updatePictureStatus, getPictureWords, savePictureWords,
getDbPictures,
updateDbPictureStatus,
getDbObjects,
createDbObject,
updateDbObject,
deleteDbObject,
getDbPictureWords,
saveDbPictureWords,
directusAssetUrl,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, Selection, CanvasObject, PictureWord } from '../types'
import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types'
const ChevronLeftIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -28,73 +34,45 @@ const TrashIcon = () => (
export default function DrawIt() {
const { token } = useAuth()
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [pictureList, setPictureList] = useState<DbPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [debouncedIndex, setDebouncedIndex] = useState(-1)
const [objects, setObjects] = useState<DirectusObject[]>([])
const [objects, setObjects] = useState<DbObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [userNotes, setUserNotes] = useState('')
const [safeWords, setSafeWords] = useState<{ title: string; level: number }[]>([])
const [safeWordInput, setSafeWordInput] = useState('')
const [safeWordLevel, setSafeWordLevel] = useState(50)
const [safeWordInputVisible, setSafeWordInputVisible] = useState(false)
const safeWordInputRef = useRef<HTMLInputElement>(null)
const [pictureWords, setPictureWords] = useState<PictureWord[]>([])
// pending words (not yet saved)
const [pendingWords, setPendingWords] = useState<{ titel_de: string; level: number }[]>([])
const [wordInput, setWordInput] = useState('')
const [wordLevel, setWordLevel] = useState(50)
const [wordInputVisible, setWordInputVisible] = useState(false)
const wordInputRef = useRef<HTMLInputElement>(null)
// saved words from Directus
const [pictureWords, setPictureWords] = useState<DbWord[]>([])
const [savingWords, setSavingWords] = useState(false)
const [parentId, setParentId] = useState<string | null>(null)
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
const [hasSelection, setHasSelection] = useState(false)
const [saving, setSaving] = useState(false)
const [finishing, setFinishing] = useState(false)
const [status, setStatus] = useState('')
const [statusMsg, setStatusMsg] = useState('')
const [statusError, setStatusError] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null)
// Debounce: Bild erst laden wenn 350ms keine weitere Navigation
// Debounce: only load picture data after 350ms of no navigation
useEffect(() => {
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
return () => clearTimeout(t)
}, [currentIndex])
useEffect(() => {
if (safeWordInputVisible) safeWordInputRef.current?.focus()
}, [safeWordInputVisible])
if (wordInputVisible) wordInputRef.current?.focus()
}, [wordInputVisible])
const addSafeWord = () => {
const title = safeWordInput.trim()
if (!title || safeWords.some(w => w.title === title) || pictureWords.some(w => w.title_de === title)) {
setSafeWordInput(''); return
}
setSafeWords(prev => [...prev, { title, level: safeWordLevel }])
setSafeWordInput('')
setSafeWordLevel(50)
setSafeWordInputVisible(false)
}
const saveSafeWords = async () => {
if (!currentPicture || !token || safeWords.length === 0) return
setSavingWords(true)
try {
await savePictureWords(currentPicture.id, safeWords.map(w => ({ title_de: w.title, level: w.level })), token)
const updated = await getPictureWords(currentPicture.id, token)
setPictureWords(updated)
setSafeWords([])
showStatus('Wörter gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true)
} finally {
setSavingWords(false)
}
}
// currentPicture folgt dem debouncedIndex → lädt erst wenn Navigation pausiert
const currentPicture: DirectusPicture | null =
const currentPicture: DbPicture | null =
debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
// Map DirectusObject → CanvasObject for rendering
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
id: obj.id,
visible: obj.visible !== false,
@@ -103,33 +81,62 @@ export default function DrawIt() {
hierarchy: 1,
}))
// Load db_pictures with status=draft
useEffect(() => {
if (!token) return
getDirectusPictures(token)
getDbPictures(token, 'draft')
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
// Load objects + words when picture changes
useEffect(() => {
if (!currentPicture || !token) {
setObjects([]); setSelectedObjectId(null)
setPictureWords([]); setSafeWords([])
setPictureWords([]); setPendingWords([])
return
}
getDirectusObjects(currentPicture.id, token)
getDbObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
getPictureWords(currentPicture.id, token)
getDbPictureWords(currentPicture.id, token)
.then(setPictureWords)
.catch(console.error)
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
setStatus(msg); setStatusError(isError)
setStatusMsg(msg); setStatusError(isError)
}
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
const addWord = () => {
const titel = wordInput.trim()
if (!titel || pendingWords.some(w => w.titel_de === titel) || pictureWords.some(w => w.titel_de === titel)) {
setWordInput(''); return
}
setPendingWords(prev => [...prev, { titel_de: titel, level: wordLevel }])
setWordInput('')
setWordLevel(50)
setWordInputVisible(false)
}
const saveWords = async () => {
if (!currentPicture || !token || pendingWords.length === 0) return
setSavingWords(true)
try {
await saveDbPictureWords(currentPicture.id, pendingWords, token)
const updated = await getDbPictureWords(currentPicture.id, token)
setPictureWords(updated)
setPendingWords([])
showStatus('Wörter gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true)
} finally {
setSavingWords(false)
}
}
const addSelection = () => {
const sel = canvasRef.current?.getCurrentSelection()
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
@@ -142,16 +149,14 @@ export default function DrawIt() {
if (!currentPicture || !token || currentSelections.length === 0) return
setSaving(true)
try {
const obj = await createDirectusObject({
const obj = await createDbObject({
picture: currentPicture.id,
selections: currentSelections,
user_notes: userNotes.trim() || null,
parent: parentId,
}, token)
setObjects(prev => [...prev, { ...obj, visible: true }])
setCurrentSelections([])
setUserNotes('')
setParentId(null)
canvasRef.current?.resetSelection()
showStatus('Objekt gespeichert.')
} catch (e) {
@@ -161,11 +166,12 @@ export default function DrawIt() {
}
}
// Mark picture as objects_created and remove from list
const finishPicture = async () => {
if (!currentPicture || !token) return
setFinishing(true)
try {
await updatePictureStatus(currentPicture.id, 'drawing_created', token)
await updateDbPictureStatus(currentPicture.id, 'objects_created', token)
setPictureList(prev => prev.filter(p => p.id !== currentPicture.id))
setCurrentIndex(i => Math.max(0, i - 1))
setObjects([])
@@ -180,7 +186,7 @@ export default function DrawIt() {
const saveNoteEdit = async () => {
if (!editingNotes || !token) return
try {
await updateDirectusObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
await updateDbObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o))
setEditingNotes(null)
showStatus('Notizen gespeichert.')
@@ -192,7 +198,7 @@ export default function DrawIt() {
const deleteObject = async (objId: string) => {
if (!token) return
try {
await deleteDirectusObject(objId, token)
await deleteDbObject(objId, token)
setObjects(prev => prev.filter(o => o.id !== objId))
if (selectedObjectId === objId) setSelectedObjectId(null)
showStatus('Objekt gelöscht.')
@@ -250,7 +256,6 @@ export default function DrawIt() {
<div className="object-item-text">
<strong>Objekt {i + 1}</strong>
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : ''}</span>
{obj.parent && <span style={{ color: 'var(--primary-muted-fg)', fontSize: 11 }}> Kind von #{objects.findIndex(o => o.id === obj.parent) + 1}</span>}
</div>
<button
className="object-icon-button"
@@ -308,10 +313,13 @@ export default function DrawIt() {
{/* Center: Canvas */}
<main className="canvas-area">
<div className="canvas-frame">
<div
className="canvas-frame"
style={currentPicture ? { background: 'var(--surface-2)' } : undefined}
>
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjectId}
mode={mode}
@@ -369,21 +377,6 @@ export default function DrawIt() {
/>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">Parent-Objekt</h3>
<select
value={parentId ?? ''}
onChange={e => setParentId(e.target.value || null)}
>
<option value=""> kein Parent </option>
{objects.map((obj, i) => (
<option key={obj.id} value={obj.id}>
Objekt {i + 1}{obj.user_notes ? ` ${obj.user_notes.slice(0, 30)}` : ''}
</option>
))}
</select>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Auswahlen
@@ -422,7 +415,7 @@ export default function DrawIt() {
Alle löschen
</button>
</div>
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
</div>
</aside>
@@ -430,26 +423,31 @@ export default function DrawIt() {
<aside className="sidebar sidebar--words">
<div className="sidebar-panel">
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Words{(pictureWords.length + safeWords.length) > 0 && <span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + safeWords.length}</span>}</span>
<span>
Words
{(pictureWords.length + pendingWords.length) > 0 && (
<span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + pendingWords.length}</span>
)}
</span>
<button
className="btn-icon"
style={{ width: 22, height: 22, borderRadius: 'var(--r-sm)', fontSize: 16, lineHeight: 1, padding: 0 }}
onClick={() => setSafeWordInputVisible(v => !v)}
onClick={() => setWordInputVisible(v => !v)}
title="Word hinzufügen"
>+</button>
</h3>
{safeWordInputVisible && (
{wordInputVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<input
ref={safeWordInputRef}
value={safeWordInput}
onChange={e => setSafeWordInput(e.target.value)}
ref={wordInputRef}
value={wordInput}
onChange={e => setWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') addSafeWord()
if (e.key === 'Escape') { setSafeWordInputVisible(false); setSafeWordInput('') }
if (e.key === 'Enter') addWord()
if (e.key === 'Escape') { setWordInputVisible(false); setWordInput('') }
}}
placeholder="Wort…"
placeholder="Wort (titel_de)…"
style={{
width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
@@ -462,22 +460,22 @@ export default function DrawIt() {
<input
type="number"
min={1} max={100}
value={safeWordLevel}
onChange={e => setSafeWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
value={wordLevel}
onChange={e => setWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
style={{
flex: 1, padding: '4px 6px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
}}
/>
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addSafeWord}></button>
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addWord}></button>
</div>
</div>
)}
{/* Saved words from Directus */}
{/* Saved words from db_words_db_pictures */}
{pictureWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: safeWords.length > 0 ? 8 : 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: pendingWords.length > 0 ? 8 : 0 }}>
{pictureWords.map(w => (
<div key={w.word_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
@@ -485,7 +483,7 @@ export default function DrawIt() {
background: 'var(--surface-2)', border: '1px solid var(--border)',
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-1)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.title_de}
{w.titel_de}
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
</div>
@@ -493,23 +491,23 @@ export default function DrawIt() {
</div>
)}
{/* Pending new words */}
{safeWords.length > 0 && (
{/* Pending new words (not yet saved) */}
{pendingWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{safeWords.map((w, i) => (
{pendingWords.map((w, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.title}
{w.titel_de}
</span>
<input
type="number"
min={1} max={100}
value={w.level}
onChange={e => setSafeWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
onChange={e => setPendingWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
style={{
width: 44, padding: '2px 4px', borderRadius: 'var(--r-sm)',
border: '1px solid color-mix(in srgb, var(--primary) 40%, transparent)',
@@ -518,7 +516,7 @@ export default function DrawIt() {
}}
/>
<button
onClick={() => setSafeWords(prev => prev.filter((_, j) => j !== i))}
onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 14, flexShrink: 0 }}
title="Entfernen"
>×</button>
@@ -527,7 +525,7 @@ export default function DrawIt() {
</div>
)}
{pictureWords.length === 0 && safeWords.length === 0 && (
{pictureWords.length === 0 && pendingWords.length === 0 && (
<div className="empty-state" style={{ fontSize: 12 }}>Noch keine Words</div>
)}
</div>
@@ -535,10 +533,10 @@ export default function DrawIt() {
<div className="sidebar-panel">
<button
className="btn-primary btn-sm btn-block"
onClick={saveSafeWords}
disabled={safeWords.length === 0 || savingWords || !currentPicture}
onClick={saveWords}
disabled={pendingWords.length === 0 || savingWords || !currentPicture}
>
{savingWords ? 'Speichere…' : `Save${safeWords.length > 0 ? ` (${safeWords.length})` : ''}`}
{savingWords ? 'Speichere…' : `Save${pendingWords.length > 0 ? ` (${pendingWords.length})` : ''}`}
</button>
</div>
</aside>