Professionelles Redesign + Directus-Auth + Tag/Nacht-Modus
- Vollbild App-Shell mit Topbar, drei-Spalten-Workspace - Login-Seite mit Directus JWT-Authentifizierung (in-memory Token) - Tag/Nacht-Modus mit CSS Custom Properties (Systemfarbe als Default) - Directus 'pictures' Collection (status=new) als Bildquelle in DrawIt - Pfeil-Navigation durch Bilder mit Bildnummer-Anzeige - Neues Design-System: Indigo-Akzent, SVG-Icons, professionelle Typografie - ThemeProvider, AuthProvider, PrivateRoute, Topbar-Komponente Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,43 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||||
import ObjectsList from '../components/ObjectsList'
|
||||
import { getImages, getObjects, cropImage, saveImage } from '../api'
|
||||
import Topbar from '../components/Topbar'
|
||||
import { getObjects, cropImage, getDirectusPictures, directusAssetUrl, type DirectusPicture } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { ObjectMeta, Selection } from '../types'
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
title_de: 'Titel / title_de',
|
||||
position_de: 'Position / position_de',
|
||||
action_de: 'Status (sitzt/schwimmt/segelt) / action_de',
|
||||
condition_de: 'Zustand (alt/jung/rostig) / condition_de',
|
||||
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'
|
||||
|
||||
export default function DrawIt() {
|
||||
const navigate = useNavigate()
|
||||
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 [imageList, setImageList] = useState<string[]>([])
|
||||
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>
|
||||
)
|
||||
|
||||
export default function DrawIt() {
|
||||
const { token } = useAuth()
|
||||
|
||||
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||
@@ -39,32 +54,33 @@ export default function DrawIt() {
|
||||
})
|
||||
|
||||
const canvasRef = useRef<DrawCanvasHandle>(null)
|
||||
const currentFilename = currentIndex >= 0 && currentIndex < imageList.length
|
||||
? imageList[currentIndex]
|
||||
const currentPicture = currentIndex >= 0 && currentIndex < pictureList.length
|
||||
? pictureList[currentIndex]
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
getImages('draw')
|
||||
.then(imgs => {
|
||||
setImageList(imgs)
|
||||
setCurrentIndex(imgs.length - 1)
|
||||
if (!token) return
|
||||
getDirectusPictures(token)
|
||||
.then(pics => {
|
||||
setPictureList(pics)
|
||||
setCurrentIndex(pics.length > 0 ? 0 : -1)
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFilename) {
|
||||
if (!currentPicture) {
|
||||
setObjects([])
|
||||
setSelectedObjectId(null)
|
||||
return
|
||||
}
|
||||
getObjects(currentFilename)
|
||||
getObjects(currentPicture.id)
|
||||
.then(objs => {
|
||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||||
setSelectedObjectId(objs[0]?.id ?? null)
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [currentFilename])
|
||||
}, [currentPicture?.id])
|
||||
|
||||
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
||||
|
||||
@@ -89,11 +105,11 @@ export default function DrawIt() {
|
||||
}
|
||||
|
||||
const saveObject = async () => {
|
||||
if (!currentFilename || currentSelections.length === 0) return
|
||||
if (!currentPicture || currentSelections.length === 0) return
|
||||
try {
|
||||
showStatus('Speichere Objekt...')
|
||||
showStatus('Speichere Objekt…')
|
||||
const result = await cropImage({
|
||||
filename: currentFilename,
|
||||
filename: currentPicture.id,
|
||||
selections: currentSelections.map((sel, idx) => ({
|
||||
number: idx + 1,
|
||||
mode: sel.mode,
|
||||
@@ -105,80 +121,75 @@ export default function DrawIt() {
|
||||
showStatus(`Gespeichert – ID: ${result.id} (${currentSelections.length} Auswahl(en))`)
|
||||
setCurrentSelections([])
|
||||
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
|
||||
const objs = await getObjects(currentFilename)
|
||||
const objs = await getObjects(currentPicture.id)
|
||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||||
} catch (e) {
|
||||
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveImage = async () => {
|
||||
if (!currentFilename) return
|
||||
try {
|
||||
showStatus('Bild wird gespeichert...')
|
||||
await saveImage(currentFilename)
|
||||
const imgs = await getImages('draw')
|
||||
setImageList(imgs)
|
||||
setCurrentIndex(imgs.length - 1)
|
||||
showStatus('Bild gespeichert.')
|
||||
} catch (e) {
|
||||
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
|
||||
}
|
||||
}
|
||||
const imageNav = (
|
||||
<div className="image-nav">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setCurrentIndex(i => i - 1)}
|
||||
disabled={currentIndex <= 0}
|
||||
title="Vorheriges Bild"
|
||||
>
|
||||
<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}
|
||||
title="Nächstes Bild"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>DrawIt</h1>
|
||||
<div className="app-shell">
|
||||
<Topbar page="draw" center={imageNav} />
|
||||
|
||||
<div className="panel image-nav">
|
||||
<div className="image-nav-left">
|
||||
<button
|
||||
onClick={() => setCurrentIndex(i => i - 1)}
|
||||
disabled={currentIndex <= 0}
|
||||
>
|
||||
⬅️
|
||||
</button>
|
||||
<span>Bild: <code>{currentFilename || '–'}</code></span>
|
||||
<button
|
||||
onClick={() => setCurrentIndex(i => i + 1)}
|
||||
disabled={currentIndex >= imageList.length - 1}
|
||||
>
|
||||
➡️
|
||||
</button>
|
||||
<button onClick={handleSaveImage} disabled={!currentFilename}>💾</button>
|
||||
</div>
|
||||
<div className="page-switch">
|
||||
<select value="/draw" onChange={e => navigate(e.target.value)}>
|
||||
<option value="/draw">DrawIt</option>
|
||||
<option value="/generate">GenerateIt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-layout">
|
||||
{/* Left: Objects */}
|
||||
<div className="objects-pane sidebar-section">
|
||||
<h2>Objekte zu diesem Bild</h2>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<div className="workspace">
|
||||
{/* Left sidebar: 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}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="left-pane">
|
||||
<div className="canvas-wrapper">
|
||||
<main className="canvas-area">
|
||||
<div className="canvas-frame">
|
||||
<DrawCanvas
|
||||
ref={canvasRef}
|
||||
imageSrc={
|
||||
currentFilename
|
||||
? `/pictures/${encodeURIComponent(currentFilename)}`
|
||||
currentPicture && token
|
||||
? directusAssetUrl(currentPicture.media, token)
|
||||
: null
|
||||
}
|
||||
objects={objects}
|
||||
@@ -187,17 +198,14 @@ export default function DrawIt() {
|
||||
onHasSelection={handleHasSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right: Controls */}
|
||||
<div className="right-pane">
|
||||
<div className="sidebar-section">
|
||||
<h2>Auswahl</h2>
|
||||
<div className="sidebar-row">
|
||||
<span>Auswahl-Typ (Interface / Backend):</span>
|
||||
</div>
|
||||
<div className="sidebar-row">
|
||||
<label className="mode-option">
|
||||
{/* Right sidebar: Controls */}
|
||||
<aside className="sidebar sidebar--right">
|
||||
<div className="sidebar-panel">
|
||||
<h3 className="sidebar-heading">Auswahl-Modus</h3>
|
||||
<div className="mode-group">
|
||||
<label className={`mode-btn${mode === 'rect' ? ' active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
@@ -205,11 +213,9 @@ export default function DrawIt() {
|
||||
checked={mode === 'rect'}
|
||||
onChange={() => setMode('rect')}
|
||||
/>
|
||||
Rechteck / <code>BBox</code>
|
||||
<span>Rechteck</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="sidebar-row">
|
||||
<label className="mode-option">
|
||||
<label className={`mode-btn${mode === 'polygon' ? ' active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
@@ -217,23 +223,27 @@ export default function DrawIt() {
|
||||
checked={mode === 'polygon'}
|
||||
onChange={() => setMode('polygon')}
|
||||
/>
|
||||
Polygon / <code>polygon</code>
|
||||
<span>Polygon</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="sidebar-row">
|
||||
<button type="button" onClick={() => canvasRef.current?.resetSelection()}>
|
||||
<div className="action-group">
|
||||
<button
|
||||
className="btn-ghost btn-sm btn-block"
|
||||
onClick={() => canvasRef.current?.resetSelection()}
|
||||
>
|
||||
Auswahl zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<h2>Metadaten</h2>
|
||||
<div className="sidebar-panel">
|
||||
<h3 className="sidebar-heading">Metadaten</h3>
|
||||
{(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => (
|
||||
<div className="sidebar-row" key={key}>
|
||||
<label htmlFor={key}>{FIELD_LABELS[key]}</label>
|
||||
<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] || ''}
|
||||
@@ -243,55 +253,66 @@ export default function DrawIt() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<h2>Auswahlen</h2>
|
||||
<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="selections-empty">Noch keine Auswahlen hinzugefügt.</div>
|
||||
<div className="empty-state">Noch keine Auswahlen</div>
|
||||
) : (
|
||||
currentSelections.map((sel, i) => (
|
||||
<div className="selection-item" key={i}>
|
||||
<strong>Auswahl {i + 1}</strong> ({sel.mode === 'rect' ? 'Rechteck' : 'Polygon'}):
|
||||
{sel.mode === 'rect' && sel.bbox
|
||||
? ` x=${sel.bbox.x}, y=${sel.bbox.y}, w=${sel.bbox.width}, h=${sel.bbox.height}`
|
||||
: ` ${sel.polygon?.length ?? 0} Punkte`}
|
||||
<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="sidebar-row">
|
||||
|
||||
<div className="action-group">
|
||||
<button
|
||||
className="btn-primary btn-sm btn-block"
|
||||
onClick={addSelection}
|
||||
disabled={!hasSelection || !currentFilename}
|
||||
disabled={!hasSelection || !currentPicture}
|
||||
>
|
||||
➕ Auswahl hinzufügen
|
||||
+ Auswahl hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-row">
|
||||
<button
|
||||
className="btn-primary btn-sm btn-block"
|
||||
onClick={saveObject}
|
||||
disabled={!currentFilename || currentSelections.length === 0}
|
||||
disabled={!currentPicture || currentSelections.length === 0}
|
||||
>
|
||||
💾 Objekt speichern
|
||||
Objekt speichern
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost btn-sm btn-block btn-danger"
|
||||
onClick={() => {
|
||||
setCurrentSelections([])
|
||||
canvasRef.current?.resetSelection()
|
||||
showStatus('Alle Auswahlen gelöscht.')
|
||||
}}
|
||||
>
|
||||
🗑️ Alle Auswahlen löschen
|
||||
Alle löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<span className={`status ${statusError ? 'error' : 'ok'}`}>{status}</span>
|
||||
<div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user