@@ -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 { Directus Object, 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 < Directus Object[ ] > ( [ ] )
const [ selectedObjectId , setSelectedObjectId ] = useState < string | null > ( null )
const [ currentSelections , setCurrentSelections ] = useState < Selection [ ] > ( [ ] )
const [ statu s, setStatu s ] = useState ( '' )
const [ statusError , setStatusError ] = useState ( false )
const [ userNote s, setUserNote s ] = 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 : u serNotes.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: O bjects */ }
{ /* Left sidebar: saved o bjects */ }
< 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: Contr ols */ }
{ /* Right sidebar: drawing to ols */ }
< 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 >