Erster Commit
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Content Mentor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1771
frontend/package-lock.json
generated
Normal file
1771
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "content-mentor-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
13
frontend/src/App.tsx
Normal file
13
frontend/src/App.tsx
Normal 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
106
frontend/src/api.ts
Normal 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
|
||||
}
|
||||
71
frontend/src/components/DetailsPanel.tsx
Normal file
71
frontend/src/components/DetailsPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
338
frontend/src/components/DrawCanvas.tsx
Normal file
338
frontend/src/components/DrawCanvas.tsx
Normal 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' }} />
|
||||
})
|
||||
181
frontend/src/components/ObjectsList.tsx
Normal file
181
frontend/src/components/ObjectsList.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/SentencesList.tsx
Normal file
34
frontend/src/components/SentencesList.tsx
Normal 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
382
frontend/src/index.css
Normal 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
13
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
298
frontend/src/pages/DrawIt.tsx
Normal file
298
frontend/src/pages/DrawIt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
frontend/src/pages/GenerateIt.tsx
Normal file
166
frontend/src/pages/GenerateIt.tsx
Normal 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
53
frontend/src/types.ts
Normal 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
|
||||
}
|
||||
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/pictures': 'http://localhost:8000',
|
||||
'/objects_image': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../static/react',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user