Objekte direkt in Directus speichern + neuer Annotationsworkflow

- DirectusObject Typ + CanvasObject Interface in types.ts
- DrawCanvas nutzt CanvasObject (generisch, nicht mehr ObjectMeta-gebunden)
- Flask: /api/directus/objects (GET/POST), /api/directus/objects/<id> (PATCH/DELETE)
- Flask: /api/directus/setup-m2m (einmalig: m2m für categories/questions)
- api.ts: getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject
- DrawIt: Objekte werden in Directus gespeichert (mit picture, bbox/polygon, user_notes, parent)
- DrawIt: Linke Sidebar zeigt Objektliste mit Notizen-Editor und Löschen-Button
- DrawIt: Rechte Sidebar: Modus, user_notes Textarea, Parent-Dropdown, Auswahlen
- Directus: user_notes Feld (textarea), action/resolution/confidence/media versteckt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 20:26:46 +02:00
parent 01812ce954
commit 343d6a2389
5 changed files with 395 additions and 213 deletions

View File

@@ -32,6 +32,57 @@ export function directusAssetUrl(mediaId: string, token: string): string {
return `${DIRECTUS_URL}/assets/${mediaId}?access_token=${token}`
}
import type { DirectusObject, BBox, Point } from './types'
export async function getDirectusObjects(pictureId: string, token: string): Promise<DirectusObject[]> {
const res = await fetch(`/api/directus/objects?picture_id=${pictureId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Laden der Objekte')
const data = await res.json()
return data.data as DirectusObject[]
}
export async function createDirectusObject(payload: {
picture: string
bbox: BBox | null
polygon: Point[] | null
user_notes: string | null
parent: string | null
}, token: string): Promise<DirectusObject> {
const res = await fetch('/api/directus/objects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...payload, status: 'draft' }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Speichern')
return data.data as DirectusObject
}
export async function updateDirectusObject(
objId: string,
payload: Partial<Pick<DirectusObject, 'user_notes' | 'parent'>>,
token: string
): Promise<DirectusObject> {
const res = await fetch(`/api/directus/objects/${objId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Aktualisieren')
return data.data as DirectusObject
}
export async function deleteDirectusObject(objId: string, token: string): Promise<void> {
const res = await fetch(`/api/directus/objects/${objId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Löschen')
}
export async function getImages(mode: 'draw' | 'generate'): Promise<string[]> {
const res = await fetch(`/api/images?mode=${mode}`)
if (!res.ok) throw new Error('Fehler beim Laden der Bilder')

View File

@@ -5,7 +5,7 @@ import {
useImperativeHandle,
useRef,
} from 'react'
import type { ObjectMeta, Point, Selection } from '../types'
import type { CanvasObject, Point, Selection } from '../types'
// Module-level cache: URL → fertig geladenes Image-Objekt
const imageCache = new Map<string, HTMLImageElement>()
@@ -17,7 +17,7 @@ export interface DrawCanvasHandle {
interface Props {
imageSrc: string | null
objects: ObjectMeta[]
objects: CanvasObject[]
selectedObjectId: string | null
mode: 'rect' | 'polygon'
onHasSelection: (has: boolean) => void

View File

@@ -1,160 +1,153 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import ObjectsList from '../components/ObjectsList'
import Topbar from '../components/Topbar'
import { getObjects, cropImage, getDirectusPictures, directusAssetUrl, type DirectusPicture } from '../api'
import {
getDirectusPictures, directusAssetUrl, type DirectusPicture,
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { ObjectMeta, Selection } from '../types'
const FIELD_LABELS: Record<string, string> = {
title_de: 'Titel',
position_de: 'Position',
action_de: 'Aktion',
condition_de: 'Zustand',
}
const FIELD_PLACEHOLDERS: Record<string, string> = {
title_de: 'z.B. Hund',
position_de: 'z.B. links oben',
action_de: 'z.B. sitzt',
condition_de: 'z.B. rostig',
}
type FormKey = 'title_de' | 'position_de' | 'action_de' | 'condition_de'
import type { DirectusObject, 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<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [objects, setObjects] = useState<ObjectMeta[]>([])
const [objects, setObjects] = useState<DirectusObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [status, setStatus] = useState('')
const [statusError, setStatusError] = useState(false)
const [userNotes, setUserNotes] = useState('')
const [parentId, setParentId] = useState<string | null>(null)
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
const [mode, setMode] = useState<'rect' | 'polygon'>('rect')
const [hasSelection, setHasSelection] = useState(false)
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [form, setForm] = useState<Record<FormKey, string>>({
title_de: '',
position_de: '',
action_de: '',
condition_de: '',
})
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState('')
const [statusError, setStatusError] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null)
const currentPicture = currentIndex >= 0 && currentIndex < pictureList.length
? pictureList[currentIndex]
: null
const currentPicture: DirectusPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
// Map DirectusObject → CanvasObject for rendering
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
id: obj.id,
visible: obj.visible !== false,
bbox: obj.bbox,
polygon: obj.polygon,
index: i + 1,
hierarchy: 1,
}))
useEffect(() => {
if (!token) return
getDirectusPictures(token)
.then(pics => {
setPictureList(pics)
setCurrentIndex(pics.length > 0 ? 0 : -1)
})
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
useEffect(() => {
if (!currentPicture) {
setObjects([])
setSelectedObjectId(null)
return
}
getObjects(currentPicture.id)
.then(objs => {
setObjects(objs.map(o => ({ ...o, visible: true })))
setSelectedObjectId(objs[0]?.id ?? null)
})
if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null); return }
getDirectusObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
}, [currentPicture?.id])
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
setStatus(msg); setStatusError(isError)
}
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
const showStatus = (msg: string, isError = false) => {
setStatus(msg)
setStatusError(isError)
}
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
})
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 || currentSelections.length === 0) return
if (!currentPicture || !token || currentSelections.length === 0) return
setSaving(true)
try {
showStatus('Speichere Objekt…')
const result = await cropImage({
filename: currentPicture.id,
selections: currentSelections.map((sel, idx) => ({
number: idx + 1,
mode: sel.mode,
bbox: sel.bbox ?? null,
polygon: sel.polygon ?? null,
})),
...form,
})
showStatus(`Gespeichert ID: ${result.id} (${currentSelections.length} Auswahl(en))`)
// Save one object per selection
const newObjs: DirectusObject[] = []
for (const sel of currentSelections) {
const obj = await createDirectusObject({
picture: currentPicture.id,
bbox: sel.mode === 'rect' ? (sel.bbox ?? null) : null,
polygon: sel.mode === 'polygon' ? (sel.polygon ?? null) : null,
user_notes: userNotes.trim() || null,
parent: parentId,
}, token)
newObjs.push({ ...obj, visible: true })
}
setObjects(prev => [...prev, ...newObjs])
setCurrentSelections([])
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
const objs = await getObjects(currentPicture.id)
setObjects(objs.map(o => ({ ...o, visible: true })))
setUserNotes('')
setParentId(null)
canvasRef.current?.resetSelection()
showStatus(`${newObjs.length} Objekt(e) gespeichert.`)
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
} finally {
setSaving(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}
title="Vorheriges Bild"
>
<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>
)}
{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}
title="Nächstes Bild"
>
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
<ChevronRightIcon />
</button>
</div>
@@ -165,20 +158,74 @@ export default function DrawIt() {
<Topbar page="draw" center={imageNav} />
<div className="workspace">
{/* Left sidebar: Objects */}
{/* Left sidebar: saved objects */}
<aside className="sidebar">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">Objekte</h3>
<ObjectsList
objects={objects}
selectedObjectId={selectedObjectId}
onSelect={setSelectedObjectId}
onVisibilityChange={(id, visible) =>
setObjects(prev => prev.map(o => o.id === id ? { ...o, visible } : o))
}
onObjectsChange={setObjects}
isGeneratePage={false}
/>
<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.bbox ? `Rect ${Math.round(obj.bbox.width)}×${Math.round(obj.bbox.height)}` : obj.polygon ? `Poly ${obj.polygon.length} Pkt.` : ''}</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>
</aside>
@@ -187,12 +234,8 @@ export default function DrawIt() {
<div className="canvas-frame">
<DrawCanvas
ref={canvasRef}
imageSrc={
currentPicture && token
? directusAssetUrl(currentPicture.media, token)
: null
}
objects={objects}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjectId}
mode={mode}
onHasSelection={handleHasSelection}
@@ -200,117 +243,100 @@ export default function DrawIt() {
</div>
</main>
{/* Right sidebar: Controls */}
{/* Right sidebar: drawing tools */}
<aside className="sidebar sidebar--right">
<div className="sidebar-panel">
<h3 className="sidebar-heading">Auswahl-Modus</h3>
<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')}
/>
<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')}
/>
<input type="radio" name="mode" value="polygon" checked={mode === 'polygon'} onChange={() => setMode('polygon')} />
<span>Polygon</span>
</label>
</div>
<div className="action-group">
<button
className="btn-ghost btn-sm btn-block"
onClick={() => canvasRef.current?.resetSelection()}
>
<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">Metadaten</h3>
{(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => (
<div className="field" key={key}>
<label className="field-label" htmlFor={key}>{FIELD_LABELS[key]}</label>
<input
id={key}
className="field-input"
type="text"
value={form[key]}
placeholder={FIELD_PLACEHOLDERS[key] || ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
/>
</div>
))}
<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>
)}
{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) => (
{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-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}
>
<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}
disabled={!currentPicture || currentSelections.length === 0 || saving}
>
Objekt speichern
{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.')
}}
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>
)}
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
</div>
</aside>
</div>

View File

@@ -25,6 +25,29 @@ export interface Sentence {
answer_advanced_en: string
}
// Minimal interface DrawCanvas needs for rendering objects
export interface CanvasObject {
id: string
visible?: boolean
bbox?: BBox | null
polygon?: Point[] | null
hierarchy?: number
index?: number
}
// Object as stored in Directus
export interface DirectusObject {
id: string
status: string
picture: string
bbox: BBox | null
polygon: Point[] | null
user_notes: string | null
parent: string | null
visible?: boolean // local UI state only
}
// Legacy — still used by GenerateIt
export interface ObjectMeta {
id: string
image_file: string