diff --git a/app.py b/app.py index 8947bcc..8a74aba 100644 --- a/app.py +++ b/app.py @@ -38,44 +38,126 @@ def read_prompt(filepath: Path, fallback: str) -> str: 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"]) def directus_auth_login(): """Proxy: Directus-Login ohne CORS-Probleme.""" - try: - body = json.dumps(request.get_json()).encode("utf-8") - 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 + data, status = _directus("POST", "/auth/login", token=None, body=request.get_json()) + return jsonify(data), status @app.route("/api/directus/pictures", methods=["GET"]) def directus_pictures(): - """Proxy: Directus-Bilder (status=new) ohne CORS-Probleme.""" + """Proxy: Directus-Bilder (status=new).""" token = request.headers.get("Authorization", "") - try: - req = urllib.request.Request( - f"{DIRECTUS_URL}/items/pictures?filter[status][_eq]=new&fields=id,media,status&sort=date_created", - headers={"Authorization": token}, - ) - 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 + data, status = _directus("GET", "/items/pictures?filter[status][_eq]=new&fields=id,media,status&sort=date_created", token) + return jsonify(data), status + + +@app.route("/api/directus/objects", methods=["GET", "POST"]) +def directus_objects(): + """Proxy: Objekte laden (GET) oder anlegen (POST).""" + token = request.headers.get("Authorization", "") + if request.method == "GET": + picture_id = request.args.get("picture_id", "") + fields = "id,bbox,polygon,user_notes,parent,status,picture" + path = f"/items/objects?filter[picture][_eq]={picture_id}&fields={fields}&sort=date_created" + 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/", 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"]) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c031211..203897d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -32,6 +32,57 @@ export function directusAssetUrl(mediaId: string, token: string): string { return `${DIRECTUS_URL}/assets/${mediaId}?access_token=${token}` } +import type { DirectusObject, BBox, Point } from './types' + +export async function getDirectusObjects(pictureId: string, token: string): Promise { + 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 { + 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>, + token: string +): Promise { + 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 { + 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 { const res = await fetch(`/api/images?mode=${mode}`) if (!res.ok) throw new Error('Fehler beim Laden der Bilder') diff --git a/frontend/src/components/DrawCanvas.tsx b/frontend/src/components/DrawCanvas.tsx index 7207ca2..ae17623 100644 --- a/frontend/src/components/DrawCanvas.tsx +++ b/frontend/src/components/DrawCanvas.tsx @@ -5,7 +5,7 @@ import { useImperativeHandle, useRef, } from 'react' -import type { ObjectMeta, Point, Selection } from '../types' +import type { CanvasObject, Point, Selection } from '../types' // Module-level cache: URL → fertig geladenes Image-Objekt const imageCache = new Map() @@ -17,7 +17,7 @@ export interface DrawCanvasHandle { interface Props { imageSrc: string | null - objects: ObjectMeta[] + objects: CanvasObject[] selectedObjectId: string | null mode: 'rect' | 'polygon' onHasSelection: (has: boolean) => void diff --git a/frontend/src/pages/DrawIt.tsx b/frontend/src/pages/DrawIt.tsx index 06642f9..01d38f5 100644 --- a/frontend/src/pages/DrawIt.tsx +++ b/frontend/src/pages/DrawIt.tsx @@ -1,160 +1,153 @@ import { useState, useEffect, useCallback, useRef } from 'react' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' -import ObjectsList from '../components/ObjectsList' 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 type { ObjectMeta, Selection } from '../types' - -const FIELD_LABELS: Record = { - title_de: 'Titel', - position_de: 'Position', - action_de: 'Aktion', - condition_de: 'Zustand', -} - -const FIELD_PLACEHOLDERS: Record = { - 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' +import type { DirectusObject, Selection, CanvasObject } from '../types' const ChevronLeftIcon = () => ( ) - const ChevronRightIcon = () => ( ) +const TrashIcon = () => ( + + + +) export default function DrawIt() { const { token } = useAuth() const [pictureList, setPictureList] = useState([]) const [currentIndex, setCurrentIndex] = useState(-1) - const [objects, setObjects] = useState([]) + const [objects, setObjects] = useState([]) + const [selectedObjectId, setSelectedObjectId] = useState(null) const [currentSelections, setCurrentSelections] = useState([]) - const [status, setStatus] = useState('') - const [statusError, setStatusError] = useState(false) + const [userNotes, setUserNotes] = useState('') + const [parentId, setParentId] = useState(null) + const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null) const [mode, setMode] = useState<'rect' | 'polygon'>('rect') const [hasSelection, setHasSelection] = useState(false) - const [selectedObjectId, setSelectedObjectId] = useState(null) - const [form, setForm] = useState>({ - title_de: '', - position_de: '', - action_de: '', - condition_de: '', - }) + const [saving, setSaving] = useState(false) + const [status, setStatus] = useState('') + const [statusError, setStatusError] = useState(false) const canvasRef = useRef(null) - const currentPicture = currentIndex >= 0 && currentIndex < pictureList.length - ? pictureList[currentIndex] - : null + + const currentPicture: DirectusPicture | 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(() => { if (!token) return getDirectusPictures(token) - .then(pics => { - setPictureList(pics) - setCurrentIndex(pics.length > 0 ? 0 : -1) - }) + .then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) }) .catch(console.error) }, [token]) useEffect(() => { - if (!currentPicture) { - setObjects([]) - setSelectedObjectId(null) - return - } - getObjects(currentPicture.id) - .then(objs => { - setObjects(objs.map(o => ({ ...o, visible: true }))) - setSelectedObjectId(objs[0]?.id ?? null) - }) + if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null); return } + getDirectusObjects(currentPicture.id, token) + .then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) }) .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 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 - }) + if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return } + setCurrentSelections(prev => { const next = [...prev, sel]; showStatus(`Auswahl ${next.length} hinzugefügt.`); return next }) canvasRef.current?.resetSelection() setHasSelection(false) } const saveObject = async () => { - if (!currentPicture || currentSelections.length === 0) return + if (!currentPicture || !token || currentSelections.length === 0) return + setSaving(true) try { - showStatus('Speichere Objekt…') - const result = await cropImage({ - filename: currentPicture.id, - 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))`) + // Save one object per selection + const newObjs: DirectusObject[] = [] + for (const sel of currentSelections) { + const obj = await createDirectusObject({ + picture: currentPicture.id, + bbox: sel.mode === 'rect' ? (sel.bbox ?? null) : null, + polygon: sel.mode === 'polygon' ? (sel.polygon ?? null) : null, + user_notes: userNotes.trim() || null, + parent: parentId, + }, token) + newObjs.push({ ...obj, visible: true }) + } + setObjects(prev => [...prev, ...newObjs]) setCurrentSelections([]) - setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' }) - const objs = await getObjects(currentPicture.id) - setObjects(objs.map(o => ({ ...o, visible: true }))) + setUserNotes('') + setParentId(null) + canvasRef.current?.resetSelection() + showStatus(`${newObjs.length} Objekt(e) gespeichert.`) } catch (e) { 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 = (
- - {pictureList.length > 0 ? ( - <> - {currentIndex + 1} - / - {pictureList.length} - - ) : ( - Keine Bilder - )} + {pictureList.length > 0 + ? <>{currentIndex + 1}/{pictureList.length} + : Keine Bilder} -
@@ -165,20 +158,74 @@ export default function DrawIt() {
- {/* Left sidebar: Objects */} + {/* Left sidebar: saved objects */}