Files
hejyou_content_creation/frontend/src/pages/DrawIt.tsx
admin 7c983a7460 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>
2026-05-10 08:03:23 +02:00

547 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import Topbar from '../components/Topbar'
import {
getDbPictures,
updateDbPictureStatus,
getDbObjects,
createDbObject,
updateDbObject,
deleteDbObject,
getDbPictureWords,
saveDbPictureWords,
directusAssetUrl,
} from '../api'
import { useAuth } from '../context/AuthContext'
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">
<polyline points="15 18 9 12 15 6" />
</svg>
)
const ChevronRightIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
)
const TrashIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
)
export default function DrawIt() {
const { token } = useAuth()
const [pictureList, setPictureList] = useState<DbPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [debouncedIndex, setDebouncedIndex] = useState(-1)
const [objects, setObjects] = useState<DbObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [userNotes, setUserNotes] = useState('')
// 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 [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 [statusMsg, setStatusMsg] = useState('')
const [statusError, setStatusError] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null)
// Debounce: only load picture data after 350ms of no navigation
useEffect(() => {
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
return () => clearTimeout(t)
}, [currentIndex])
useEffect(() => {
if (wordInputVisible) wordInputRef.current?.focus()
}, [wordInputVisible])
const currentPicture: DbPicture | null =
debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
id: obj.id,
visible: obj.visible !== false,
selections: obj.selections,
index: i + 1,
hierarchy: 1,
}))
// Load db_pictures with status=draft
useEffect(() => {
if (!token) return
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([]); setPendingWords([])
return
}
getDbObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
getDbPictureWords(currentPicture.id, token)
.then(setPictureWords)
.catch(console.error)
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
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 }
setCurrentSelections(prev => { const next = [...prev, sel]; showStatus(`Auswahl ${next.length} hinzugefügt.`); return next })
canvasRef.current?.resetSelection()
setHasSelection(false)
}
const saveObject = async () => {
if (!currentPicture || !token || currentSelections.length === 0) return
setSaving(true)
try {
const obj = await createDbObject({
picture: currentPicture.id,
selections: currentSelections,
user_notes: userNotes.trim() || null,
}, token)
setObjects(prev => [...prev, { ...obj, visible: true }])
setCurrentSelections([])
setUserNotes('')
canvasRef.current?.resetSelection()
showStatus('Objekt gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
} finally {
setSaving(false)
}
}
// Mark picture as objects_created and remove from list
const finishPicture = async () => {
if (!currentPicture || !token) return
setFinishing(true)
try {
await updateDbPictureStatus(currentPicture.id, 'objects_created', token)
setPictureList(prev => prev.filter(p => p.id !== currentPicture.id))
setCurrentIndex(i => Math.max(0, i - 1))
setObjects([])
showStatus('Bild fertiggestellt.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
} finally {
setFinishing(false)
}
}
const saveNoteEdit = async () => {
if (!editingNotes || !token) return
try {
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.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
}
}
const deleteObject = async (objId: string) => {
if (!token) return
try {
await deleteDbObject(objId, token)
setObjects(prev => prev.filter(o => o.id !== objId))
if (selectedObjectId === objId) setSelectedObjectId(null)
showStatus('Objekt gelöscht.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Löschen.', true)
}
}
const imageNav = (
<div className="image-nav">
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
<ChevronLeftIcon />
</button>
<span className="image-counter">
{pictureList.length > 0
? <><span className="image-counter-num">{currentIndex + 1}</span><span className="image-counter-sep">/</span><span className="image-counter-total">{pictureList.length}</span></>
: <span className="image-counter-empty">Keine Bilder</span>}
</span>
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
<ChevronRightIcon />
</button>
</div>
)
return (
<div className="app-shell">
<Topbar page="draw" center={imageNav} />
<div className="workspace">
{/* Left sidebar: saved objects */}
<aside className="sidebar">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Objekte
{objects.length > 0 && <span className="badge">{objects.length}</span>}
</h3>
{objects.length === 0 ? (
<div className="empty-state">Noch keine Objekte für dieses Bild.</div>
) : (
<div className="objects-list">
{objects.map((obj, i) => (
<div
key={obj.id}
className={`object-item${selectedObjectId === obj.id ? ' selected' : ''}`}
onClick={() => setSelectedObjectId(obj.id === selectedObjectId ? null : obj.id)}
>
<div className="object-item-header">
<input
type="checkbox"
checked={obj.visible !== false}
onClick={e => e.stopPropagation()}
onChange={e => setObjects(prev => prev.map(o => o.id === obj.id ? { ...o, visible: e.target.checked } : o))}
/>
<div className="object-item-text">
<strong>Objekt {i + 1}</strong>
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : ''}</span>
</div>
<button
className="object-icon-button"
onClick={e => { e.stopPropagation(); setEditingNotes({ id: obj.id, notes: obj.user_notes ?? '' }) }}
title="Notizen bearbeiten"
></button>
<button
className="object-icon-button"
onClick={e => { e.stopPropagation(); deleteObject(obj.id) }}
title="Löschen"
><TrashIcon /></button>
</div>
{obj.user_notes && (
<div style={{ padding: '4px 8px 6px 32px', fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.4, borderTop: '1px solid var(--border)' }}>
{obj.user_notes}
</div>
)}
{editingNotes?.id === obj.id && (
<div style={{ padding: '8px', borderTop: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 6 }} onClick={e => e.stopPropagation()}>
<textarea
value={editingNotes.notes}
onChange={e => setEditingNotes({ ...editingNotes, notes: e.target.value })}
rows={3}
style={{ width: '100%', resize: 'vertical', padding: '6px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12 }}
placeholder="Notizen zum Objekt…"
autoFocus
/>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn-primary btn-sm" style={{ flex: 1 }} onClick={saveNoteEdit}>Speichern</button>
<button className="btn-ghost btn-sm" onClick={() => setEditingNotes(null)}>Abbrechen</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{objects.length > 0 && (
<div className="sidebar-panel">
<button
className="btn-primary btn-sm btn-block"
onClick={finishPicture}
disabled={finishing}
style={{ background: 'var(--success, #16a34a)' }}
>
{finishing ? 'Wird fertiggestellt…' : '✅ Fertigstellen'}
</button>
</div>
)}
</aside>
{/* Center: Canvas */}
<main className="canvas-area">
<div
className="canvas-frame"
style={currentPicture ? { background: 'var(--surface-2)' } : undefined}
>
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjectId}
mode={mode}
onHasSelection={handleHasSelection}
/>
</div>
</main>
{/* Right sidebar: drawing tools */}
<aside className="sidebar sidebar--right">
<div className="sidebar-panel">
<h3 className="sidebar-heading">Modus</h3>
<div className="mode-group">
<label className={`mode-btn${mode === 'rect' ? ' active' : ''}`}>
<input type="radio" name="mode" value="rect" checked={mode === 'rect'} onChange={() => setMode('rect')} />
<span>Rechteck</span>
</label>
<label className={`mode-btn${mode === 'polygon' ? ' active' : ''}`}>
<input type="radio" name="mode" value="polygon" checked={mode === 'polygon'} onChange={() => setMode('polygon')} />
<span>Polygon</span>
</label>
</div>
<div className="action-group">
{mode === 'polygon' && (
<button
className="btn-primary btn-sm btn-block"
onClick={() => canvasRef.current?.closePolygon()}
disabled={!hasSelection}
>
Polygon schließen
</button>
)}
<button className="btn-ghost btn-sm btn-block" onClick={() => canvasRef.current?.resetSelection()}>
Auswahl zurücksetzen
</button>
</div>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">Notizen zum Objekt</h3>
<textarea
value={userNotes}
onChange={e => setUserNotes(e.target.value)}
rows={4}
placeholder="Beschreibung, Besonderheiten, Kontext…"
style={{
width: '100%', resize: 'vertical', padding: '7px 10px',
borderRadius: 'var(--r-md)', border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-1)',
fontFamily: 'var(--font)', fontSize: 13, lineHeight: 1.5,
transition: 'border-color .15s, box-shadow .15s', outline: 'none',
}}
onFocus={e => { e.target.style.borderColor = 'var(--primary)'; e.target.style.boxShadow = '0 0 0 3px rgba(92,108,246,.12)' }}
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
/>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Auswahlen
{currentSelections.length > 0 && <span className="badge">{currentSelections.length}</span>}
</h3>
<div className="selections-list">
{currentSelections.length === 0
? <div className="empty-state">Noch keine Auswahlen</div>
: currentSelections.map((sel, i) => (
<div className="selection-chip" key={i}>
<span className="selection-chip-num">{i + 1}</span>
<span className="selection-chip-type">{sel.mode === 'rect' ? 'Rect' : 'Poly'}</span>
<span className="selection-chip-info">
{sel.mode === 'rect' && sel.bbox
? `${Math.round(sel.bbox.width)}×${Math.round(sel.bbox.height)}`
: `${sel.polygon?.length ?? 0} Pkt.`}
</span>
</div>
))}
</div>
<div className="action-group">
<button className="btn-primary btn-sm btn-block" onClick={addSelection} disabled={!hasSelection || !currentPicture}>
+ Auswahl hinzufügen
</button>
<button
className="btn-primary btn-sm btn-block"
onClick={saveObject}
disabled={!currentPicture || currentSelections.length === 0 || saving}
>
{saving ? 'Speichere…' : `${currentSelections.length > 1 ? currentSelections.length + ' Objekte' : 'Objekt'} speichern`}
</button>
<button
className="btn-ghost btn-sm btn-block btn-danger"
onClick={() => { setCurrentSelections([]); canvasRef.current?.resetSelection(); showStatus('Alle Auswahlen gelöscht.') }}
>
Alle löschen
</button>
</div>
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
</div>
</aside>
{/* Words sidebar */}
<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 + 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={() => setWordInputVisible(v => !v)}
title="Word hinzufügen"
>+</button>
</h3>
{wordInputVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<input
ref={wordInputRef}
value={wordInput}
onChange={e => setWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') addWord()
if (e.key === 'Escape') { setWordInputVisible(false); setWordInput('') }
}}
placeholder="Wort (titel_de)…"
style={{
width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
boxSizing: 'border-box',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 11, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>Level</label>
<input
type="number"
min={1} max={100}
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={addWord}></button>
</div>
</div>
)}
{/* Saved words from db_words_db_pictures */}
{pictureWords.length > 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',
padding: '4px 8px', borderRadius: 'var(--r-sm)',
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.titel_de}
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
</div>
))}
</div>
)}
{/* Pending new words (not yet saved) */}
{pendingWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{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.titel_de}
</span>
<input
type="number"
min={1} max={100}
value={w.level}
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)',
background: 'var(--surface)', color: 'var(--primary)',
fontFamily: 'var(--font)', fontSize: 11, textAlign: 'center',
}}
/>
<button
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>
</div>
))}
</div>
)}
{pictureWords.length === 0 && pendingWords.length === 0 && (
<div className="empty-state" style={{ fontSize: 12 }}>Noch keine Words</div>
)}
</div>
<div className="sidebar-panel">
<button
className="btn-primary btn-sm btn-block"
onClick={saveWords}
disabled={pendingWords.length === 0 || savingWords || !currentPicture}
>
{savingWords ? 'Speichere…' : `Save${pendingWords.length > 0 ? ` (${pendingWords.length})` : ''}`}
</button>
</div>
</aside>
</div>
</div>
)
}