Objekte direkt in Directus speichern + neuer Annotationsworkflow
- DirectusObject Typ + CanvasObject Interface in types.ts - DrawCanvas nutzt CanvasObject (generisch, nicht mehr ObjectMeta-gebunden) - Flask: /api/directus/objects (GET/POST), /api/directus/objects/<id> (PATCH/DELETE) - Flask: /api/directus/setup-m2m (einmalig: m2m für categories/questions) - api.ts: getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject - DrawIt: Objekte werden in Directus gespeichert (mit picture, bbox/polygon, user_notes, parent) - DrawIt: Linke Sidebar zeigt Objektliste mit Notizen-Editor und Löschen-Button - DrawIt: Rechte Sidebar: Modus, user_notes Textarea, Parent-Dropdown, Auswahlen - Directus: user_notes Feld (textarea), action/resolution/confidence/media versteckt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
142
app.py
142
app.py
@@ -38,44 +38,126 @@ def read_prompt(filepath: Path, fallback: str) -> str:
|
|||||||
return fallback.strip()
|
return fallback.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _directus(method, path, token, body=None):
|
||||||
|
"""Hilfsfunktion: Directus-API-Aufruf via urllib."""
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = token
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{DIRECTUS_URL}{path}",
|
||||||
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
return json.loads(raw) if raw else {}, resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8")
|
||||||
|
return json.loads(raw) if raw else {}, e.code
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/directus/auth/login", methods=["POST"])
|
@app.route("/api/directus/auth/login", methods=["POST"])
|
||||||
def directus_auth_login():
|
def directus_auth_login():
|
||||||
"""Proxy: Directus-Login ohne CORS-Probleme."""
|
"""Proxy: Directus-Login ohne CORS-Probleme."""
|
||||||
try:
|
data, status = _directus("POST", "/auth/login", token=None, body=request.get_json())
|
||||||
body = json.dumps(request.get_json()).encode("utf-8")
|
return jsonify(data), status
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{DIRECTUS_URL}/auth/login",
|
|
||||||
data=body,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req) as resp:
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
return jsonify(data)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
data = json.loads(e.read().decode("utf-8"))
|
|
||||||
return jsonify(data), e.code
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"errors": [{"message": str(e)}]}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/directus/pictures", methods=["GET"])
|
@app.route("/api/directus/pictures", methods=["GET"])
|
||||||
def directus_pictures():
|
def directus_pictures():
|
||||||
"""Proxy: Directus-Bilder (status=new) ohne CORS-Probleme."""
|
"""Proxy: Directus-Bilder (status=new)."""
|
||||||
token = request.headers.get("Authorization", "")
|
token = request.headers.get("Authorization", "")
|
||||||
try:
|
data, status = _directus("GET", "/items/pictures?filter[status][_eq]=new&fields=id,media,status&sort=date_created", token)
|
||||||
req = urllib.request.Request(
|
return jsonify(data), status
|
||||||
f"{DIRECTUS_URL}/items/pictures?filter[status][_eq]=new&fields=id,media,status&sort=date_created",
|
|
||||||
headers={"Authorization": token},
|
|
||||||
)
|
@app.route("/api/directus/objects", methods=["GET", "POST"])
|
||||||
with urllib.request.urlopen(req) as resp:
|
def directus_objects():
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
"""Proxy: Objekte laden (GET) oder anlegen (POST)."""
|
||||||
return jsonify(data)
|
token = request.headers.get("Authorization", "")
|
||||||
except urllib.error.HTTPError as e:
|
if request.method == "GET":
|
||||||
data = json.loads(e.read().decode("utf-8"))
|
picture_id = request.args.get("picture_id", "")
|
||||||
return jsonify(data), e.code
|
fields = "id,bbox,polygon,user_notes,parent,status,picture"
|
||||||
except Exception as e:
|
path = f"/items/objects?filter[picture][_eq]={picture_id}&fields={fields}&sort=date_created"
|
||||||
return jsonify({"errors": [{"message": str(e)}]}), 500
|
data, status = _directus("GET", path, token)
|
||||||
|
return jsonify(data), status
|
||||||
|
else:
|
||||||
|
data, status = _directus("POST", "/items/objects", token, body=request.get_json())
|
||||||
|
return jsonify(data), status
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/directus/objects/<obj_id>", methods=["PATCH", "DELETE"])
|
||||||
|
def directus_object(obj_id):
|
||||||
|
"""Proxy: Objekt aktualisieren (PATCH) oder löschen (DELETE)."""
|
||||||
|
token = request.headers.get("Authorization", "")
|
||||||
|
if request.method == "PATCH":
|
||||||
|
data, status = _directus("PATCH", f"/items/objects/{obj_id}", token, body=request.get_json())
|
||||||
|
else:
|
||||||
|
data, status = _directus("DELETE", f"/items/objects/{obj_id}", token)
|
||||||
|
return jsonify(data), status
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/directus/setup-m2m", methods=["POST"])
|
||||||
|
def directus_setup_m2m():
|
||||||
|
"""Einmalig: m2m-Relationen für categories und questions auf objects anlegen."""
|
||||||
|
token = request.headers.get("Authorization", "")
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for rel_name, related_table, related_fk in [
|
||||||
|
("categories", "categories", "categories_id"),
|
||||||
|
("questions", "questions", "questions_id"),
|
||||||
|
]:
|
||||||
|
junction = f"objects_{rel_name}"
|
||||||
|
|
||||||
|
# 1. Altes m2o-Feld entfernen
|
||||||
|
d, s = _directus("DELETE", f"/fields/objects/{rel_name}", token)
|
||||||
|
results.append({"step": f"delete_m2o_{rel_name}", "status": s})
|
||||||
|
|
||||||
|
# 2. Junction-Collection anlegen
|
||||||
|
d, s = _directus("POST", "/collections", token, {
|
||||||
|
"collection": junction,
|
||||||
|
"meta": {"hidden": True, "icon": "import_export"},
|
||||||
|
"schema": {},
|
||||||
|
})
|
||||||
|
results.append({"step": f"create_junction_{junction}", "status": s})
|
||||||
|
|
||||||
|
# 3. Felder der Junction
|
||||||
|
for field_def in [
|
||||||
|
{"field": "id", "type": "integer", "schema": {"has_auto_increment": True, "is_primary_key": True, "is_nullable": False}, "meta": {"hidden": True}},
|
||||||
|
{"field": "objects_id","type": "uuid", "schema": {"foreign_key_table": "objects", "foreign_key_column": "id", "is_nullable": False}, "meta": {"hidden": True}},
|
||||||
|
{"field": related_fk, "type": "uuid", "schema": {"foreign_key_table": related_table, "foreign_key_column": "id", "is_nullable": False}, "meta": {"hidden": True}},
|
||||||
|
]:
|
||||||
|
d, s = _directus("POST", f"/fields/{junction}", token, field_def)
|
||||||
|
results.append({"step": f"field_{junction}_{field_def['field']}", "status": s})
|
||||||
|
|
||||||
|
# 4. Relation junction.objects_id → objects (mit back-reference)
|
||||||
|
d, s = _directus("POST", "/relations", token, {
|
||||||
|
"collection": junction, "field": "objects_id",
|
||||||
|
"related_collection": "objects",
|
||||||
|
"meta": {"one_field": rel_name, "junction_field": related_fk, "sort_field": None},
|
||||||
|
"schema": {"on_delete": "CASCADE"},
|
||||||
|
})
|
||||||
|
results.append({"step": f"relation_{junction}_objects", "status": s})
|
||||||
|
|
||||||
|
# 5. Relation junction.related_fk → related_table
|
||||||
|
d, s = _directus("POST", "/relations", token, {
|
||||||
|
"collection": junction, "field": related_fk,
|
||||||
|
"related_collection": related_table,
|
||||||
|
"schema": {"on_delete": "CASCADE"},
|
||||||
|
})
|
||||||
|
results.append({"step": f"relation_{junction}_{rel_name}", "status": s})
|
||||||
|
|
||||||
|
# 6. Alias-Feld auf objects (m2m)
|
||||||
|
d, s = _directus("POST", "/fields/objects", token, {
|
||||||
|
"field": rel_name, "type": "alias",
|
||||||
|
"meta": {"interface": "list-m2m", "special": ["m2m"], "hidden": False, "width": "full"},
|
||||||
|
"schema": None,
|
||||||
|
})
|
||||||
|
results.append({"step": f"alias_{rel_name}", "status": s})
|
||||||
|
|
||||||
|
return jsonify({"results": results})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/images", methods=["GET"])
|
@app.route("/api/images", methods=["GET"])
|
||||||
|
|||||||
@@ -32,6 +32,57 @@ export function directusAssetUrl(mediaId: string, token: string): string {
|
|||||||
return `${DIRECTUS_URL}/assets/${mediaId}?access_token=${token}`
|
return `${DIRECTUS_URL}/assets/${mediaId}?access_token=${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { DirectusObject, BBox, Point } from './types'
|
||||||
|
|
||||||
|
export async function getDirectusObjects(pictureId: string, token: string): Promise<DirectusObject[]> {
|
||||||
|
const res = await fetch(`/api/directus/objects?picture_id=${pictureId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden der Objekte')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.data as DirectusObject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDirectusObject(payload: {
|
||||||
|
picture: string
|
||||||
|
bbox: BBox | null
|
||||||
|
polygon: Point[] | null
|
||||||
|
user_notes: string | null
|
||||||
|
parent: string | null
|
||||||
|
}, token: string): Promise<DirectusObject> {
|
||||||
|
const res = await fetch('/api/directus/objects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ ...payload, status: 'draft' }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Speichern')
|
||||||
|
return data.data as DirectusObject
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDirectusObject(
|
||||||
|
objId: string,
|
||||||
|
payload: Partial<Pick<DirectusObject, 'user_notes' | 'parent'>>,
|
||||||
|
token: string
|
||||||
|
): Promise<DirectusObject> {
|
||||||
|
const res = await fetch(`/api/directus/objects/${objId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Aktualisieren')
|
||||||
|
return data.data as DirectusObject
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDirectusObject(objId: string, token: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/directus/objects/${objId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Löschen')
|
||||||
|
}
|
||||||
|
|
||||||
export async function getImages(mode: 'draw' | 'generate'): Promise<string[]> {
|
export async function getImages(mode: 'draw' | 'generate'): Promise<string[]> {
|
||||||
const res = await fetch(`/api/images?mode=${mode}`)
|
const res = await fetch(`/api/images?mode=${mode}`)
|
||||||
if (!res.ok) throw new Error('Fehler beim Laden der Bilder')
|
if (!res.ok) throw new Error('Fehler beim Laden der Bilder')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { ObjectMeta, Point, Selection } from '../types'
|
import type { CanvasObject, Point, Selection } from '../types'
|
||||||
|
|
||||||
// Module-level cache: URL → fertig geladenes Image-Objekt
|
// Module-level cache: URL → fertig geladenes Image-Objekt
|
||||||
const imageCache = new Map<string, HTMLImageElement>()
|
const imageCache = new Map<string, HTMLImageElement>()
|
||||||
@@ -17,7 +17,7 @@ export interface DrawCanvasHandle {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
imageSrc: string | null
|
imageSrc: string | null
|
||||||
objects: ObjectMeta[]
|
objects: CanvasObject[]
|
||||||
selectedObjectId: string | null
|
selectedObjectId: string | null
|
||||||
mode: 'rect' | 'polygon'
|
mode: 'rect' | 'polygon'
|
||||||
onHasSelection: (has: boolean) => void
|
onHasSelection: (has: boolean) => void
|
||||||
|
|||||||
@@ -1,160 +1,153 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||||||
import ObjectsList from '../components/ObjectsList'
|
|
||||||
import Topbar from '../components/Topbar'
|
import Topbar from '../components/Topbar'
|
||||||
import { getObjects, cropImage, getDirectusPictures, directusAssetUrl, type DirectusPicture } from '../api'
|
import {
|
||||||
|
getDirectusPictures, directusAssetUrl, type DirectusPicture,
|
||||||
|
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
|
||||||
|
} from '../api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { ObjectMeta, Selection } from '../types'
|
import type { DirectusObject, Selection, CanvasObject } from '../types'
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
|
||||||
title_de: 'Titel',
|
|
||||||
position_de: 'Position',
|
|
||||||
action_de: 'Aktion',
|
|
||||||
condition_de: 'Zustand',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
|
||||||
title_de: 'z.B. Hund',
|
|
||||||
position_de: 'z.B. links oben',
|
|
||||||
action_de: 'z.B. sitzt',
|
|
||||||
condition_de: 'z.B. rostig',
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormKey = 'title_de' | 'position_de' | 'action_de' | 'condition_de'
|
|
||||||
|
|
||||||
const ChevronLeftIcon = () => (
|
const ChevronLeftIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<polyline points="15 18 9 12 15 6" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="9 18 15 12 9 6" />
|
<polyline points="9 18 15 12 9 6" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
const TrashIcon = () => (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export default function DrawIt() {
|
export default function DrawIt() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
|
|
||||||
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||||
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
const [objects, setObjects] = useState<DirectusObject[]>([])
|
||||||
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||||
const [status, setStatus] = useState('')
|
const [userNotes, setUserNotes] = useState('')
|
||||||
const [statusError, setStatusError] = useState(false)
|
const [parentId, setParentId] = useState<string | null>(null)
|
||||||
|
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
||||||
const [mode, setMode] = useState<'rect' | 'polygon'>('rect')
|
const [mode, setMode] = useState<'rect' | 'polygon'>('rect')
|
||||||
const [hasSelection, setHasSelection] = useState(false)
|
const [hasSelection, setHasSelection] = useState(false)
|
||||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
const [saving, setSaving] = useState(false)
|
||||||
const [form, setForm] = useState<Record<FormKey, string>>({
|
const [status, setStatus] = useState('')
|
||||||
title_de: '',
|
const [statusError, setStatusError] = useState(false)
|
||||||
position_de: '',
|
|
||||||
action_de: '',
|
|
||||||
condition_de: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const canvasRef = useRef<DrawCanvasHandle>(null)
|
const canvasRef = useRef<DrawCanvasHandle>(null)
|
||||||
const currentPicture = currentIndex >= 0 && currentIndex < pictureList.length
|
|
||||||
? pictureList[currentIndex]
|
const currentPicture: DirectusPicture | null =
|
||||||
: null
|
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||||
|
|
||||||
|
// Map DirectusObject → CanvasObject for rendering
|
||||||
|
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
|
||||||
|
id: obj.id,
|
||||||
|
visible: obj.visible !== false,
|
||||||
|
bbox: obj.bbox,
|
||||||
|
polygon: obj.polygon,
|
||||||
|
index: i + 1,
|
||||||
|
hierarchy: 1,
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
getDirectusPictures(token)
|
getDirectusPictures(token)
|
||||||
.then(pics => {
|
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
|
||||||
setPictureList(pics)
|
|
||||||
setCurrentIndex(pics.length > 0 ? 0 : -1)
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture) {
|
if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null); return }
|
||||||
setObjects([])
|
getDirectusObjects(currentPicture.id, token)
|
||||||
setSelectedObjectId(null)
|
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
|
||||||
return
|
|
||||||
}
|
|
||||||
getObjects(currentPicture.id)
|
|
||||||
.then(objs => {
|
|
||||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
|
||||||
setSelectedObjectId(objs[0]?.id ?? null)
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [currentPicture?.id])
|
}, [currentPicture?.id, token])
|
||||||
|
|
||||||
|
const showStatus = (msg: string, isError = false) => {
|
||||||
|
setStatus(msg); setStatusError(isError)
|
||||||
|
}
|
||||||
|
|
||||||
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
||||||
|
|
||||||
const showStatus = (msg: string, isError = false) => {
|
|
||||||
setStatus(msg)
|
|
||||||
setStatusError(isError)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSelection = () => {
|
const addSelection = () => {
|
||||||
const sel = canvasRef.current?.getCurrentSelection()
|
const sel = canvasRef.current?.getCurrentSelection()
|
||||||
if (!sel) {
|
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
|
||||||
showStatus('Bitte zuerst einen Bereich auswählen.', true)
|
setCurrentSelections(prev => { const next = [...prev, sel]; showStatus(`Auswahl ${next.length} hinzugefügt.`); return next })
|
||||||
return
|
|
||||||
}
|
|
||||||
setCurrentSelections(prev => {
|
|
||||||
const next = [...prev, sel]
|
|
||||||
showStatus(`Auswahl ${next.length} hinzugefügt.`)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
canvasRef.current?.resetSelection()
|
canvasRef.current?.resetSelection()
|
||||||
setHasSelection(false)
|
setHasSelection(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveObject = async () => {
|
const saveObject = async () => {
|
||||||
if (!currentPicture || currentSelections.length === 0) return
|
if (!currentPicture || !token || currentSelections.length === 0) return
|
||||||
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
showStatus('Speichere Objekt…')
|
// Save one object per selection
|
||||||
const result = await cropImage({
|
const newObjs: DirectusObject[] = []
|
||||||
filename: currentPicture.id,
|
for (const sel of currentSelections) {
|
||||||
selections: currentSelections.map((sel, idx) => ({
|
const obj = await createDirectusObject({
|
||||||
number: idx + 1,
|
picture: currentPicture.id,
|
||||||
mode: sel.mode,
|
bbox: sel.mode === 'rect' ? (sel.bbox ?? null) : null,
|
||||||
bbox: sel.bbox ?? null,
|
polygon: sel.mode === 'polygon' ? (sel.polygon ?? null) : null,
|
||||||
polygon: sel.polygon ?? null,
|
user_notes: userNotes.trim() || null,
|
||||||
})),
|
parent: parentId,
|
||||||
...form,
|
}, token)
|
||||||
})
|
newObjs.push({ ...obj, visible: true })
|
||||||
showStatus(`Gespeichert – ID: ${result.id} (${currentSelections.length} Auswahl(en))`)
|
}
|
||||||
|
setObjects(prev => [...prev, ...newObjs])
|
||||||
setCurrentSelections([])
|
setCurrentSelections([])
|
||||||
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
|
setUserNotes('')
|
||||||
const objs = await getObjects(currentPicture.id)
|
setParentId(null)
|
||||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
canvasRef.current?.resetSelection()
|
||||||
|
showStatus(`${newObjs.length} Objekt(e) gespeichert.`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
|
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNoteEdit = async () => {
|
||||||
|
if (!editingNotes || !token) return
|
||||||
|
try {
|
||||||
|
await updateDirectusObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
|
||||||
|
setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o))
|
||||||
|
setEditingNotes(null)
|
||||||
|
showStatus('Notizen gespeichert.')
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteObject = async (objId: string) => {
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
await deleteDirectusObject(objId, token)
|
||||||
|
setObjects(prev => prev.filter(o => o.id !== objId))
|
||||||
|
if (selectedObjectId === objId) setSelectedObjectId(null)
|
||||||
|
showStatus('Objekt gelöscht.')
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(e instanceof Error ? e.message : 'Fehler beim Löschen.', true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageNav = (
|
const imageNav = (
|
||||||
<div className="image-nav">
|
<div className="image-nav">
|
||||||
<button
|
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
|
||||||
className="btn-icon"
|
|
||||||
onClick={() => setCurrentIndex(i => i - 1)}
|
|
||||||
disabled={currentIndex <= 0}
|
|
||||||
title="Vorheriges Bild"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
<span className="image-counter">
|
<span className="image-counter">
|
||||||
{pictureList.length > 0 ? (
|
{pictureList.length > 0
|
||||||
<>
|
? <><span className="image-counter-num">{currentIndex + 1}</span><span className="image-counter-sep">/</span><span className="image-counter-total">{pictureList.length}</span></>
|
||||||
<span className="image-counter-num">{currentIndex + 1}</span>
|
: <span className="image-counter-empty">Keine Bilder</span>}
|
||||||
<span className="image-counter-sep">/</span>
|
|
||||||
<span className="image-counter-total">{pictureList.length}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="image-counter-empty">Keine Bilder</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
|
||||||
className="btn-icon"
|
|
||||||
onClick={() => setCurrentIndex(i => i + 1)}
|
|
||||||
disabled={currentIndex >= pictureList.length - 1}
|
|
||||||
title="Nächstes Bild"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,20 +158,74 @@ export default function DrawIt() {
|
|||||||
<Topbar page="draw" center={imageNav} />
|
<Topbar page="draw" center={imageNav} />
|
||||||
|
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
{/* Left sidebar: Objects */}
|
{/* Left sidebar: saved objects */}
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-panel" style={{ flex: 1 }}>
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
<h3 className="sidebar-heading">Objekte</h3>
|
<h3 className="sidebar-heading">
|
||||||
<ObjectsList
|
Objekte
|
||||||
objects={objects}
|
{objects.length > 0 && <span className="badge">{objects.length}</span>}
|
||||||
selectedObjectId={selectedObjectId}
|
</h3>
|
||||||
onSelect={setSelectedObjectId}
|
|
||||||
onVisibilityChange={(id, visible) =>
|
{objects.length === 0 ? (
|
||||||
setObjects(prev => prev.map(o => o.id === id ? { ...o, visible } : o))
|
<div className="empty-state">Noch keine Objekte für dieses Bild.</div>
|
||||||
}
|
) : (
|
||||||
onObjectsChange={setObjects}
|
<div className="objects-list">
|
||||||
isGeneratePage={false}
|
{objects.map((obj, i) => (
|
||||||
/>
|
<div
|
||||||
|
key={obj.id}
|
||||||
|
className={`object-item${selectedObjectId === obj.id ? ' selected' : ''}`}
|
||||||
|
onClick={() => setSelectedObjectId(obj.id === selectedObjectId ? null : obj.id)}
|
||||||
|
>
|
||||||
|
<div className="object-item-header">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={obj.visible !== false}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onChange={e => setObjects(prev => prev.map(o => o.id === obj.id ? { ...o, visible: e.target.checked } : o))}
|
||||||
|
/>
|
||||||
|
<div className="object-item-text">
|
||||||
|
<strong>Objekt {i + 1}</strong>
|
||||||
|
<span>{obj.bbox ? `Rect ${Math.round(obj.bbox.width)}×${Math.round(obj.bbox.height)}` : obj.polygon ? `Poly ${obj.polygon.length} Pkt.` : '–'}</span>
|
||||||
|
{obj.parent && <span style={{ color: 'var(--primary-muted-fg)', fontSize: 11 }}>↳ Kind von #{objects.findIndex(o => o.id === obj.parent) + 1}</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="object-icon-button"
|
||||||
|
onClick={e => { e.stopPropagation(); setEditingNotes({ id: obj.id, notes: obj.user_notes ?? '' }) }}
|
||||||
|
title="Notizen bearbeiten"
|
||||||
|
>✏️</button>
|
||||||
|
<button
|
||||||
|
className="object-icon-button"
|
||||||
|
onClick={e => { e.stopPropagation(); deleteObject(obj.id) }}
|
||||||
|
title="Löschen"
|
||||||
|
><TrashIcon /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{obj.user_notes && (
|
||||||
|
<div style={{ padding: '4px 8px 6px 32px', fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.4, borderTop: '1px solid var(--border)' }}>
|
||||||
|
{obj.user_notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingNotes?.id === obj.id && (
|
||||||
|
<div style={{ padding: '8px', borderTop: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<textarea
|
||||||
|
value={editingNotes.notes}
|
||||||
|
onChange={e => setEditingNotes({ ...editingNotes, notes: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', resize: 'vertical', padding: '6px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12 }}
|
||||||
|
placeholder="Notizen zum Objekt…"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button className="btn-primary btn-sm" style={{ flex: 1 }} onClick={saveNoteEdit}>Speichern</button>
|
||||||
|
<button className="btn-ghost btn-sm" onClick={() => setEditingNotes(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -187,12 +234,8 @@ export default function DrawIt() {
|
|||||||
<div className="canvas-frame">
|
<div className="canvas-frame">
|
||||||
<DrawCanvas
|
<DrawCanvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
imageSrc={
|
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
|
||||||
currentPicture && token
|
objects={canvasObjects}
|
||||||
? directusAssetUrl(currentPicture.media, token)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
objects={objects}
|
|
||||||
selectedObjectId={selectedObjectId}
|
selectedObjectId={selectedObjectId}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onHasSelection={handleHasSelection}
|
onHasSelection={handleHasSelection}
|
||||||
@@ -200,117 +243,100 @@ export default function DrawIt() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right sidebar: Controls */}
|
{/* Right sidebar: drawing tools */}
|
||||||
<aside className="sidebar sidebar--right">
|
<aside className="sidebar sidebar--right">
|
||||||
<div className="sidebar-panel">
|
<div className="sidebar-panel">
|
||||||
<h3 className="sidebar-heading">Auswahl-Modus</h3>
|
<h3 className="sidebar-heading">Modus</h3>
|
||||||
<div className="mode-group">
|
<div className="mode-group">
|
||||||
<label className={`mode-btn${mode === 'rect' ? ' active' : ''}`}>
|
<label className={`mode-btn${mode === 'rect' ? ' active' : ''}`}>
|
||||||
<input
|
<input type="radio" name="mode" value="rect" checked={mode === 'rect'} onChange={() => setMode('rect')} />
|
||||||
type="radio"
|
|
||||||
name="mode"
|
|
||||||
value="rect"
|
|
||||||
checked={mode === 'rect'}
|
|
||||||
onChange={() => setMode('rect')}
|
|
||||||
/>
|
|
||||||
<span>Rechteck</span>
|
<span>Rechteck</span>
|
||||||
</label>
|
</label>
|
||||||
<label className={`mode-btn${mode === 'polygon' ? ' active' : ''}`}>
|
<label className={`mode-btn${mode === 'polygon' ? ' active' : ''}`}>
|
||||||
<input
|
<input type="radio" name="mode" value="polygon" checked={mode === 'polygon'} onChange={() => setMode('polygon')} />
|
||||||
type="radio"
|
|
||||||
name="mode"
|
|
||||||
value="polygon"
|
|
||||||
checked={mode === 'polygon'}
|
|
||||||
onChange={() => setMode('polygon')}
|
|
||||||
/>
|
|
||||||
<span>Polygon</span>
|
<span>Polygon</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="action-group">
|
<div className="action-group">
|
||||||
<button
|
<button className="btn-ghost btn-sm btn-block" onClick={() => canvasRef.current?.resetSelection()}>
|
||||||
className="btn-ghost btn-sm btn-block"
|
|
||||||
onClick={() => canvasRef.current?.resetSelection()}
|
|
||||||
>
|
|
||||||
Auswahl zurücksetzen
|
Auswahl zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-panel">
|
<div className="sidebar-panel">
|
||||||
<h3 className="sidebar-heading">Metadaten</h3>
|
<h3 className="sidebar-heading">Notizen zum Objekt</h3>
|
||||||
{(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => (
|
<textarea
|
||||||
<div className="field" key={key}>
|
value={userNotes}
|
||||||
<label className="field-label" htmlFor={key}>{FIELD_LABELS[key]}</label>
|
onChange={e => setUserNotes(e.target.value)}
|
||||||
<input
|
rows={4}
|
||||||
id={key}
|
placeholder="Beschreibung, Besonderheiten, Kontext…"
|
||||||
className="field-input"
|
style={{
|
||||||
type="text"
|
width: '100%', resize: 'vertical', padding: '7px 10px',
|
||||||
value={form[key]}
|
borderRadius: 'var(--r-md)', border: '1px solid var(--border)',
|
||||||
placeholder={FIELD_PLACEHOLDERS[key] || ''}
|
background: 'var(--surface-2)', color: 'var(--text-1)',
|
||||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
fontFamily: 'var(--font)', fontSize: 13, lineHeight: 1.5,
|
||||||
/>
|
transition: 'border-color .15s, box-shadow .15s', outline: 'none',
|
||||||
</div>
|
}}
|
||||||
))}
|
onFocus={e => { e.target.style.borderColor = 'var(--primary)'; e.target.style.boxShadow = '0 0 0 3px rgba(92,108,246,.12)' }}
|
||||||
|
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-panel">
|
||||||
|
<h3 className="sidebar-heading">Parent-Objekt</h3>
|
||||||
|
<select
|
||||||
|
value={parentId ?? ''}
|
||||||
|
onChange={e => setParentId(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">— kein Parent —</option>
|
||||||
|
{objects.map((obj, i) => (
|
||||||
|
<option key={obj.id} value={obj.id}>
|
||||||
|
Objekt {i + 1}{obj.user_notes ? ` – ${obj.user_notes.slice(0, 30)}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-panel">
|
<div className="sidebar-panel">
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading">
|
||||||
Auswahlen
|
Auswahlen
|
||||||
{currentSelections.length > 0 && (
|
{currentSelections.length > 0 && <span className="badge">{currentSelections.length}</span>}
|
||||||
<span className="badge">{currentSelections.length}</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="selections-list">
|
<div className="selections-list">
|
||||||
{currentSelections.length === 0 ? (
|
{currentSelections.length === 0
|
||||||
<div className="empty-state">Noch keine Auswahlen</div>
|
? <div className="empty-state">Noch keine Auswahlen</div>
|
||||||
) : (
|
: currentSelections.map((sel, i) => (
|
||||||
currentSelections.map((sel, i) => (
|
|
||||||
<div className="selection-chip" key={i}>
|
<div className="selection-chip" key={i}>
|
||||||
<span className="selection-chip-num">{i + 1}</span>
|
<span className="selection-chip-num">{i + 1}</span>
|
||||||
<span className="selection-chip-type">
|
<span className="selection-chip-type">{sel.mode === 'rect' ? 'Rect' : 'Poly'}</span>
|
||||||
{sel.mode === 'rect' ? 'Rect' : 'Poly'}
|
|
||||||
</span>
|
|
||||||
<span className="selection-chip-info">
|
<span className="selection-chip-info">
|
||||||
{sel.mode === 'rect' && sel.bbox
|
{sel.mode === 'rect' && sel.bbox
|
||||||
? `${Math.round(sel.bbox.width)}×${Math.round(sel.bbox.height)}`
|
? `${Math.round(sel.bbox.width)}×${Math.round(sel.bbox.height)}`
|
||||||
: `${sel.polygon?.length ?? 0} Pkt.`}
|
: `${sel.polygon?.length ?? 0} Pkt.`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="action-group">
|
<div className="action-group">
|
||||||
<button
|
<button className="btn-primary btn-sm btn-block" onClick={addSelection} disabled={!hasSelection || !currentPicture}>
|
||||||
className="btn-primary btn-sm btn-block"
|
|
||||||
onClick={addSelection}
|
|
||||||
disabled={!hasSelection || !currentPicture}
|
|
||||||
>
|
|
||||||
+ Auswahl hinzufügen
|
+ Auswahl hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-primary btn-sm btn-block"
|
className="btn-primary btn-sm btn-block"
|
||||||
onClick={saveObject}
|
onClick={saveObject}
|
||||||
disabled={!currentPicture || currentSelections.length === 0}
|
disabled={!currentPicture || currentSelections.length === 0 || saving}
|
||||||
>
|
>
|
||||||
Objekt speichern
|
{saving ? 'Speichere…' : `${currentSelections.length > 1 ? currentSelections.length + ' Objekte' : 'Objekt'} speichern`}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-ghost btn-sm btn-block btn-danger"
|
className="btn-ghost btn-sm btn-block btn-danger"
|
||||||
onClick={() => {
|
onClick={() => { setCurrentSelections([]); canvasRef.current?.resetSelection(); showStatus('Alle Auswahlen gelöscht.') }}
|
||||||
setCurrentSelections([])
|
|
||||||
canvasRef.current?.resetSelection()
|
|
||||||
showStatus('Alle Auswahlen gelöscht.')
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Alle löschen
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
|
||||||
{status && (
|
|
||||||
<div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ export interface Sentence {
|
|||||||
answer_advanced_en: string
|
answer_advanced_en: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal interface DrawCanvas needs for rendering objects
|
||||||
|
export interface CanvasObject {
|
||||||
|
id: string
|
||||||
|
visible?: boolean
|
||||||
|
bbox?: BBox | null
|
||||||
|
polygon?: Point[] | null
|
||||||
|
hierarchy?: number
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object as stored in Directus
|
||||||
|
export interface DirectusObject {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
picture: string
|
||||||
|
bbox: BBox | null
|
||||||
|
polygon: Point[] | null
|
||||||
|
user_notes: string | null
|
||||||
|
parent: string | null
|
||||||
|
visible?: boolean // local UI state only
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy — still used by GenerateIt
|
||||||
export interface ObjectMeta {
|
export interface ObjectMeta {
|
||||||
id: string
|
id: string
|
||||||
image_file: string
|
image_file: string
|
||||||
|
|||||||
Reference in New Issue
Block a user