Erster Commit
This commit is contained in:
31
.claude/settings.local.json
Normal file
31
.claude/settings.local.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install *)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(echo \"Exit: $?\")",
|
||||
"Bash(npm run *)",
|
||||
"Bash(pip install *)",
|
||||
"Bash(pip show *)",
|
||||
"Bash(pip3 show *)",
|
||||
"Bash(pip3 list *)",
|
||||
"Bash(.venv/bin/pip install *)",
|
||||
"Bash(/opt/homebrew/opt/python@3.12/bin/python3.12 -m venv .venv --clear)",
|
||||
"Bash(python3.13 -m venv .venv --clear)",
|
||||
"Bash(.venv/bin/python -c \"import flask; import flask_cors; import ollama; import PIL; print\\('Alle Imports OK - Flask', flask.__version__\\)\")",
|
||||
"Bash(.venv/bin/python app.py)",
|
||||
"Bash(curl -s http://localhost:8000/api/images?mode=draw)",
|
||||
"Bash(curl -s \"http://localhost:8000/api/images?mode=draw\")",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(curl -s \"http://localhost:3000/\")",
|
||||
"Bash(curl -s \"http://localhost:3000/api/images?mode=draw\")",
|
||||
"mcp__coolify__ping",
|
||||
"mcp__coolify__projects",
|
||||
"mcp__coolify__servers",
|
||||
"Bash(docker build *)",
|
||||
"mcp__coolify__private-keys",
|
||||
"Bash(pkill -f \"python app.py\")",
|
||||
"Bash(curl -s http://localhost:8000/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Nutzerdaten (werden als Volumes gemountet, nicht ins Image gepackt)
|
||||
pictures/
|
||||
objects_image/
|
||||
sentence_object/
|
||||
sentance_object/
|
||||
|
||||
# Git & IDE
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
# Bereits gebaute React-Dateien (werden im Docker-Build neu gebaut)
|
||||
static/react/
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
|
||||
# Nutzerdaten (lokal bleiben, nicht ins Repo)
|
||||
pictures/
|
||||
objects_image/
|
||||
sentence_object/
|
||||
sentance_object/
|
||||
|
||||
# Vite-Build (wird im Docker neu gebaut)
|
||||
static/react/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# ── Stage 1: React-Frontend bauen ───────────────────────────────────────────
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
# /repo entspricht dem Repo-Root im Build-Context
|
||||
WORKDIR /repo/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci --silent
|
||||
COPY frontend/ .
|
||||
# vite.config outDir: ../static/react → schreibt nach /repo/static/react
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Python/Flask Backend ────────────────────────────────────────────
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Bildcodec-Abhängigkeiten für Pillow
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libjpeg-dev libpng-dev libwebp-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python-Pakete
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Anwendungscode
|
||||
COPY app.py .
|
||||
COPY prompts/ prompts/
|
||||
COPY templates/ templates/
|
||||
|
||||
# Leeres static/ vorbereiten, dann React-Build einfügen
|
||||
RUN mkdir -p static/react
|
||||
COPY --from=frontend-builder /repo/static/react ./static/react
|
||||
|
||||
# Datenverzeichnisse (in Coolify als Volumes konfigurieren)
|
||||
RUN mkdir -p pictures objects_image sentence_object
|
||||
|
||||
# Ollama-Verbindung: Standard → Ollama auf dem Host-Gerät
|
||||
# In Coolify als Umgebungsvariable überschreibbar
|
||||
ENV OLLAMA_HOST=http://host.docker.internal:11434
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "app:app"]
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## Bild-Ausschnitt-Tool
|
||||
|
||||
Kurzes Web-Tool, um ein lokales Bild aus dem Ordner `pictures` im Browser anzuzeigen, einen Rechteck-Ausschnitt mit der Maus zu wählen und den Ausschnitt in `objects_image` zu speichern.
|
||||
|
||||
### Installation
|
||||
|
||||
1. In den Projektordner wechseln:
|
||||
|
||||
```bash
|
||||
cd /Users/tim/SynologyDrive/LanguageParent/content_mentor
|
||||
```
|
||||
|
||||
2. (Optional, empfohlen) Virtuelle Umgebung anlegen/aktivieren und Abhängigkeiten installieren:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # macOS / Linux
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Lege deine Quellbilder in den Ordner `pictures` neben dieser Datei.
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Dann im Browser aufrufen:
|
||||
|
||||
```text
|
||||
http://localhost:5000
|
||||
```
|
||||
|
||||
Den Port kannst du in `app.py` im `app.run(...)`-Aufruf anpassen und später per Nginx/Reverse-Proxy von außen erreichbar machen.
|
||||
|
||||
815
app.py
Normal file
815
app.py
Normal file
@@ -0,0 +1,815 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
import json
|
||||
|
||||
from flask import Flask, send_from_directory, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from PIL import Image
|
||||
import ollama
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PICTURES_DIR = BASE_DIR / "pictures"
|
||||
OBJECTS_DIR = BASE_DIR / "objects_image"
|
||||
SENTENCE_DIR = BASE_DIR / "sentence_object"
|
||||
PROMPTS_DIR = BASE_DIR / "prompts"
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(BASE_DIR / "templates"),
|
||||
static_folder=str(BASE_DIR / "static"),
|
||||
)
|
||||
CORS(app)
|
||||
|
||||
|
||||
def read_prompt(filepath: Path, fallback: str) -> str:
|
||||
"""
|
||||
Hilfsfunktion, um Prompt-Dateien zu lesen.
|
||||
"""
|
||||
try:
|
||||
if filepath.exists():
|
||||
return filepath.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return fallback.strip()
|
||||
|
||||
|
||||
@app.route("/api/images", methods=["GET"])
|
||||
def list_images():
|
||||
"""
|
||||
Gibt alle Bilder im pictures-Ordner zurück.
|
||||
Query-Parameter: ?mode=draw (Standard) oder ?mode=generate
|
||||
draw → Bilder ohne _saved-Suffix
|
||||
generate → Bilder mit _saved-Suffix
|
||||
"""
|
||||
mode = request.args.get("mode", "draw")
|
||||
PICTURES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "generate":
|
||||
image_paths = sorted(
|
||||
[f for f in PICTURES_DIR.iterdir() if f.is_file() and f.stem.endswith("_saved")],
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
)
|
||||
else:
|
||||
image_paths = sorted(
|
||||
[f for f in PICTURES_DIR.iterdir() if f.is_file() and not f.stem.endswith("_saved")],
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
)
|
||||
return jsonify({"images": [p.name for p in image_paths]})
|
||||
|
||||
|
||||
REACT_BUILD_DIR = BASE_DIR / "static" / "react"
|
||||
|
||||
|
||||
def _serve_spa():
|
||||
"""Liefert die React SPA index.html für alle Frontend-Routen."""
|
||||
return send_from_directory(REACT_BUILD_DIR, "index.html")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@app.route("/draw")
|
||||
@app.route("/generate")
|
||||
def serve_spa():
|
||||
return _serve_spa()
|
||||
|
||||
|
||||
@app.route("/assets/<path:filename>")
|
||||
def serve_react_assets(filename: str):
|
||||
"""Liefert JS/CSS-Assets aus dem Vite-Build."""
|
||||
return send_from_directory(REACT_BUILD_DIR / "assets", filename)
|
||||
|
||||
|
||||
@app.route("/pictures/<path:filename>")
|
||||
def serve_picture(filename: str):
|
||||
# Statisches Ausliefern der Originalbilder
|
||||
return send_from_directory(PICTURES_DIR, filename)
|
||||
|
||||
|
||||
@app.route("/objects_image/<path:filename>")
|
||||
def serve_object_image(filename: str):
|
||||
# Ausliefern der gespeicherten Ausschnitt-Bilder
|
||||
return send_from_directory(OBJECTS_DIR, filename)
|
||||
|
||||
|
||||
@app.route("/api/objects", methods=["GET"])
|
||||
def list_objects_for_image():
|
||||
"""
|
||||
Gibt alle gespeicherten Objekte (Ausschnitte) zu einem Quellbild zurück.
|
||||
|
||||
Query-Parameter:
|
||||
?filename=testbild.png
|
||||
"""
|
||||
filename = request.args.get("filename")
|
||||
if not filename:
|
||||
return jsonify({"error": "Missing filename query parameter"}), 400
|
||||
|
||||
# Für *_saved-Bilder auch Objekte berücksichtigen, deren source_filename
|
||||
# noch auf den ursprünglichen Namen ohne "_saved" zeigt (Kompatibilität)
|
||||
base_filename = filename
|
||||
legacy_filename = None
|
||||
if base_filename.endswith(".png") or base_filename.endswith(".jpg") or base_filename.endswith(".jpeg") or base_filename.endswith(".webp"):
|
||||
stem = base_filename.rsplit(".", 1)[0]
|
||||
suffix = base_filename.rsplit(".", 1)[1]
|
||||
if stem.endswith("_saved"):
|
||||
legacy_filename = f"{stem[:-6]}.{suffix}"
|
||||
|
||||
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
objects = []
|
||||
for meta_file in OBJECTS_DIR.glob("*.txt"):
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
src_name = meta.get("source_filename")
|
||||
if src_name != filename and (legacy_filename is None or src_name != legacy_filename):
|
||||
continue
|
||||
|
||||
objects.append(
|
||||
{
|
||||
"id": meta.get("id"),
|
||||
"image_file": meta.get("image_file"),
|
||||
"title_de": meta.get("title_de", ""),
|
||||
"position_de": meta.get("position_de", ""),
|
||||
"action_de": meta.get("action_de", ""),
|
||||
"condition_de": meta.get("condition_de", ""),
|
||||
# Von KI erzeugte Details (optional)
|
||||
"label_en": meta.get("label_en"),
|
||||
"label_de": meta.get("label_de"),
|
||||
"label_se": meta.get("label_se"),
|
||||
"color_en": meta.get("color_en"),
|
||||
"adjective_en": meta.get("adjective_en"),
|
||||
"action_verb_en": meta.get("action_verb_en"),
|
||||
"preposition_en": meta.get("preposition_en"),
|
||||
"relative_position_en": meta.get("relative_position_en"),
|
||||
"season_en": meta.get("season_en"),
|
||||
"mode": meta.get("mode"),
|
||||
"bbox": meta.get("bbox"),
|
||||
"polygon": meta.get("polygon"),
|
||||
"hierarchy": meta.get("hierarchy", 1),
|
||||
"parent_id": meta.get("parent_id"),
|
||||
"created_at": meta.get("created_at"),
|
||||
}
|
||||
)
|
||||
|
||||
# Älteste zuerst sortieren (1 = ältestes Objekt)
|
||||
objects.sort(key=lambda o: o.get("created_at") or "")
|
||||
|
||||
# Laufende Nummer je Bild (1..n) nach obiger Sortierung
|
||||
for idx, obj in enumerate(objects, start=1):
|
||||
obj["index"] = idx
|
||||
|
||||
return jsonify({"objects": objects})
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>/hierarchy", methods=["POST"])
|
||||
def update_object_hierarchy(obj_id: str):
|
||||
"""
|
||||
Aktualisiert die Hierarchie (1, 2 oder 3) eines gespeicherten Objekts.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
try:
|
||||
hierarchy = int(data.get("hierarchy"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid hierarchy"}), 400
|
||||
|
||||
if hierarchy not in (1, 2, 3):
|
||||
return jsonify({"error": "Hierarchy must be 1, 2 or 3"}), 400
|
||||
|
||||
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
|
||||
if not meta_path.exists():
|
||||
return jsonify({"error": "Object not found"}), 404
|
||||
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read meta file"}), 500
|
||||
|
||||
meta["hierarchy"] = hierarchy
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return jsonify({"id": obj_id, "hierarchy": hierarchy})
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>", methods=["POST"])
|
||||
def update_object_meta(obj_id: str):
|
||||
"""
|
||||
Aktualisiert Text-Metadaten (title_de, position_de, action_de, condition_de)
|
||||
eines gespeicherten Objekts.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
|
||||
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
|
||||
if not meta_path.exists():
|
||||
return jsonify({"error": "Object not found"}), 404
|
||||
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read meta file"}), 500
|
||||
|
||||
for key in ("title_de", "position_de", "action_de", "condition_de"):
|
||||
if key in data:
|
||||
meta[key] = data.get(key, "")
|
||||
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return jsonify({k: meta.get(k) for k in ("id", "title_de", "position_de", "action_de", "condition_de")})
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>/parent", methods=["POST"])
|
||||
def update_object_parent(obj_id: str):
|
||||
"""
|
||||
Aktualisiert die Parent-Relation eines Objekts.
|
||||
Erwartet JSON: { "parent_id": "<uuid>" | null }
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
parent_id = data.get("parent_id")
|
||||
|
||||
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
|
||||
if not meta_path.exists():
|
||||
return jsonify({"error": "Object not found"}), 404
|
||||
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read meta file"}), 500
|
||||
|
||||
# Optional: prüfen, ob parent_id existiert und zum selben Bild gehört
|
||||
if parent_id:
|
||||
parent_meta_path = OBJECTS_DIR / f"{parent_id}.txt"
|
||||
if not parent_meta_path.exists():
|
||||
return jsonify({"error": "Parent object not found"}), 400
|
||||
try:
|
||||
parent_meta = json.loads(parent_meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read parent meta file"}), 500
|
||||
|
||||
if parent_meta.get("source_filename") != meta.get("source_filename"):
|
||||
return jsonify({"error": "Parent must belong to same source image"}), 400
|
||||
|
||||
meta["parent_id"] = parent_id or None
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return jsonify({"id": obj_id, "parent_id": meta.get("parent_id")})
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>/generate_details", methods=["POST"])
|
||||
def generate_object_details(obj_id: str):
|
||||
"""
|
||||
Erzeugt mit Llama 3.2 Vision zusätzliche englische Metadaten zu einem Objekt
|
||||
auf Basis des ausgeschnittenen Objektbildes + vorhandener deutscher Felder.
|
||||
"""
|
||||
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
|
||||
if not meta_path.exists():
|
||||
return jsonify({"error": "Object not found"}), 404
|
||||
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read meta file"}), 500
|
||||
|
||||
image_file = meta.get("image_file")
|
||||
if not image_file:
|
||||
return jsonify({"error": "Object has no image_file"}), 400
|
||||
|
||||
image_path = OBJECTS_DIR / image_file
|
||||
if not image_path.exists():
|
||||
return jsonify({"error": "Object image not found"}), 404
|
||||
|
||||
# Prompt laden
|
||||
prompt_template = read_prompt(
|
||||
PROMPTS_DIR / "create_details.txt",
|
||||
"Analyze this object and return one JSON object with the requested fields.",
|
||||
)
|
||||
|
||||
title_de = meta.get("title_de", "") or ""
|
||||
action_de = meta.get("action_de", "") or ""
|
||||
condition_de = meta.get("condition_de", "") or ""
|
||||
|
||||
# Platzhalter im Prompt ersetzen (einfache .format-Verwendung)
|
||||
try:
|
||||
prompt = prompt_template.format(
|
||||
title_de=title_de,
|
||||
action_de=action_de,
|
||||
condition_de=condition_de,
|
||||
)
|
||||
except Exception:
|
||||
# Falls das Format fehlschlägt, einfach Rohprompt + Kontext anhängen
|
||||
prompt = (
|
||||
f"{prompt_template}\n\n"
|
||||
f"Titel (Deutsch): {title_de}\n"
|
||||
f"Status (Deutsch): {action_de}\n"
|
||||
f"Zustand (Deutsch): {condition_de}\n"
|
||||
)
|
||||
|
||||
# Bild als Bytes laden
|
||||
try:
|
||||
img_bytes = image_path.read_bytes()
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read object image"}), 500
|
||||
|
||||
try:
|
||||
response = ollama.chat(
|
||||
model="llama3.2-vision",
|
||||
messages=[{"role": "user", "content": prompt, "images": [img_bytes]}],
|
||||
format="json",
|
||||
options={"temperature": 0},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"LLM request failed: {e}"}), 500
|
||||
|
||||
content = response.get("message", {}).get("content")
|
||||
if not content:
|
||||
return jsonify({"error": "Empty response from LLM"}), 500
|
||||
|
||||
try:
|
||||
ai_data = json.loads(content)
|
||||
except Exception:
|
||||
# Fallback: versuchen, das erste JSON-Objekt zu extrahieren
|
||||
try:
|
||||
start = content.index("{")
|
||||
end = content.rindex("}") + 1
|
||||
ai_data = json.loads(content[start:end])
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not parse JSON from LLM response"}), 500
|
||||
|
||||
# Debug-Ausgabe des rohen AI-Outputs
|
||||
try:
|
||||
print(f"[generate_details] Raw AI data for {obj_id}: {json.dumps(ai_data, ensure_ascii=False)}")
|
||||
except Exception:
|
||||
print(f"[generate_details] Raw AI data for {obj_id} (non-serializable): {ai_data!r}")
|
||||
|
||||
# Erwartete Felder übernehmen (gemäß neuem Prompt)
|
||||
fields = [
|
||||
"label_en",
|
||||
"label_de",
|
||||
"label_se",
|
||||
"color_en",
|
||||
"adjective_en",
|
||||
"action_verb_en",
|
||||
"preposition_en",
|
||||
"relative_position_en",
|
||||
"season_en",
|
||||
]
|
||||
for key in fields:
|
||||
if key in ai_data:
|
||||
meta[key] = ai_data.get(key)
|
||||
|
||||
# Metadatei zurückschreiben
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# Nur die neuen Felder zurückgeben (None als leeren String konvertieren)
|
||||
result = {key: (meta.get(key) or "") for key in fields}
|
||||
|
||||
try:
|
||||
print(f"[generate_details] Stored meta for {obj_id}: {json.dumps(result, ensure_ascii=False)}")
|
||||
except Exception:
|
||||
print(f"[generate_details] Stored meta for {obj_id}: {result!r}")
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def load_sentences_for_object(obj_id: str):
|
||||
"""
|
||||
Lädt alle gespeicherten Sätze für ein Objekt aus sentence_object/<obj_id>.txt.
|
||||
Rückgabe: Liste von Dicts mit mindestens question_en, answer_en, created_at.
|
||||
"""
|
||||
SENTENCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sentence_path = SENTENCE_DIR / f"{obj_id}.txt"
|
||||
if not sentence_path.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(sentence_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
# Fallback: einzelnes Objekt in Liste wrappen
|
||||
return [data]
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>/sentences", methods=["GET"])
|
||||
def get_object_sentences(obj_id: str):
|
||||
"""
|
||||
Gibt alle bisher erzeugten KI-Sätze zu einem Objekt zurück.
|
||||
"""
|
||||
sentences = load_sentences_for_object(obj_id)
|
||||
return jsonify({"object_id": obj_id, "sentences": sentences})
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>/generate_sentence", methods=["POST"])
|
||||
def generate_object_sentence(obj_id: str):
|
||||
"""
|
||||
Erzeugt mit Llama 3.1 einen neuen englischen Frage-Antwort-Satz
|
||||
zu einem Objekt basierend auf den KI-Details und bisherigen Sätzen.
|
||||
Speichert alle Sätze in sentence_object/<obj_id>.txt.
|
||||
"""
|
||||
# Objekt-Metadaten laden (inkl. KI-Details)
|
||||
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
|
||||
if not meta_path.exists():
|
||||
return jsonify({"error": "Object not found"}), 404
|
||||
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not read meta file"}), 500
|
||||
|
||||
# Relevante Felder für den Satz (KI-Details)
|
||||
details = {
|
||||
"label_en": meta.get("label_en"),
|
||||
"label_de": meta.get("label_de"),
|
||||
"label_se": meta.get("label_se"),
|
||||
"color_en": meta.get("color_en"),
|
||||
"adjective_en": meta.get("adjective_en"),
|
||||
"action_verb_en": meta.get("action_verb_en"),
|
||||
"preposition_en": meta.get("preposition_en"),
|
||||
"relative_position_en": meta.get("relative_position_en"),
|
||||
"season_en": meta.get("season_en"),
|
||||
"title_de": meta.get("title_de"),
|
||||
"position_de": meta.get("position_de"),
|
||||
"action_de": meta.get("action_de"),
|
||||
"condition_de": meta.get("condition_de"),
|
||||
}
|
||||
|
||||
# Bisherige Sätze laden
|
||||
previous_sentences = load_sentences_for_object(obj_id)
|
||||
|
||||
# Prompt laden
|
||||
prompt_template = read_prompt(
|
||||
PROMPTS_DIR / "create_sentence.txt",
|
||||
"Create one new English question_en and answer_en as JSON, based on the object details and avoiding previous sentences.",
|
||||
)
|
||||
|
||||
# Vollständigen Prompt zusammensetzen
|
||||
try:
|
||||
details_json = json.dumps(details, ensure_ascii=False, indent=2)
|
||||
prev_json = json.dumps(previous_sentences, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
details_json = str(details)
|
||||
prev_json = str(previous_sentences)
|
||||
|
||||
prompt = (
|
||||
f"{prompt_template}\n\n"
|
||||
f"OBJECT_DETAILS_JSON:\n{details_json}\n\n"
|
||||
f"PREVIOUS_SENTENCES_JSON:\n{prev_json}\n"
|
||||
)
|
||||
|
||||
# LLM-Aufruf (nur Text, kein Bild nötig)
|
||||
try:
|
||||
response = ollama.chat(
|
||||
model="llama3.1",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
format="json",
|
||||
options={"temperature": 0.2},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"LLM request failed: {e}"}), 500
|
||||
|
||||
content = response.get("message", {}).get("content")
|
||||
if not content:
|
||||
return jsonify({"error": "Empty response from LLM"}), 500
|
||||
|
||||
try:
|
||||
ai_data = json.loads(content)
|
||||
except Exception:
|
||||
# Fallback: erstes JSON-Objekt extrahieren
|
||||
try:
|
||||
start = content.index("{")
|
||||
end = content.rindex("}") + 1
|
||||
ai_data = json.loads(content[start:end])
|
||||
except Exception:
|
||||
return jsonify({"error": "Could not parse JSON from LLM response"}), 500
|
||||
|
||||
question_simple = (ai_data.get("question_simple_en") or "").strip()
|
||||
answer_simple = (ai_data.get("answer_simple_en") or "").strip()
|
||||
question_advanced = (ai_data.get("question_advanced_en") or "").strip()
|
||||
answer_advanced = (ai_data.get("answer_advanced_en") or "").strip()
|
||||
|
||||
if not question_simple or not answer_simple or not question_advanced or not answer_advanced:
|
||||
return jsonify({"error": "LLM response missing required fields (question_simple_en, answer_simple_en, question_advanced_en, answer_advanced_en)"}), 500
|
||||
|
||||
# Neuen Satz mit Timestamp anreichern
|
||||
entry = {
|
||||
"object_id": obj_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"question_simple_en": question_simple,
|
||||
"answer_simple_en": answer_simple,
|
||||
"question_advanced_en": question_advanced,
|
||||
"answer_advanced_en": answer_advanced,
|
||||
}
|
||||
|
||||
sentences = previous_sentences + [entry]
|
||||
|
||||
# In Datei speichern
|
||||
SENTENCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sentence_path = SENTENCE_DIR / f"{obj_id}.txt"
|
||||
sentence_path.write_text(json.dumps(sentences, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return jsonify({"object_id": obj_id, "sentence": entry, "count": len(sentences)})
|
||||
|
||||
|
||||
@app.route("/api/image/save", methods=["POST"])
|
||||
def save_image():
|
||||
"""
|
||||
Markiert ein Bild und alle zugehörigen Objekte als 'gespeichert',
|
||||
indem der Dateiname um '_saved' erweitert wird und die Metadaten
|
||||
der Objekte angepasst werden.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
filename = data.get("filename")
|
||||
if not filename:
|
||||
return jsonify({"error": "Missing filename"}), 400
|
||||
|
||||
src_path = PICTURES_DIR / filename
|
||||
if not src_path.exists():
|
||||
return jsonify({"error": "Image not found"}), 404
|
||||
|
||||
if src_path.stem.endswith("_saved"):
|
||||
return jsonify({"error": "Image already saved"}), 400
|
||||
|
||||
new_name = f"{src_path.stem}_saved{src_path.suffix}"
|
||||
dst_path = PICTURES_DIR / new_name
|
||||
|
||||
# Bild umbenennen
|
||||
src_path.rename(dst_path)
|
||||
|
||||
# Zugehörige Objekte aktualisieren
|
||||
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for meta_file in OBJECTS_DIR.glob("*.txt"):
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if meta.get("source_filename") != filename:
|
||||
continue
|
||||
|
||||
meta["source_filename"] = new_name
|
||||
meta_file.write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return jsonify({"old_name": filename, "new_name": new_name})
|
||||
|
||||
|
||||
@app.route("/api/crop", methods=["POST"])
|
||||
def crop_image():
|
||||
"""
|
||||
Erwartet JSON:
|
||||
{
|
||||
"filename": "bild.jpg",
|
||||
"selections": [
|
||||
{
|
||||
"number": 1,
|
||||
"mode": "rect" | "polygon",
|
||||
"bbox": {"x": 10, "y": 20, "width": 200, "height": 150}, // für rect
|
||||
"polygon": [{"x": 10, "y": 20}, ...] // für polygon
|
||||
},
|
||||
...
|
||||
],
|
||||
"title_de": "...",
|
||||
"position_de": "...",
|
||||
"action_de": "...",
|
||||
"condition_de": "..."
|
||||
}
|
||||
|
||||
ODER (Legacy-Format für Kompatibilität):
|
||||
{
|
||||
"filename": "bild.jpg",
|
||||
"mode": "rect" | "polygon",
|
||||
"x": 10, "y": 20, "width": 200, "height": 150, // für rect
|
||||
"polygon": [{"x": 10, "y": 20}, ...] // für polygon
|
||||
}
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
|
||||
if "filename" not in data:
|
||||
return jsonify({"error": "Missing filename"}), 400
|
||||
|
||||
src_path = PICTURES_DIR / data["filename"]
|
||||
if src_path.stem.endswith("_saved"):
|
||||
return jsonify({"error": "Cannot crop on saved image"}), 400
|
||||
if not src_path.exists():
|
||||
return jsonify({"error": "Source image not found"}), 404
|
||||
|
||||
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# UUID für Bild + Metadaten
|
||||
obj_id = uuid4().hex
|
||||
timestamp = datetime.now().isoformat()
|
||||
out_image_name = f"{obj_id}{src_path.suffix}"
|
||||
out_image_path = OBJECTS_DIR / out_image_name
|
||||
out_meta_path = OBJECTS_DIR / f"{obj_id}.txt"
|
||||
|
||||
# Prüfen, ob neues Format (selections) oder Legacy-Format
|
||||
selections = data.get("selections")
|
||||
|
||||
if selections and isinstance(selections, list) and len(selections) > 0:
|
||||
# Neues Format: mehrere Auswahlen
|
||||
processed_selections = []
|
||||
|
||||
with Image.open(src_path) as img:
|
||||
img_w, img_h = img.size
|
||||
|
||||
for sel in selections:
|
||||
sel_mode = sel.get("mode")
|
||||
if sel_mode == "rect":
|
||||
bbox = sel.get("bbox")
|
||||
if not bbox:
|
||||
continue
|
||||
try:
|
||||
x = int(bbox.get("x", 0))
|
||||
y = int(bbox.get("y", 0))
|
||||
w = int(bbox.get("width", 0))
|
||||
h = int(bbox.get("height", 0))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
|
||||
x2 = min(x + w, img_w)
|
||||
y2 = min(y + h, img_h)
|
||||
x1 = max(0, x)
|
||||
y1 = max(0, y)
|
||||
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
continue
|
||||
|
||||
processed_selections.append({
|
||||
"number": sel.get("number", len(processed_selections) + 1),
|
||||
"mode": "rect",
|
||||
"bbox": {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1},
|
||||
})
|
||||
|
||||
elif sel_mode == "polygon":
|
||||
polygon = sel.get("polygon")
|
||||
if not isinstance(polygon, list) or len(polygon) < 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
xs = [int(p.get("x", 0)) for p in polygon]
|
||||
ys = [int(p.get("y", 0)) for p in polygon]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
|
||||
min_x = max(min(xs), 0)
|
||||
min_y = max(min(ys), 0)
|
||||
max_x = min(max(xs), img_w)
|
||||
max_y = min(max(ys), img_h)
|
||||
|
||||
if min_x >= max_x or min_y >= max_y:
|
||||
continue
|
||||
|
||||
processed_selections.append({
|
||||
"number": sel.get("number", len(processed_selections) + 1),
|
||||
"mode": "polygon",
|
||||
"polygon": polygon,
|
||||
"bbox": {"x": min_x, "y": min_y, "width": max_x - min_x, "height": max_y - min_y},
|
||||
})
|
||||
|
||||
if not processed_selections:
|
||||
return jsonify({"error": "No valid selections provided"}), 400
|
||||
|
||||
# Erstes Bild aus erster Auswahl erstellen
|
||||
first_sel = processed_selections[0]
|
||||
try:
|
||||
with Image.open(src_path) as img:
|
||||
if first_sel["mode"] == "rect":
|
||||
bbox = first_sel["bbox"]
|
||||
x1 = bbox["x"]
|
||||
y1 = bbox["y"]
|
||||
x2 = x1 + bbox["width"]
|
||||
y2 = y1 + bbox["height"]
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
else: # polygon - verwende Bounding-Box für das Bild
|
||||
bbox = first_sel["bbox"]
|
||||
x1 = bbox["x"]
|
||||
y1 = bbox["y"]
|
||||
x2 = x1 + bbox["width"]
|
||||
y2 = y1 + bbox["height"]
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
|
||||
# Bild speichern (Format beibehalten)
|
||||
img_format = img.format or "PNG"
|
||||
cropped.save(out_image_path, format=img_format)
|
||||
print(f"[crop_image] Bild gespeichert: {out_image_path} (Größe: {cropped.size}, Format: {img_format})")
|
||||
except Exception as e:
|
||||
print(f"[crop_image] Fehler beim Erstellen des Bildes: {e}")
|
||||
return jsonify({"error": f"Failed to create image: {e}"}), 500
|
||||
|
||||
# Metadaten mit allen Auswahlen speichern
|
||||
meta = {
|
||||
"id": obj_id,
|
||||
"created_at": timestamp,
|
||||
"source_filename": data["filename"],
|
||||
"mode": processed_selections[0]["mode"], # Legacy-Feld
|
||||
"bbox": processed_selections[0]["bbox"], # Legacy-Feld
|
||||
"image_file": out_image_name,
|
||||
"hierarchy": 1,
|
||||
"parent_id": None,
|
||||
"title_de": data.get("title_de", ""),
|
||||
"position_de": data.get("position_de", ""),
|
||||
"action_de": data.get("action_de", ""),
|
||||
"condition_de": data.get("condition_de", ""),
|
||||
"selections": processed_selections, # Neue Feld: alle Auswahlen nummeriert
|
||||
}
|
||||
if processed_selections[0]["mode"] == "polygon":
|
||||
meta["polygon"] = processed_selections[0]["polygon"] # Legacy-Feld
|
||||
|
||||
else:
|
||||
# Legacy-Format: einzelne Auswahl (für Rückwärtskompatibilität)
|
||||
mode = data.get("mode", "rect")
|
||||
if mode not in {"rect", "polygon"}:
|
||||
return jsonify({"error": "Invalid mode"}), 400
|
||||
|
||||
with Image.open(src_path) as img:
|
||||
img_w, img_h = img.size
|
||||
if mode == "rect":
|
||||
try:
|
||||
x = int(data["x"])
|
||||
y = int(data["y"])
|
||||
w = int(data["width"])
|
||||
h = int(data["height"])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return jsonify({"error": "Invalid rectangle coordinates"}), 400
|
||||
|
||||
if w <= 0 or h <= 0:
|
||||
return jsonify({"error": "Width and height must be positive"}), 400
|
||||
|
||||
x2 = min(x + w, img_w)
|
||||
y2 = min(y + h, img_h)
|
||||
x1 = max(0, x)
|
||||
y1 = max(0, y)
|
||||
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
return jsonify({"error": "Crop area outside of image"}), 400
|
||||
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1}
|
||||
else: # polygon
|
||||
polygon = data.get("polygon") or []
|
||||
if not isinstance(polygon, list) or len(polygon) < 3:
|
||||
return jsonify({"error": "Polygon must have at least 3 points"}), 400
|
||||
|
||||
try:
|
||||
xs = [int(p["x"]) for p in polygon]
|
||||
ys = [int(p["y"]) for p in polygon]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid polygon coordinates"}), 400
|
||||
|
||||
min_x = max(min(xs), 0)
|
||||
min_y = max(min(ys), 0)
|
||||
max_x = min(max(xs), img_w)
|
||||
max_y = min(max(ys), img_h)
|
||||
|
||||
if min_x >= max_x or min_y >= max_y:
|
||||
return jsonify({"error": "Polygon bounding box outside of image"}), 400
|
||||
|
||||
cropped = img.crop((min_x, min_y, max_x, max_y))
|
||||
bbox = {
|
||||
"x": min_x,
|
||||
"y": min_y,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
}
|
||||
|
||||
cropped.save(out_image_path)
|
||||
|
||||
# Metadaten als JSON in .txt speichern
|
||||
meta = {
|
||||
"id": obj_id,
|
||||
"created_at": timestamp,
|
||||
"source_filename": data["filename"],
|
||||
"mode": mode,
|
||||
"bbox": bbox,
|
||||
"image_file": out_image_name,
|
||||
"hierarchy": 1,
|
||||
"parent_id": None,
|
||||
"title_de": data.get("title_de", ""),
|
||||
"position_de": data.get("position_de", ""),
|
||||
"action_de": data.get("action_de", ""),
|
||||
"condition_de": data.get("condition_de", ""),
|
||||
}
|
||||
if mode == "polygon":
|
||||
meta["polygon"] = data.get("polygon")
|
||||
|
||||
out_meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"id": obj_id,
|
||||
"image_file": out_image_name,
|
||||
"meta_file": out_meta_path.name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Du kannst den Port hier anpassen, z.B. 8000 oder 5000.
|
||||
# Für externen Zugriff später einfach in Nginx/Reverse Proxy weiterleiten.
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
39
prompts/create_details.txt
Normal file
39
prompts/create_details.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
Du bist eine hilfreiche Bild-KI auf Basis von Llama 3.1.
|
||||
|
||||
Du erhältst:
|
||||
- einen BILDAUSSCHNITT eines einzelnen Objektes (z.B. ausgeschnittenes Boot, Mensch, Tier, Gebäude, usw.)
|
||||
- bereits vorhandene Metadaten zum Objekt in DEUTSCH.
|
||||
|
||||
Deine Aufgabe:
|
||||
- Analysiere ausschließlich dieses eine Objekt im Bildausschnitt.
|
||||
- Nutze die deutschen Metadaten nur als Zusatzkontext, falls sinnvoll.
|
||||
- Antworte **immer** mit GENAU EINEM JSON-OBJEKT, ohne erklärenden Text davor oder dahinter.
|
||||
- Verwende **englische** Begriffe in den Werten, außer dort, wo explizit etwas anderes verlangt ist.
|
||||
|
||||
Rules for fields:
|
||||
1. label_en: Single noun, singular (e.g., "cloud", "lighthouse").
|
||||
2. label_de: German translation, singular, lowercase (e.g., "wolke").
|
||||
3. label_se: Swedish translation, singular, lowercase (e.g., "moln").
|
||||
4. color_en: Main color as a single word (e.g., "white", "grey").
|
||||
5. adjective_en: One descriptive adjective about condition/appearance (e.g., "fluffy", "dark", "weathered").
|
||||
6. action_verb_en: One verb ending in -ing describing the state (e.g., "floating", "shining", "standing").
|
||||
7. preposition_en: The most fitting preposition to describe its location (e.g., "in", "on", "above", "behind").
|
||||
8. relative_position_en: The object it is positioned relative to (e.g., "sky", "water", "hill").
|
||||
9. season_en: The most likely season (e.g., "summer", "autumn").
|
||||
|
||||
Output format:
|
||||
{
|
||||
"label_en": "...",
|
||||
"label_de": "...",
|
||||
"label_se": "...",
|
||||
"color_en": "...",
|
||||
"adjective_en": "...",
|
||||
"action_verb_en": "...",
|
||||
"preposition_en": "...",
|
||||
"relative_position_en": "...",
|
||||
"season_en": "..."
|
||||
}
|
||||
|
||||
WICHTIG:
|
||||
- Gib **nur** dieses JSON-Objekt zurück.
|
||||
- Keine Erklärungen, keine Kommentare, kein Markdown.
|
||||
44
prompts/create_sentence.txt
Normal file
44
prompts/create_sentence.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
You are an expert ESL teacher and dialogue writer.
|
||||
|
||||
Your goal: Create **TWO** short, natural **English question and answer pairs** that a language teacher can use to talk about ONE object in an image.
|
||||
|
||||
You will receive, in the user message:
|
||||
- JSON with details about the object (fields like label_en, label_de, color_en, adjective_en, action_verb_en, preposition_en, relative_position_en, season_en, etc.)
|
||||
- JSON list of previous sentences for this object (each with question_simple_en, answer_simple_en, question_advanced_en, answer_advanced_en).
|
||||
|
||||
### Task
|
||||
1. Read the object details and understand what the object is, what it looks like, what it is doing and where it is.
|
||||
2. Read the previous sentences and **avoid repeating the same question or answer meaning**.
|
||||
3. Create **TWO** question and answer pairs in English:
|
||||
- **SIMPLE** (for children): Very simple language, A1 level. Short words, no complex grammar. Avoid "like", "tam tam" or similar filler words. Direct and clear.
|
||||
- **ADVANCED** (for learners): Natural, clear English, about A2–B1 level. More descriptive, natural flow.
|
||||
|
||||
### Style examples
|
||||
|
||||
**Simple (for children):**
|
||||
- Q: "Can you see the boat?"
|
||||
A: "great, you can see the boat."
|
||||
- Q: "Where is the bird?"
|
||||
A: "The bird is in the sky."
|
||||
|
||||
**Advanced (for learners):**
|
||||
- Q: "Can you see the seagull flying in the sky?"
|
||||
A: "Yes, you can see a seagull flying in the sky."
|
||||
- Q: "Where is the red boat?"
|
||||
A: "The red boat is on the water near the lighthouse."
|
||||
|
||||
### Output format
|
||||
Return **exactly one** JSON object, with **no extra text** before or after:
|
||||
{
|
||||
"question_simple_en": "...",
|
||||
"answer_simple_en": "...",
|
||||
"question_advanced_en": "...",
|
||||
"answer_advanced_en": "..."
|
||||
}
|
||||
|
||||
Important:
|
||||
- Do NOT add any other fields.
|
||||
- Do NOT wrap this object in a list.
|
||||
- Do NOT explain your reasoning.
|
||||
- Simple sentences must be VERY simple - suitable for young children learning English.
|
||||
- Never use "what" or "why"
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.3
|
||||
Flask-Cors==4.0.1
|
||||
gunicorn==22.0.0
|
||||
Pillow==11.0.0
|
||||
ollama==0.3.0
|
||||
|
||||
1115
static/script.js
Normal file
1115
static/script.js
Normal file
File diff suppressed because it is too large
Load Diff
369
static/style.css
Normal file
369
static/style.css
Normal file
@@ -0,0 +1,369 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.sentences-pane {
|
||||
flex: 0 0 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.object-item-text strong {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
159
templates/generate.html
Normal file
159
templates/generate.html
Normal file
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>GenerateIt</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>GenerateIt</h1>
|
||||
|
||||
<div class="panel image-nav">
|
||||
<div class="image-nav-left">
|
||||
<button id="prevImageBtn" type="button">⬅️</button>
|
||||
<span>Bild: <code id="currentImageName">{{ current_image or "–" }}</code></span>
|
||||
<button id="nextImageBtn" type="button">➡️</button>
|
||||
<button id="generateDetailsBtn" type="button">✨ KI‑Details</button>
|
||||
<button id="generateSentenceBtn" type="button">💬 KI‑Sentence</button>
|
||||
</div>
|
||||
<div class="page-switch">
|
||||
<select id="pageSelect">
|
||||
<option value="/draw">DrawIt</option>
|
||||
<option value="/generate" selected>GenerateIt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="objects-pane sidebar-section">
|
||||
<h2>Objekte zu diesem Bild</h2>
|
||||
<div id="objectsList" class="objects-list">
|
||||
<!-- Wird per JS gefüllt -->
|
||||
</div>
|
||||
<div id="objectsTags" class="objects-tags">
|
||||
<!-- Tags mit Objektnamen -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-pane">
|
||||
<div class="sidebar-section">
|
||||
<h2>Details</h2>
|
||||
<div class="sidebar-row">
|
||||
<label>Titel</label>
|
||||
<div id="detailTitle" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Position</label>
|
||||
<div id="detailPosition" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Status (sitzt/schwimmt/segelt)</label>
|
||||
<div id="detailAction" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Zustand (alt/jung/rostig)</label>
|
||||
<div id="detailCondition" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Hierarchie</label>
|
||||
<div id="detailHierarchy" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Gehört zu (Parent-Index)</label>
|
||||
<div id="detailParent" class="detail-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2>KI‑Details</h2>
|
||||
<div class="sidebar-row">
|
||||
<label>Label (EN)</label>
|
||||
<div id="detailLabelEn" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Label (DE)</label>
|
||||
<div id="detailLabelDe" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Label (SE)</label>
|
||||
<div id="detailLabelSe" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Farbe (EN)</label>
|
||||
<div id="detailColorEn" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Adjektiv (EN)</label>
|
||||
<div id="detailAdjectiveEn" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Action Verb (EN)</label>
|
||||
<div id="detailActionVerbEn" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Präposition (EN)</label>
|
||||
<div id="detailPrepositionEn" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Relative Position (EN)</label>
|
||||
<div id="detailRelativePositionEn" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Season (EN)</label>
|
||||
<div id="detailSeasonEn" class="detail-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2>KI‑Sentence</h2>
|
||||
<h3 style="font-size: 0.9rem; margin-top: 0; margin-bottom: 8px; color: #475569;">Einfach (für Kinder)</h3>
|
||||
<div class="sidebar-row">
|
||||
<label>Question (EN)</label>
|
||||
<div id="detailSentenceQuestionSimple" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Answer (EN)</label>
|
||||
<div id="detailSentenceAnswerSimple" class="detail-value"></div>
|
||||
</div>
|
||||
<h3 style="font-size: 0.9rem; margin-top: 12px; margin-bottom: 8px; color: #475569;">Fortgeschritten</h3>
|
||||
<div class="sidebar-row">
|
||||
<label>Question (EN)</label>
|
||||
<div id="detailSentenceQuestionAdvanced" class="detail-value"></div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label>Answer (EN)</label>
|
||||
<div id="detailSentenceAnswerAdvanced" class="detail-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sentences-pane">
|
||||
<div class="sidebar-section">
|
||||
<h2>Alle Sätze</h2>
|
||||
<div id="sentencesList" class="sentences-list">
|
||||
<!-- Wird per JS gefüllt -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
window.isGeneratePage = true;
|
||||
const pageSelect = document.getElementById("pageSelect");
|
||||
if (pageSelect) {
|
||||
pageSelect.addEventListener("change", (e) => {
|
||||
window.location.href = e.target.value;
|
||||
});
|
||||
}
|
||||
window.initialImages = {{ images|tojson|safe }};
|
||||
window.initialImageIndex = {{ current_index if current_index is not none else -1 }};
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
124
templates/index.html
Normal file
124
templates/index.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Bild-Ausschnitt wählen</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Bild-Ausschnitt wählen</h1>
|
||||
|
||||
<div class="panel image-nav">
|
||||
<div class="image-nav-left">
|
||||
<button id="prevImageBtn" type="button">⬅️</button>
|
||||
<span>Bild: <code id="currentImageName">{{ current_image or "–" }}</code></span>
|
||||
<button id="nextImageBtn" type="button">➡️</button>
|
||||
<button id="saveImageBtn" type="button">💾</button>
|
||||
</div>
|
||||
<div class="page-switch">
|
||||
<select id="pageSelect">
|
||||
<option value="/draw" selected>DrawIt</option>
|
||||
<option value="/generate">GenerateIt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="objects-pane sidebar-section">
|
||||
<h2>Objekte zu diesem Bild</h2>
|
||||
<div id="objectsList" class="objects-list">
|
||||
<!-- Wird per JS gefüllt -->
|
||||
</div>
|
||||
<div id="objectsTags" class="objects-tags">
|
||||
<!-- Tags mit Objektnamen -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-pane">
|
||||
<div class="canvas-wrapper">
|
||||
<canvas id="imageCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-pane">
|
||||
<div class="sidebar-section">
|
||||
<h2>Auswahl</h2>
|
||||
<div class="sidebar-row">
|
||||
<span>Auswahl-Typ (Interface / Backend):</span>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="mode" value="rect" checked />
|
||||
Rechteck / <code>BBox</code>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="mode" value="polygon" />
|
||||
Polygon / <code>polygon</code>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<button id="clearSelectionBtn" type="button">Auswahl zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2>Metadaten</h2>
|
||||
<div class="sidebar-row">
|
||||
<label for="title_de">Titel / <code>title_de</code></label>
|
||||
<input id="title_de" type="text" />
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label for="position_de">Position / <code>position_de</code></label>
|
||||
<input id="position_de" type="text" />
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label for="action_de">Status (sitzt/schwimmt/segelt) / <code>action_de</code></label>
|
||||
<input id="action_de" type="text" placeholder="z.B. sitzt" />
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<label for="condition_de">Zustand (alt/jung/rostig) / <code>condition_de</code></label>
|
||||
<input id="condition_de" type="text" placeholder="z.B. rostig" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2>Auswahlen</h2>
|
||||
<div id="selectionsList" class="selections-list">
|
||||
<div class="selections-empty">Noch keine Auswahlen hinzugefügt.</div>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<button id="addSelectionBtn" disabled>➕ Auswahl hinzufügen</button>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<button id="saveCropBtn" disabled>💾 Objekt speichern</button>
|
||||
</div>
|
||||
<div class="sidebar-row">
|
||||
<button id="clearAllSelectionsBtn" type="button">🗑️ Alle Auswahlen löschen</button>
|
||||
</div>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
window.isGeneratePage = false;
|
||||
const pageSelect = document.getElementById("pageSelect");
|
||||
if (pageSelect) {
|
||||
pageSelect.addEventListener("change", (e) => {
|
||||
window.location.href = e.target.value;
|
||||
});
|
||||
}
|
||||
window.initialImages = {{ images|tojson|safe }};
|
||||
window.initialImageIndex = {{ current_index if current_index is not none else -1 }};
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user