Files
hejyou_content_creation/frontend/src/pages/DrawIt.tsx
Tim Leikauf 08cce17976 fix(draw): Bildnavigation debounce – nur letztes Bild laden bei schnellem Weiterklicken
currentPicture folgt debouncedIndex (350ms), currentIndex reagiert sofort
für Counter und Button-Disabled-State.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:28:11 +02:00

549 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 {
getDirectusPictures, directusAssetUrl, type DirectusPicture,
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
updatePictureStatus, getPictureWords, savePictureWords,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, Selection, CanvasObject, PictureWord } 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<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [debouncedIndex, setDebouncedIndex] = useState(-1)
const [objects, setObjects] = useState<DirectusObject[]>([])
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[]>([])
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 [statusError, setStatusError] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null)
// Debounce: Bild erst laden wenn 350ms keine weitere Navigation
useEffect(() => {
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
return () => clearTimeout(t)
}, [currentIndex])
useEffect(() => {
if (safeWordInputVisible) safeWordInputRef.current?.focus()
}, [safeWordInputVisible])
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 =
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,
selections: obj.selections,
index: i + 1,
hierarchy: 1,
}))
useEffect(() => {
if (!token) return
getDirectusPictures(token)
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
useEffect(() => {
if (!currentPicture || !token) {
setObjects([]); setSelectedObjectId(null)
setPictureWords([]); setSafeWords([])
return
}
getDirectusObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
getPictureWords(currentPicture.id, token)
.then(setPictureWords)
.catch(console.error)
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
setStatus(msg); setStatusError(isError)
}
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
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 createDirectusObject({
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) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
} finally {
setSaving(false)
}
}
const finishPicture = async () => {
if (!currentPicture || !token) return
setFinishing(true)
try {
await updatePictureStatus(currentPicture.id, 'drawing_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 updateDirectusObject(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 deleteDirectusObject(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>
{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"
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">
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, 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">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
{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>
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</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 + safeWords.length) > 0 && <span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + safeWords.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)}
title="Word hinzufügen"
>+</button>
</h3>
{safeWordInputVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<input
ref={safeWordInputRef}
value={safeWordInput}
onChange={e => setSafeWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') addSafeWord()
if (e.key === 'Escape') { setSafeWordInputVisible(false); setSafeWordInput('') }
}}
placeholder="Wort…"
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={safeWordLevel}
onChange={e => setSafeWordLevel(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>
</div>
</div>
)}
{/* Saved words from Directus */}
{pictureWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: safeWords.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.title_de}
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
</div>
))}
</div>
)}
{/* Pending new words */}
{safeWords.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{safeWords.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}
</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))}
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={() => setSafeWords(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 && safeWords.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={saveSafeWords}
disabled={safeWords.length === 0 || savingWords || !currentPicture}
>
{savingWords ? 'Speichere…' : `Save${safeWords.length > 0 ? ` (${safeWords.length})` : ''}`}
</button>
</div>
</aside>
</div>
</div>
)
}