680 lines
29 KiB
TypeScript
680 lines
29 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||
import BlurhashCanvas from '../components/BlurhashCanvas'
|
||
import Topbar from '../components/Topbar'
|
||
import {
|
||
getDbPictures,
|
||
updateDbPicture,
|
||
updateDbPictureStatus,
|
||
deleteDbPicture,
|
||
getDbObjects,
|
||
createDbObject,
|
||
updateDbObject,
|
||
deleteDbObject,
|
||
getDbObjectWords,
|
||
addDbObjectWord,
|
||
deleteDbObjectWord,
|
||
getDbPictureWords,
|
||
addDbPictureWord,
|
||
deleteDbPictureWord,
|
||
directusAssetUrl,
|
||
getDesignOptions,
|
||
} 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('')
|
||
// per-object words: objectId → DbWord[]
|
||
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
|
||
// per-object word input values: objectId → current input text
|
||
const [wordInputs, setWordInputs] = useState<Record<string, string>>({})
|
||
const [pendingWords, setPendingWords] = useState<string[]>([])
|
||
const [newWordInput, setNewWordInput] = useState('')
|
||
const [designOptions, setDesignOptions] = useState<{ text: string; value: string }[]>([])
|
||
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 [deleting, setDeleting] = useState(false)
|
||
const [statusMsg, setStatusMsg] = useState('')
|
||
const [statusError, setStatusError] = useState(false)
|
||
const [imageLoaded, setImageLoaded] = useState(false)
|
||
const [pictureWords, setPictureWords] = useState<DbWord[]>([])
|
||
const [pictureWordInput, setPictureWordInput] = useState('')
|
||
|
||
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])
|
||
|
||
// Reset imageLoaded immediately on navigation so blurhash shows right away
|
||
useEffect(() => {
|
||
setImageLoaded(false)
|
||
}, [currentIndex])
|
||
|
||
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 design options once on mount
|
||
useEffect(() => {
|
||
if (!token) return
|
||
getDesignOptions(token).then(setDesignOptions).catch(console.error)
|
||
}, [token])
|
||
|
||
// Load objects when picture changes, then load each object's words and picture words
|
||
useEffect(() => {
|
||
if (!currentPicture || !token) {
|
||
setObjects([]); setSelectedObjectId(null)
|
||
setObjectWords({})
|
||
setWordInputs({})
|
||
setImageLoaded(false)
|
||
setPictureWords([])
|
||
setPictureWordInput('')
|
||
return
|
||
}
|
||
getDbObjects(currentPicture.id, token)
|
||
.then(objs => {
|
||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||
setSelectedObjectId(null)
|
||
// Load words for each object
|
||
const newWords: Record<string, DbWord[]> = {}
|
||
const promises = objs.map(obj =>
|
||
getDbObjectWords(obj.id, token)
|
||
.then(words => { newWords[obj.id] = words })
|
||
.catch(() => { newWords[obj.id] = [] })
|
||
)
|
||
Promise.all(promises).then(() => {
|
||
setObjectWords(newWords)
|
||
})
|
||
})
|
||
.catch(console.error)
|
||
// Load picture-level words
|
||
getDbPictureWords(currentPicture.id, token)
|
||
.then(words => setPictureWords(words))
|
||
.catch(() => setPictureWords([]))
|
||
}, [currentPicture?.id, token])
|
||
|
||
const showStatus = (msg: string, isError = false) => {
|
||
setStatusMsg(msg); setStatusError(isError)
|
||
}
|
||
|
||
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
||
|
||
const handleAddObjectWord = async (objId: string) => {
|
||
if (!token) return
|
||
const titel_de = (wordInputs[objId] || '').trim()
|
||
if (!titel_de) return
|
||
try {
|
||
const result = await addDbObjectWord(objId, { titel_de, level: 50 }, token)
|
||
if (result.ok) {
|
||
const words = await getDbObjectWords(objId, token)
|
||
setObjectWords(prev => ({ ...prev, [objId]: words }))
|
||
setWordInputs(prev => ({ ...prev, [objId]: '' }))
|
||
}
|
||
} catch (e) {
|
||
showStatus(e instanceof Error ? e.message : 'Fehler beim Hinzufügen des Worts.', true)
|
||
}
|
||
}
|
||
|
||
const handleAddPendingWord = () => {
|
||
const w = newWordInput.trim()
|
||
if (!w || pendingWords.includes(w)) return
|
||
setPendingWords(prev => [...prev, w])
|
||
setNewWordInput('')
|
||
}
|
||
|
||
const handleRemoveObjectWord = async (objId: string, junctionId: string | number) => {
|
||
if (!token) return
|
||
try {
|
||
await deleteDbObjectWord(objId, junctionId, token)
|
||
setObjectWords(prev => ({
|
||
...prev,
|
||
[objId]: (prev[objId] || []).filter(w => w.junction_id !== junctionId)
|
||
}))
|
||
} catch (e) {
|
||
showStatus(e instanceof Error ? e.message : 'Fehler beim Entfernen des Worts.', true)
|
||
}
|
||
}
|
||
|
||
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)
|
||
// Save pending words to the new object
|
||
const savedWords: DbWord[] = []
|
||
for (const w of pendingWords) {
|
||
try {
|
||
const result = await addDbObjectWord(obj.id, { titel_de: w, level: 50 }, token)
|
||
if (result.ok) {
|
||
savedWords.push({ junction_id: result.junction_id, word_id: result.word_id, titel_de: w, level: 50, status: 'draft' })
|
||
}
|
||
} catch {}
|
||
}
|
||
setObjects(prev => [...prev, { ...obj, visible: true }])
|
||
setObjectWords(prev => ({ ...prev, [obj.id]: savedWords }))
|
||
setCurrentSelections([])
|
||
setUserNotes('')
|
||
setPendingWords([])
|
||
setNewWordInput('')
|
||
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 {
|
||
// Save status + current design in one call to avoid race conditions
|
||
await updateDbPicture(currentPicture.id, {
|
||
status: 'objects_created',
|
||
design: currentPicture.design ?? null,
|
||
}, 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 handleAddPictureWord = async () => {
|
||
if (!token || !currentPicture) return
|
||
const titel_de = pictureWordInput.trim()
|
||
if (!titel_de) return
|
||
try {
|
||
await addDbPictureWord(currentPicture.id, { titel_de, level: 50 }, token)
|
||
const words = await getDbPictureWords(currentPicture.id, token)
|
||
setPictureWords(words)
|
||
setPictureWordInput('')
|
||
} catch (e) { showStatus('Fehler beim Hinzufügen.', true) }
|
||
}
|
||
|
||
const handleRemovePictureWord = async (junctionId: string | number) => {
|
||
if (!token || !currentPicture) return
|
||
try {
|
||
await deleteDbPictureWord(currentPicture.id, junctionId, token)
|
||
setPictureWords(prev => prev.filter(w => w.junction_id !== junctionId))
|
||
} catch (e) { showStatus('Fehler beim Entfernen.', true) }
|
||
}
|
||
|
||
const deleteObject = async (objId: string) => {
|
||
if (!token) return
|
||
try {
|
||
await deleteDbObject(objId, token)
|
||
setObjects(prev => prev.filter(o => o.id !== objId))
|
||
setObjectWords(prev => { const n = { ...prev }; delete n[objId]; return n })
|
||
setWordInputs(prev => { const n = { ...prev }; delete n[objId]; return n })
|
||
if (selectedObjectId === objId) setSelectedObjectId(null)
|
||
showStatus('Objekt gelöscht.')
|
||
} catch (e) {
|
||
showStatus(e instanceof Error ? e.message : 'Fehler beim Löschen.', true)
|
||
}
|
||
}
|
||
|
||
const deleteCurrentPicture = async () => {
|
||
if (!token || !currentPicture) return
|
||
if (!window.confirm('Bild wirklich löschen? Der Eintrag und die Datei werden dauerhaft aus Directus entfernt.')) return
|
||
setDeleting(true)
|
||
try {
|
||
await deleteDbPicture(currentPicture.id, token)
|
||
const newList = pictureList.filter(p => p.id !== currentPicture.id)
|
||
setPictureList(newList)
|
||
const nextIndex = Math.min(currentIndex, newList.length - 1)
|
||
setCurrentIndex(nextIndex)
|
||
showStatus('Bild gelöscht.')
|
||
} catch (e) {
|
||
showStatus(e instanceof Error ? e.message : 'Fehler beim Löschen.', true)
|
||
} finally {
|
||
setDeleting(false)
|
||
}
|
||
}
|
||
|
||
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>
|
||
<button
|
||
className="btn-icon"
|
||
onClick={deleteCurrentPicture}
|
||
disabled={deleting || !currentPicture}
|
||
title="Bild löschen"
|
||
style={{ color: 'var(--error, #dc2626)', marginLeft: 8 }}
|
||
>
|
||
<TrashIcon />
|
||
</button>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<Topbar page="draw" center={imageNav} />
|
||
|
||
<div className="workspace">
|
||
{/* Left sidebar: saved objects */}
|
||
<aside className="sidebar">
|
||
{currentPicture && (
|
||
<div className="sidebar-panel">
|
||
<h3 className="sidebar-heading">Hauptwörter</h3>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
||
{pictureWords.length === 0 && (
|
||
<span style={{ fontSize: 11, color: 'var(--text-2)' }}>Noch keine Hauptwörter</span>
|
||
)}
|
||
{pictureWords.map(w => (
|
||
<span key={w.junction_id} style={{
|
||
display: 'flex', alignItems: 'center', gap: 3,
|
||
padding: '2px 8px', background: '#dcfce7', color: '#166534',
|
||
borderRadius: 9999, fontSize: 11,
|
||
}}>
|
||
{w.titel_de}
|
||
<button
|
||
onClick={() => handleRemovePictureWord(w.junction_id!)}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#16a34a', padding: 0, fontSize: 13 }}
|
||
>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<input
|
||
type="text"
|
||
value={pictureWordInput}
|
||
onChange={e => setPictureWordInput(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleAddPictureWord() }}
|
||
placeholder="Hauptwort hinzufügen…"
|
||
style={{ flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12 }}
|
||
/>
|
||
<button
|
||
onClick={handleAddPictureWord}
|
||
style={{ padding: '4px 10px', borderRadius: 'var(--r-sm)', background: '#16a34a', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||
>+</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
|
||
{currentPicture && (
|
||
<div className="sidebar-panel">
|
||
<h3 className="sidebar-heading">Design</h3>
|
||
<select
|
||
value={currentPicture.design || ''}
|
||
onChange={async e => {
|
||
const value = e.target.value
|
||
const design = value || null
|
||
setPictureList(prev => prev.map(p => p.id === currentPicture.id ? { ...p, design } : p))
|
||
try {
|
||
await updateDbPicture(currentPicture.id, { design }, token!)
|
||
showStatus('Design gespeichert.')
|
||
} catch (e) {
|
||
showStatus('Fehler beim Speichern des Designs.', true)
|
||
}
|
||
}}
|
||
style={{
|
||
width: '100%', padding: '6px 8px', borderRadius: 'var(--r-sm)',
|
||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 13, cursor: 'pointer',
|
||
}}
|
||
>
|
||
<option value="">– kein Design –</option>
|
||
{designOptions.map(opt => (
|
||
<option key={opt.value} value={opt.value}>{opt.text}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{currentPicture && (
|
||
<div className="sidebar-panel">
|
||
<button
|
||
className="btn-primary btn-sm btn-block"
|
||
onClick={finishPicture}
|
||
disabled={finishing || objects.length === 0}
|
||
style={{ background: objects.length > 0 ? 'var(--success, #16a34a)' : undefined, opacity: objects.length === 0 ? 0.5 : 1 }}
|
||
>
|
||
{finishing ? 'Wird fertiggestellt…' : '✅ Fertigstellen'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
|
||
{/* Center: Canvas */}
|
||
<main className="canvas-area">
|
||
<div
|
||
className="canvas-frame"
|
||
style={{ position: 'relative', background: 'var(--surface-2)' }}
|
||
>
|
||
{/* Blurhash-Platzhalter: sichtbar solange das echte Bild noch lädt */}
|
||
{currentPicture?.blurhash && !imageLoaded && (
|
||
<BlurhashCanvas
|
||
hash={currentPicture.blurhash}
|
||
width={32}
|
||
height={32}
|
||
style={{ zIndex: 1 }}
|
||
/>
|
||
)}
|
||
<DrawCanvas
|
||
ref={canvasRef}
|
||
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
|
||
objects={canvasObjects}
|
||
selectedObjectId={selectedObjectId}
|
||
mode={mode}
|
||
onHasSelection={handleHasSelection}
|
||
onImageLoad={() => setImageLoaded(true)}
|
||
/>
|
||
</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>
|
||
|
||
<div className="sidebar-panel">
|
||
<h3 className="sidebar-heading">
|
||
Wörter
|
||
{selectedObjectId
|
||
? ` (Objekt ${objects.findIndex(o => o.id === selectedObjectId) + 1})`
|
||
: ' (neues Objekt)'}
|
||
</h3>
|
||
|
||
{/* Existing word chips for selected object */}
|
||
{selectedObjectId && (
|
||
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
|
||
{(objectWords[selectedObjectId] || []).map(w => (
|
||
<span key={w.junction_id} style={{
|
||
display:'flex', alignItems:'center', gap:3,
|
||
padding:'2px 8px', background:'#e0e7ff', color:'#3730a3',
|
||
borderRadius:9999, fontSize:11,
|
||
}}>
|
||
{w.titel_de}
|
||
<button onClick={() => handleRemoveObjectWord(selectedObjectId, w.junction_id!)}
|
||
style={{ background:'none', border:'none', cursor:'pointer', color:'#818cf8', padding:0, fontSize:13 }}>×</button>
|
||
</span>
|
||
))}
|
||
{(objectWords[selectedObjectId] || []).length === 0 && (
|
||
<span style={{ fontSize:11, color:'var(--text-2)' }}>Noch keine Wörter</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pending words for new object */}
|
||
{!selectedObjectId && pendingWords.length > 0 && (
|
||
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
|
||
{pendingWords.map((w, i) => (
|
||
<span key={i} style={{
|
||
display:'flex', alignItems:'center', gap:3,
|
||
padding:'2px 8px', background:'#fef3c7', color:'#92400e',
|
||
borderRadius:9999, fontSize:11,
|
||
}}>
|
||
{w}
|
||
<button onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
|
||
style={{ background:'none', border:'none', cursor:'pointer', color:'#d97706', padding:0, fontSize:13 }}>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add word input */}
|
||
<div style={{ display:'flex', gap:4 }}>
|
||
<input
|
||
type="text"
|
||
value={selectedObjectId ? (wordInputs[selectedObjectId] || '') : newWordInput}
|
||
onChange={e => selectedObjectId
|
||
? setWordInputs(prev => ({ ...prev, [selectedObjectId]: e.target.value }))
|
||
: setNewWordInput(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()
|
||
}}
|
||
placeholder="Wort hinzufügen…"
|
||
style={{ flex:1, padding:'4px 8px', borderRadius:'var(--r-sm)', border:'1px solid var(--border)', background:'var(--surface-2)', color:'var(--text-1)', fontFamily:'var(--font)', fontSize:12 }}
|
||
/>
|
||
<button
|
||
onClick={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
|
||
style={{ padding:'4px 10px', borderRadius:'var(--r-sm)', background:'#6366f1', color:'#fff', border:'none', cursor:'pointer', fontSize:12 }}
|
||
>+</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|