Erster Commit

This commit is contained in:
2026-04-23 22:10:45 +02:00
commit 5d47482d2a
30 changed files with 6340 additions and 0 deletions

13
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import DrawIt from './pages/DrawIt'
import GenerateIt from './pages/GenerateIt'
export default function App() {
return (
<Routes>
<Route path="/" element={<Navigate to="/draw" replace />} />
<Route path="/draw" element={<DrawIt />} />
<Route path="/generate" element={<GenerateIt />} />
</Routes>
)
}

106
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { ObjectMeta, Sentence } from './types'
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')
const data = await res.json()
return data.images as string[]
}
export async function getObjects(filename: string): Promise<ObjectMeta[]> {
const res = await fetch(`/api/objects?filename=${encodeURIComponent(filename)}`)
if (!res.ok) throw new Error('Fehler beim Laden der Objekte')
const data = await res.json()
return (data.objects || []) as ObjectMeta[]
}
export async function cropImage(payload: {
filename: string
selections: Array<{
number: number
mode: string
bbox?: { x: number; y: number; width: number; height: number } | null
polygon?: Array<{ x: number; y: number }> | null
}>
title_de: string
position_de: string
action_de: string
condition_de: string
}): Promise<{ id: string; image_file: string; meta_file: string }> {
const res = await fetch('/api/crop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern des Ausschnitts')
return data
}
export async function saveImage(filename: string): Promise<{ old_name: string; new_name: string }> {
const res = await fetch('/api/image/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern des Bildes')
return data
}
export async function updateObjectMeta(
objId: string,
meta: { title_de: string; position_de: string; action_de: string; condition_de: string }
): Promise<void> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Metadaten')
}
export async function updateHierarchy(objId: string, hierarchy: number): Promise<void> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/hierarchy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hierarchy }),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Hierarchie')
}
export async function updateParent(objId: string, parentId: string | null): Promise<void> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/parent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: parentId }),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Parent-Relation')
}
export async function generateDetails(objId: string): Promise<Partial<ObjectMeta>> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_details`, {
method: 'POST',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Details')
return data
}
export async function getSentences(objId: string): Promise<Sentence[]> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/sentences`)
if (!res.ok) throw new Error('Fehler beim Laden der Sätze')
const data = await res.json()
return (data.sentences || []) as Sentence[]
}
export async function generateSentence(
objId: string
): Promise<{ sentence: Sentence; count: number }> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_sentence`, {
method: 'POST',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Sentence')
return data
}

View File

@@ -0,0 +1,71 @@
import type { ObjectMeta, Sentence } from '../types'
interface Props {
obj: ObjectMeta | null
objects: ObjectMeta[]
sentences: Sentence[]
}
function DetailRow({ label, value }: { label: string; value?: string }) {
return (
<div className="sidebar-row">
<label>{label}</label>
<div className="detail-value">{value || ''}</div>
</div>
)
}
export default function DetailsPanel({ obj, objects, sentences }: Props) {
const latestSentence = sentences.length > 0 ? sentences[sentences.length - 1] : null
const parentDisplay = obj?.parent_id
? (() => {
const parent = objects.find(o => o.id === obj.parent_id)
return parent ? `${parent.index} - ${parent.title_de || 'ohne Titel'}` : ''
})()
: ''
return (
<>
<div className="sidebar-section">
<h2>Details</h2>
<DetailRow label="Titel" value={obj?.title_de} />
<DetailRow label="Position" value={obj?.position_de} />
<DetailRow label="Status (sitzt/schwimmt/segelt)" value={obj?.action_de} />
<DetailRow label="Zustand (alt/jung/rostig)" value={obj?.condition_de} />
<DetailRow label="Hierarchie" value={obj?.hierarchy != null ? String(obj.hierarchy) : ''} />
<div className="sidebar-row">
<label>Gehört zu (Parent-Index)</label>
<div className="detail-value">{parentDisplay}</div>
</div>
</div>
<div className="sidebar-section">
<h2>KI-Details</h2>
<DetailRow label="Label (EN)" value={obj?.label_en} />
<DetailRow label="Label (DE)" value={obj?.label_de} />
<DetailRow label="Label (SE)" value={obj?.label_se} />
<DetailRow label="Farbe (EN)" value={obj?.color_en} />
<DetailRow label="Adjektiv (EN)" value={obj?.adjective_en} />
<DetailRow label="Action Verb (EN)" value={obj?.action_verb_en} />
<DetailRow label="Präposition (EN)" value={obj?.preposition_en} />
<DetailRow label="Relative Position (EN)" value={obj?.relative_position_en} />
<DetailRow label="Season (EN)" value={obj?.season_en} />
</div>
<div className="sidebar-section">
<h2>KI-Sentence</h2>
<h3 style={{ fontSize: '0.9rem', marginTop: 0, marginBottom: 8, color: '#475569' }}>
Einfach (für Kinder)
</h3>
<DetailRow label="Question (EN)" value={latestSentence?.question_simple_en} />
<DetailRow label="Answer (EN)" value={latestSentence?.answer_simple_en} />
<h3 style={{ fontSize: '0.9rem', marginTop: 12, marginBottom: 8, color: '#475569' }}>
Fortgeschritten
</h3>
<DetailRow label="Question (EN)" value={latestSentence?.question_advanced_en} />
<DetailRow label="Answer (EN)" value={latestSentence?.answer_advanced_en} />
</div>
</>
)
}

View File

@@ -0,0 +1,338 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react'
import type { ObjectMeta, Point, Selection } from '../types'
export interface DrawCanvasHandle {
getCurrentSelection: () => Selection | null
resetSelection: () => void
}
interface Props {
imageSrc: string | null
objects: ObjectMeta[]
selectedObjectId: string | null
mode: 'rect' | 'polygon'
onHasSelection: (has: boolean) => void
}
export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
{ imageSrc, objects, selectedObjectId, mode, onHasSelection },
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null)
// Drawing state in refs — no re-renders needed
const isDragging = useRef(false)
const startXY = useRef({ x: 0, y: 0 })
const currentXY = useRef({ x: 0, y: 0 })
const polygonPoints = useRef<Point[]>([])
const isPolygonClosed = useRef(false)
const displayScale = useRef(1)
const imageRef = useRef<HTMLImageElement | null>(null)
// Keep latest props accessible from stable callbacks
const modeRef = useRef(mode)
const objectsRef = useRef(objects)
const selectedObjectIdRef = useRef(selectedObjectId)
const onHasSelectionRef = useRef(onHasSelection)
useEffect(() => { modeRef.current = mode }, [mode])
useEffect(() => { objectsRef.current = objects }, [objects])
useEffect(() => { selectedObjectIdRef.current = selectedObjectId }, [selectedObjectId])
useEffect(() => { onHasSelectionRef.current = onHasSelection }, [onHasSelection])
const redraw = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const img = imageRef.current
if (img) ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const scale = displayScale.current
const selectedId = selectedObjectIdRef.current
// Draw saved objects
for (const obj of objectsRef.current) {
if (obj.visible === false) continue
const isSelected = obj.id === selectedId
const h = obj.hierarchy || 1
let stroke = '#14532d'
let fill = 'rgba(20, 83, 45, 0.2)'
if (h === 1) { stroke = '#6b7280'; fill = 'rgba(107, 114, 128, 0.2)' }
else if (h === 2) { stroke = '#eab308'; fill = 'rgba(234, 179, 8, 0.3)' }
else if (h === 3) { stroke = '#dc2626'; fill = 'rgba(220, 38, 38, 0.3)' }
ctx.save()
ctx.strokeStyle = stroke
ctx.fillStyle = fill
ctx.lineWidth = isSelected ? 3 : 2
ctx.setLineDash(isSelected ? [2, 2] : [4, 3])
const { polygon, bbox } = obj
if (polygon && polygon.length >= 3) {
ctx.beginPath()
ctx.moveTo(polygon[0].x * scale, polygon[0].y * scale)
for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i].x * scale, polygon[i].y * scale)
ctx.closePath()
ctx.fill()
ctx.stroke()
} else if (bbox) {
ctx.fillRect(bbox.x * scale, bbox.y * scale, bbox.width * scale, bbox.height * scale)
ctx.strokeRect(bbox.x * scale, bbox.y * scale, bbox.width * scale, bbox.height * scale)
}
// White highlight ring for selected object
if (isSelected) {
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.setLineDash([])
if (polygon && polygon.length >= 3) {
ctx.beginPath()
ctx.moveTo(polygon[0].x * scale, polygon[0].y * scale)
for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i].x * scale, polygon[i].y * scale)
ctx.closePath()
ctx.stroke()
} else if (bbox) {
ctx.strokeRect(bbox.x * scale, bbox.y * scale, bbox.width * scale, bbox.height * scale)
}
}
// Index number in object center
const indexLabel = typeof obj.index === 'number' ? String(obj.index) : ''
if (indexLabel) {
let cx = 0, cy = 0
if (bbox) {
cx = (bbox.x + bbox.width / 2) * scale
cy = (bbox.y + bbox.height / 2) * scale
} else if (polygon && polygon.length > 0) {
const xs = polygon.map(p => p.x)
const ys = polygon.map(p => p.y)
cx = ((Math.min(...xs) + Math.max(...xs)) / 2) * scale
cy = ((Math.min(...ys) + Math.max(...ys)) / 2) * scale
}
ctx.save()
ctx.font = "bold 12px system-ui, -apple-system, sans-serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = 'rgba(15, 23, 42, 0.7)'
ctx.beginPath()
ctx.arc(cx, cy, 10, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.fillText(indexLabel, cx, cy + 0.5)
ctx.restore()
}
ctx.restore()
}
// Draw current in-progress selection
const m = modeRef.current
const { x: sx, y: sy } = startXY.current
const { x: ex, y: ey } = currentXY.current
if (m === 'rect' && (isDragging.current || sx !== ex || sy !== ey)) {
const x = Math.min(sx, ex)
const y = Math.min(sy, ey)
const w = Math.abs(ex - sx)
const h2 = Math.abs(ey - sy)
if (w > 0 && h2 > 0) {
ctx.save()
ctx.strokeStyle = '#f97316'
ctx.fillStyle = 'rgba(249, 115, 22, 0.3)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.fillRect(x, y, w, h2)
ctx.strokeRect(x, y, w, h2)
ctx.restore()
}
} else if (m === 'polygon' && polygonPoints.current.length > 0) {
ctx.save()
ctx.strokeStyle = '#f97316'
ctx.fillStyle = 'rgba(249, 115, 22, 0.3)'
ctx.lineWidth = 2
ctx.setLineDash([])
const pts = polygonPoints.current
ctx.beginPath()
ctx.moveTo(pts[0].x, pts[0].y)
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y)
if (!isPolygonClosed.current && isDragging.current) ctx.lineTo(ex, ey)
if (isPolygonClosed.current) { ctx.closePath(); ctx.fill() }
ctx.stroke()
for (const p of pts) {
ctx.beginPath()
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
ctx.fillStyle = '#ea580c'
ctx.fill()
}
ctx.restore()
}
}, [])
useImperativeHandle(ref, () => ({
getCurrentSelection(): Selection | null {
const scale = displayScale.current
if (modeRef.current === 'rect') {
const { x: sx, y: sy } = startXY.current
const { x: ex, y: ey } = currentXY.current
const w = Math.abs(ex - sx)
const h = Math.abs(ey - sy)
if (w <= 0 || h <= 0) return null
return {
mode: 'rect',
bbox: {
x: Math.round(Math.min(sx, ex) / scale),
y: Math.round(Math.min(sy, ey) / scale),
width: Math.round(w / scale),
height: Math.round(h / scale),
},
}
} else {
if (!isPolygonClosed.current || polygonPoints.current.length < 3) return null
return {
mode: 'polygon',
polygon: polygonPoints.current.map(p => ({
x: Math.round(p.x / scale),
y: Math.round(p.y / scale),
})),
}
}
},
resetSelection() {
isDragging.current = false
startXY.current = { x: 0, y: 0 }
currentXY.current = { x: 0, y: 0 }
polygonPoints.current = []
isPolygonClosed.current = false
onHasSelectionRef.current(false)
redraw()
},
}), [redraw])
// Reset drawing when mode changes
useEffect(() => {
modeRef.current = mode
isDragging.current = false
startXY.current = { x: 0, y: 0 }
currentXY.current = { x: 0, y: 0 }
polygonPoints.current = []
isPolygonClosed.current = false
onHasSelectionRef.current(false)
redraw()
}, [mode, redraw])
// Load image when src changes
useEffect(() => {
if (!imageSrc) {
imageRef.current = null
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height)
return
}
const img = new Image()
img.onload = () => {
const canvas = canvasRef.current
if (!canvas) return
imageRef.current = img
const maxW = (canvas.parentElement?.clientWidth ?? 816) - 16
const maxH = window.innerHeight * 0.7
const scale = Math.min(maxW / img.width, maxH / img.height, 1)
displayScale.current = isFinite(scale) && scale > 0 ? scale : 1
canvas.width = img.width * displayScale.current
canvas.height = img.height * displayScale.current
redraw()
}
img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc)
img.src = imageSrc
}, [imageSrc, redraw])
// Redraw when objects or selection changes
useEffect(() => {
redraw()
}, [objects, selectedObjectId, redraw])
// Mouse event handlers
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const getPos = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect()
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
}
}
const onMouseDown = (e: MouseEvent) => {
if (!imageRef.current) return
const { x, y } = getPos(e)
if (modeRef.current === 'rect') {
startXY.current = { x, y }
currentXY.current = { x, y }
isDragging.current = true
} else {
if (isPolygonClosed.current) {
polygonPoints.current = []
isPolygonClosed.current = false
onHasSelectionRef.current(false)
}
polygonPoints.current.push({ x, y })
isDragging.current = true
}
redraw()
}
const onMouseMove = (e: MouseEvent) => {
if (!imageRef.current || !isDragging.current) return
currentXY.current = getPos(e)
redraw()
}
const onMouseUp = (e: MouseEvent) => {
if (!imageRef.current) return
isDragging.current = false
if (modeRef.current === 'rect') {
const w = Math.abs(currentXY.current.x - startXY.current.x)
const h = Math.abs(currentXY.current.y - startXY.current.y)
onHasSelectionRef.current(w > 0 && h > 0)
} else {
if (e.detail === 2 && polygonPoints.current.length >= 3) {
isPolygonClosed.current = true
onHasSelectionRef.current(true)
}
}
redraw()
}
const onMouseLeave = () => {
if (isDragging.current) {
isDragging.current = false
redraw()
}
}
canvas.addEventListener('mousedown', onMouseDown)
canvas.addEventListener('mousemove', onMouseMove)
canvas.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('mouseleave', onMouseLeave)
return () => {
canvas.removeEventListener('mousedown', onMouseDown)
canvas.removeEventListener('mousemove', onMouseMove)
canvas.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('mouseleave', onMouseLeave)
}
}, [redraw])
return <canvas ref={canvasRef} style={{ display: 'block', maxWidth: '100%', height: 'auto' }} />
})

View File

@@ -0,0 +1,181 @@
import { useState, useEffect } from 'react'
import { updateHierarchy, updateObjectMeta, updateParent } from '../api'
import type { ObjectMeta } from '../types'
interface Props {
objects: ObjectMeta[]
selectedObjectId: string | null
onSelect: (id: string) => void
onVisibilityChange?: (id: string, visible: boolean) => void
onObjectsChange: (objects: ObjectMeta[]) => void
isGeneratePage: boolean
onShowDetails?: (obj: ObjectMeta) => void
onLoadSentences?: (objId: string) => void
}
type EditForm = { title_de: string; position_de: string; action_de: string; condition_de: string }
export default function ObjectsList({
objects,
selectedObjectId,
onSelect,
onVisibilityChange,
onObjectsChange,
isGeneratePage,
onShowDetails,
onLoadSentences,
}: Props) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const [editForms, setEditForms] = useState<Record<string, EditForm>>({})
useEffect(() => {
const forms: Record<string, EditForm> = {}
for (const obj of objects) {
forms[obj.id] = {
title_de: obj.title_de || '',
position_de: obj.position_de || '',
action_de: obj.action_de || '',
condition_de: obj.condition_de || '',
}
}
setEditForms(forms)
}, [objects])
const handleHierarchyChange = async (obj: ObjectMeta, value: number) => {
onObjectsChange(objects.map(o => o.id === obj.id ? { ...o, hierarchy: value } : o))
try { await updateHierarchy(obj.id, value) } catch (e) { console.error(e) }
}
const handleParentChange = async (obj: ObjectMeta, parentId: string | null) => {
onObjectsChange(objects.map(o => o.id === obj.id ? { ...o, parent_id: parentId } : o))
try { await updateParent(obj.id, parentId) } catch (e) { console.error(e) }
}
const handleSaveMeta = async (obj: ObjectMeta) => {
const form = editForms[obj.id]
if (!form) return
try {
await updateObjectMeta(obj.id, form)
onObjectsChange(objects.map(o => o.id === obj.id ? { ...o, ...form } : o))
setExpandedId(null)
} catch (e) { console.error(e) }
}
if (objects.length === 0) {
return (
<div className="objects-list">
<div className="object-item-text">Noch keine Objekte gespeichert.</div>
</div>
)
}
return (
<>
<div className="objects-list">
{objects.map(obj => (
<div
key={obj.id}
className="object-item"
style={obj.id === selectedObjectId ? { borderColor: '#2563eb' } : undefined}
onClick={() => {
onSelect(obj.id)
if (isGeneratePage) {
onShowDetails?.(obj)
onLoadSentences?.(obj.id)
}
}}
>
<div className="object-item-header">
{!isGeneratePage && (
<input
type="checkbox"
checked={obj.visible !== false}
onClick={e => e.stopPropagation()}
onChange={e => onVisibilityChange?.(obj.id, e.target.checked)}
/>
)}
{obj.image_file && (
<img
src={`/objects_image/${encodeURIComponent(obj.image_file)}`}
alt={obj.title_de || obj.id}
/>
)}
<select
className="object-hierarchy-select"
value={obj.hierarchy || 1}
onClick={e => e.stopPropagation()}
onChange={e => handleHierarchyChange(obj, parseInt(e.target.value))}
>
{[1, 2, 3].map(l => <option key={l} value={l}>{l}</option>)}
</select>
<select
className="object-parent-select"
value={obj.parent_id || ''}
onClick={e => e.stopPropagation()}
onChange={e => handleParentChange(obj, e.target.value || null)}
>
<option value="">-</option>
{objects
.filter(o => o.id !== obj.id)
.map(o => (
<option key={o.id} value={o.id}>{o.index}</option>
))}
</select>
<div className="object-item-text">
<strong>{obj.title_de || obj.id || 'Ohne Titel'}</strong>
{obj.position_de && <span>{obj.position_de}</span>}
</div>
{!isGeneratePage && (
<button
type="button"
className="object-icon-button"
onClick={e => {
e.stopPropagation()
setExpandedId(expandedId === obj.id ? null : obj.id)
}}
>
📝
</button>
)}
</div>
{!isGeneratePage && expandedId === obj.id && (
<div className="object-item-details visible">
{(['title_de', 'position_de', 'action_de', 'condition_de'] as const).map(key => (
<div key={key}>
<label style={{ fontSize: '0.75rem' }}>{key}</label>
<input
type="text"
value={editForms[obj.id]?.[key] || ''}
onClick={e => e.stopPropagation()}
onChange={e =>
setEditForms(f => ({
...f,
[obj.id]: { ...f[obj.id], [key]: e.target.value },
}))
}
/>
</div>
))}
<button
type="button"
className="object-icon-button"
onClick={e => { e.stopPropagation(); handleSaveMeta(obj) }}
>
💾
</button>
</div>
)}
</div>
))}
</div>
<div className="objects-tags">
{objects.map(obj => (
<span key={obj.id} className="object-tag">
{obj.title_de || obj.id || 'Ohne Titel'}
</span>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,34 @@
import type { Sentence } from '../types'
interface Props {
sentences: Sentence[]
}
export default function SentencesList({ sentences }: Props) {
if (sentences.length === 0) {
return (
<div className="sentences-list">
<div className="sentence-item-empty">Noch keine Sätze vorhanden.</div>
</div>
)
}
return (
<div className="sentences-list">
{[...sentences].reverse().map((s, i) => (
<div className="sentence-item" key={i}>
<div style={{ fontWeight: 600, color: '#1e40af', marginBottom: 4, fontSize: '0.85rem' }}>
Einfach:
</div>
<div className="sentence-item-question">{s.question_simple_en}</div>
<div className="sentence-item-answer">{s.answer_simple_en}</div>
<div style={{ fontWeight: 600, color: '#1e40af', marginTop: 8, marginBottom: 4, fontSize: '0.85rem' }}>
Fortgeschritten:
</div>
<div className="sentence-item-question">{s.question_advanced_en}</div>
<div className="sentence-item-answer">{s.answer_advanced_en}</div>
</div>
))}
</div>
)
}

382
frontend/src/index.css Normal file
View File

@@ -0,0 +1,382 @@
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: #f5f7fb;
color: #222;
}
.container {
max-width: 1180px;
margin: 24px auto;
padding: 16px 20px 32px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
h1 {
margin-top: 0;
margin-bottom: 16px;
font-size: 1.6rem;
}
label {
font-weight: 500;
}
.panel {
margin: 12px 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.image-nav {
justify-content: space-between;
gap: 8px;
}
.image-nav button {
padding: 4px 10px;
border-radius: 999px;
}
.image-nav-left {
display: flex;
align-items: center;
gap: 8px;
}
.page-switch select {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid #cbd5f0;
background: #f9fbff;
font-size: 0.9rem;
min-width: unset;
}
.main-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.objects-pane {
flex: 0 0 260px;
}
.left-pane {
flex: 1 1 auto;
}
.right-pane {
flex: 0 0 280px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sentences-pane {
flex: 0 0 300px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mode-option {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 400;
}
select {
min-width: 220px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #cbd5f0;
background: #f9fbff;
}
.canvas-wrapper {
border-radius: 12px;
border: 1px solid #d4ddf5;
background: #f1f5ff;
overflow: visible;
padding: 8px;
}
canvas {
display: block;
max-width: 100%;
height: auto;
}
button {
padding: 8px 14px;
border-radius: 999px;
border: none;
background: #2563eb;
color: white;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.4);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
box-shadow: none;
}
button:not(:disabled):hover {
background: #1d4ed8;
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.5);
transform: translateY(-1px);
}
.status {
font-size: 0.9rem;
}
.status.ok {
color: #16a34a;
}
.status.error {
color: #dc2626;
}
.sidebar-section {
border-radius: 10px;
border: 1px solid #e5e7eb;
padding: 10px 12px;
background: #f9fafb;
}
.sidebar-section h2 {
font-size: 1rem;
margin: 0 0 6px;
}
.sidebar-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.sidebar-row input[type="text"] {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.9rem;
}
.detail-value {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #f9fafb;
font-size: 0.9rem;
min-height: 24px;
}
.objects-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
.objects-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.object-tag {
padding: 2px 6px;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.75rem;
white-space: nowrap;
}
.object-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
cursor: pointer;
}
.object-item-header {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.object-hierarchy-select {
width: 40px;
min-width: 0;
padding: 2px 3px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
background: #f9fafb;
}
.object-parent-select {
width: 50px;
min-width: 0;
padding: 2px 3px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
background: #eef2ff;
}
.object-item img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.object-item-text {
display: flex;
flex-direction: column;
font-size: 0.8rem;
flex: 1;
min-width: 0;
}
.object-item-text strong {
font-size: 0.85rem;
}
.object-item-details {
padding-left: 24px;
display: none;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.object-item-details.visible {
display: flex;
}
.object-item-details input[type="text"] {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
}
.object-item-details label {
font-size: 0.75rem;
}
.object-icon-button {
padding: 2px 6px;
border-radius: 6px;
font-size: 0.85rem;
box-shadow: none;
background: #e5e7eb;
color: #374151;
flex-shrink: 0;
}
.object-icon-button:not(:disabled):hover {
background: #d1d5db;
box-shadow: none;
transform: none;
}
.sentences-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 70vh;
overflow-y: auto;
padding: 4px;
}
.sentence-item {
padding: 12px;
background: #f9fbff;
border: 1px solid #d4ddf5;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sentence-item-question {
font-weight: 600;
color: #1e40af;
font-size: 0.95rem;
}
.sentence-item-answer {
color: #475569;
font-size: 0.9rem;
padding-left: 12px;
border-left: 2px solid #cbd5f0;
}
.sentence-item-empty {
padding: 24px;
text-align: center;
color: #94a3b8;
font-style: italic;
}
.selections-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 8px;
background: #f9fbff;
border: 1px solid #d4ddf5;
border-radius: 8px;
margin-bottom: 8px;
}
.selection-item {
padding: 8px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 0.85rem;
color: #475569;
}
.selection-item strong {
color: #1e40af;
font-weight: 600;
}
.selections-empty {
padding: 16px;
text-align: center;
color: #94a3b8;
font-style: italic;
font-size: 0.85rem;
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View File

@@ -0,0 +1,298 @@
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 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',
}
const FIELD_PLACEHOLDERS: Record<string, string> = {
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 [imageList, setImageList] = useState<string[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [objects, setObjects] = useState<ObjectMeta[]>([])
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [status, setStatus] = useState('')
const [statusError, setStatusError] = useState(false)
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 canvasRef = useRef<DrawCanvasHandle>(null)
const currentFilename = currentIndex >= 0 && currentIndex < imageList.length
? imageList[currentIndex]
: null
useEffect(() => {
getImages('draw')
.then(imgs => {
setImageList(imgs)
setCurrentIndex(imgs.length - 1)
})
.catch(console.error)
}, [])
useEffect(() => {
if (!currentFilename) {
setObjects([])
setSelectedObjectId(null)
return
}
getObjects(currentFilename)
.then(objs => {
setObjects(objs.map(o => ({ ...o, visible: true })))
setSelectedObjectId(objs[0]?.id ?? null)
})
.catch(console.error)
}, [currentFilename])
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
})
canvasRef.current?.resetSelection()
setHasSelection(false)
}
const saveObject = async () => {
if (!currentFilename || currentSelections.length === 0) return
try {
showStatus('Speichere Objekt...')
const result = await cropImage({
filename: currentFilename,
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))`)
setCurrentSelections([])
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
const objs = await getObjects(currentFilename)
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)
}
}
return (
<div className="container">
<h1>DrawIt</h1>
<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>
{/* Center: Canvas */}
<div className="left-pane">
<div className="canvas-wrapper">
<DrawCanvas
ref={canvasRef}
imageSrc={
currentFilename
? `/pictures/${encodeURIComponent(currentFilename)}`
: null
}
objects={objects}
selectedObjectId={selectedObjectId}
mode={mode}
onHasSelection={handleHasSelection}
/>
</div>
</div>
{/* 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">
<input
type="radio"
name="mode"
value="rect"
checked={mode === 'rect'}
onChange={() => setMode('rect')}
/>
Rechteck / <code>BBox</code>
</label>
</div>
<div className="sidebar-row">
<label className="mode-option">
<input
type="radio"
name="mode"
value="polygon"
checked={mode === 'polygon'}
onChange={() => setMode('polygon')}
/>
Polygon / <code>polygon</code>
</label>
</div>
<div className="sidebar-row">
<button type="button" onClick={() => canvasRef.current?.resetSelection()}>
Auswahl zurücksetzen
</button>
</div>
</div>
<div className="sidebar-section">
<h2>Metadaten</h2>
{(['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>
<input
id={key}
type="text"
value={form[key]}
placeholder={FIELD_PLACEHOLDERS[key] || ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
/>
</div>
))}
</div>
<div className="sidebar-section">
<h2>Auswahlen</h2>
<div className="selections-list">
{currentSelections.length === 0 ? (
<div className="selections-empty">Noch keine Auswahlen hinzugefügt.</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>
))
)}
</div>
<div className="sidebar-row">
<button
onClick={addSelection}
disabled={!hasSelection || !currentFilename}
>
Auswahl hinzufügen
</button>
</div>
<div className="sidebar-row">
<button
onClick={saveObject}
disabled={!currentFilename || currentSelections.length === 0}
>
💾 Objekt speichern
</button>
</div>
<div className="sidebar-row">
<button
type="button"
onClick={() => {
setCurrentSelections([])
canvasRef.current?.resetSelection()
showStatus('Alle Auswahlen gelöscht.')
}}
>
🗑 Alle Auswahlen löschen
</button>
</div>
{status && (
<span className={`status ${statusError ? 'error' : 'ok'}`}>{status}</span>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import ObjectsList from '../components/ObjectsList'
import DetailsPanel from '../components/DetailsPanel'
import SentencesList from '../components/SentencesList'
import { getImages, getObjects, generateDetails, generateSentence, getSentences } from '../api'
import type { ObjectMeta, Sentence } from '../types'
export default function GenerateIt() {
const navigate = useNavigate()
const [imageList, setImageList] = useState<string[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [objects, setObjects] = useState<ObjectMeta[]>([])
const [selectedObj, setSelectedObj] = useState<ObjectMeta | null>(null)
const [sentences, setSentences] = useState<Sentence[]>([])
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
const [isGeneratingSentence, setIsGeneratingSentence] = useState(false)
const currentFilename =
currentIndex >= 0 && currentIndex < imageList.length ? imageList[currentIndex] : null
useEffect(() => {
getImages('generate')
.then(imgs => {
setImageList(imgs)
setCurrentIndex(imgs.length - 1)
})
.catch(console.error)
}, [])
useEffect(() => {
if (!currentFilename) {
setObjects([])
setSelectedObj(null)
setSentences([])
return
}
getObjects(currentFilename)
.then(objs => {
setObjects(objs)
if (objs.length > 0) {
setSelectedObj(objs[0])
loadSentences(objs[0].id)
} else {
setSelectedObj(null)
setSentences([])
}
})
.catch(console.error)
}, [currentFilename])
const loadSentences = async (objId: string) => {
try {
const s = await getSentences(objId)
setSentences(s)
} catch (e) {
console.error(e)
}
}
const handleGenerateDetails = async () => {
const target = selectedObj ?? objects[0]
if (!target) return
setIsGeneratingDetails(true)
try {
const data = await generateDetails(target.id)
const updated = { ...target, ...data }
setSelectedObj(updated)
setObjects(prev => prev.map(o => o.id === target.id ? updated : o))
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler bei KI-Details')
} finally {
setIsGeneratingDetails(false)
}
}
const handleGenerateSentence = async () => {
const target = selectedObj ?? objects[0]
if (!target) return
setIsGeneratingSentence(true)
try {
const data = await generateSentence(target.id)
setSentences(prev => [...prev, data.sentence])
setSelectedObj(prev => prev ? { ...prev, latest_sentence: data.sentence } : prev)
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler bei KI-Sentence')
} finally {
setIsGeneratingSentence(false)
}
}
return (
<div className="container">
<h1>GenerateIt</h1>
<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={handleGenerateDetails}
disabled={isGeneratingDetails || !selectedObj}
>
{isGeneratingDetails ? '⏳ KI-Details...' : '✨ KI-Details'}
</button>
<button
onClick={handleGenerateSentence}
disabled={isGeneratingSentence || !selectedObj}
>
{isGeneratingSentence ? '⏳ KI-Sentence...' : '💬 KI-Sentence'}
</button>
</div>
<div className="page-switch">
<select value="/generate" onChange={e => navigate(e.target.value)}>
<option value="/draw">DrawIt</option>
<option value="/generate">GenerateIt</option>
</select>
</div>
</div>
<div className="main-layout">
<div className="objects-pane sidebar-section">
<h2>Objekte zu diesem Bild</h2>
<ObjectsList
objects={objects}
selectedObjectId={selectedObj?.id ?? null}
onSelect={id => {
const obj = objects.find(o => o.id === id)
if (obj) {
setSelectedObj(obj)
loadSentences(obj.id)
}
}}
onObjectsChange={setObjects}
isGeneratePage={true}
onShowDetails={obj => setSelectedObj(obj)}
onLoadSentences={loadSentences}
/>
</div>
<div className="right-pane">
<DetailsPanel obj={selectedObj} objects={objects} sentences={sentences} />
</div>
<div className="sentences-pane">
<div className="sidebar-section">
<h2>Alle Sätze</h2>
<SentencesList sentences={sentences} />
</div>
</div>
</div>
</div>
)
}

53
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,53 @@
export interface BBox {
x: number
y: number
width: number
height: number
}
export interface Point {
x: number
y: number
}
export interface Selection {
mode: 'rect' | 'polygon'
bbox?: BBox
polygon?: Point[]
}
export interface Sentence {
object_id: string
created_at: string
question_simple_en: string
answer_simple_en: string
question_advanced_en: string
answer_advanced_en: string
}
export interface ObjectMeta {
id: string
image_file: string
title_de: string
position_de: string
action_de: string
condition_de: string
label_en?: string
label_de?: string
label_se?: string
color_en?: string
adjective_en?: string
action_verb_en?: string
preposition_en?: string
relative_position_en?: string
season_en?: string
mode: 'rect' | 'polygon'
bbox?: BBox
polygon?: Point[]
hierarchy: number
parent_id?: string | null
created_at: string
index?: number
visible?: boolean
latest_sentence?: Sentence
}