From 5d47482d2aa616aa27b900d2eeeb6a8e53893426 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 23 Apr 2026 22:10:45 +0200 Subject: [PATCH] Erster Commit --- .claude/settings.local.json | 31 + .dockerignore | 23 + .gitignore | 29 + Dockerfile | 43 + README.md | 36 + app.py | 815 ++++++++++ frontend/index.html | 12 + frontend/package-lock.json | 1771 +++++++++++++++++++++ frontend/package.json | 23 + frontend/src/App.tsx | 13 + frontend/src/api.ts | 106 ++ frontend/src/components/DetailsPanel.tsx | 71 + frontend/src/components/DrawCanvas.tsx | 338 ++++ frontend/src/components/ObjectsList.tsx | 181 +++ frontend/src/components/SentencesList.tsx | 34 + frontend/src/index.css | 382 +++++ frontend/src/main.tsx | 13 + frontend/src/pages/DrawIt.tsx | 298 ++++ frontend/src/pages/GenerateIt.tsx | 166 ++ frontend/src/types.ts | 53 + frontend/tsconfig.json | 18 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 18 + prompts/create_details.txt | 39 + prompts/create_sentence.txt | 44 + requirements.txt | 6 + static/script.js | 1115 +++++++++++++ static/style.css | 369 +++++ templates/generate.html | 159 ++ templates/index.html | 124 ++ 30 files changed, 6340 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/DetailsPanel.tsx create mode 100644 frontend/src/components/DrawCanvas.tsx create mode 100644 frontend/src/components/ObjectsList.tsx create mode 100644 frontend/src/components/SentencesList.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/DrawIt.tsx create mode 100644 frontend/src/pages/GenerateIt.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 prompts/create_details.txt create mode 100644 prompts/create_sentence.txt create mode 100644 requirements.txt create mode 100644 static/script.js create mode 100644 static/style.css create mode 100644 templates/generate.html create mode 100644 templates/index.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9f5c599 --- /dev/null +++ b/.claude/settings.local.json @@ -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/)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e1db8d9 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..441f745 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e8fce0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fdbde4 --- /dev/null +++ b/README.md @@ -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. + diff --git a/app.py b/app.py new file mode 100644 index 0000000..3a51c7f --- /dev/null +++ b/app.py @@ -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/") +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/") +def serve_picture(filename: str): + # Statisches Ausliefern der Originalbilder + return send_from_directory(PICTURES_DIR, filename) + + +@app.route("/objects_image/") +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//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/", 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//parent", methods=["POST"]) +def update_object_parent(obj_id: str): + """ + Aktualisiert die Parent-Relation eines Objekts. + Erwartet JSON: { "parent_id": "" | 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//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/.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//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//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/.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) + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c723115 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Content Mentor + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8f51435 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1771 @@ +{ + "name": "content-mentor-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "content-mentor-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..39140bf --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3f89849 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + } /> + } /> + } /> + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..5b3c5d4 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,106 @@ +import type { ObjectMeta, Sentence } from './types' + +export async function getImages(mode: 'draw' | 'generate'): Promise { + const res = await fetch(`/api/images?mode=${mode}`) + if (!res.ok) throw new Error('Fehler beim Laden der Bilder') + const data = await res.json() + return data.images as string[] +} + +export async function getObjects(filename: string): Promise { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 +} diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx new file mode 100644 index 0000000..94cb438 --- /dev/null +++ b/frontend/src/components/DetailsPanel.tsx @@ -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 ( +
+ +
{value || ''}
+
+ ) +} + +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 ( + <> +
+

Details

+ + + + + +
+ +
{parentDisplay}
+
+
+ +
+

KI-Details

+ + + + + + + + + +
+ +
+

KI-Sentence

+

+ Einfach (für Kinder) +

+ + +

+ Fortgeschritten +

+ + +
+ + ) +} diff --git a/frontend/src/components/DrawCanvas.tsx b/frontend/src/components/DrawCanvas.tsx new file mode 100644 index 0000000..500254c --- /dev/null +++ b/frontend/src/components/DrawCanvas.tsx @@ -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(function DrawCanvas( + { imageSrc, objects, selectedObjectId, mode, onHasSelection }, + ref +) { + const canvasRef = useRef(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([]) + const isPolygonClosed = useRef(false) + const displayScale = useRef(1) + const imageRef = useRef(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 +}) diff --git a/frontend/src/components/ObjectsList.tsx b/frontend/src/components/ObjectsList.tsx new file mode 100644 index 0000000..b2b6ed4 --- /dev/null +++ b/frontend/src/components/ObjectsList.tsx @@ -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(null) + const [editForms, setEditForms] = useState>({}) + + useEffect(() => { + const forms: Record = {} + 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 ( +
+
Noch keine Objekte gespeichert.
+
+ ) + } + + return ( + <> +
+ {objects.map(obj => ( +
{ + onSelect(obj.id) + if (isGeneratePage) { + onShowDetails?.(obj) + onLoadSentences?.(obj.id) + } + }} + > +
+ {!isGeneratePage && ( + e.stopPropagation()} + onChange={e => onVisibilityChange?.(obj.id, e.target.checked)} + /> + )} + {obj.image_file && ( + {obj.title_de + )} + + +
+ {obj.title_de || obj.id || 'Ohne Titel'} + {obj.position_de && {obj.position_de}} +
+ {!isGeneratePage && ( + + )} +
+ + {!isGeneratePage && expandedId === obj.id && ( +
+ {(['title_de', 'position_de', 'action_de', 'condition_de'] as const).map(key => ( +
+ + e.stopPropagation()} + onChange={e => + setEditForms(f => ({ + ...f, + [obj.id]: { ...f[obj.id], [key]: e.target.value }, + })) + } + /> +
+ ))} + +
+ )} +
+ ))} +
+
+ {objects.map(obj => ( + + {obj.title_de || obj.id || 'Ohne Titel'} + + ))} +
+ + ) +} diff --git a/frontend/src/components/SentencesList.tsx b/frontend/src/components/SentencesList.tsx new file mode 100644 index 0000000..addb7f6 --- /dev/null +++ b/frontend/src/components/SentencesList.tsx @@ -0,0 +1,34 @@ +import type { Sentence } from '../types' + +interface Props { + sentences: Sentence[] +} + +export default function SentencesList({ sentences }: Props) { + if (sentences.length === 0) { + return ( +
+
Noch keine Sätze vorhanden.
+
+ ) + } + + return ( +
+ {[...sentences].reverse().map((s, i) => ( +
+
+ Einfach: +
+
{s.question_simple_en}
+
{s.answer_simple_en}
+
+ Fortgeschritten: +
+
{s.question_advanced_en}
+
{s.answer_advanced_en}
+
+ ))} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b3a204c --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2caafe8 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + +) diff --git a/frontend/src/pages/DrawIt.tsx b/frontend/src/pages/DrawIt.tsx new file mode 100644 index 0000000..85c2fc8 --- /dev/null +++ b/frontend/src/pages/DrawIt.tsx @@ -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 = { + 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 = { + 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([]) + const [currentIndex, setCurrentIndex] = useState(-1) + const [objects, setObjects] = useState([]) + const [currentSelections, setCurrentSelections] = useState([]) + 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(null) + const [form, setForm] = useState>({ + title_de: '', + position_de: '', + action_de: '', + condition_de: '', + }) + + const canvasRef = useRef(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 ( +
+

DrawIt

+ +
+
+ + Bild: {currentFilename || '–'} + + +
+
+ +
+
+ +
+ {/* Left: Objects */} +
+

Objekte zu diesem Bild

+ + setObjects(prev => prev.map(o => o.id === id ? { ...o, visible } : o)) + } + onObjectsChange={setObjects} + isGeneratePage={false} + /> +
+ + {/* Center: Canvas */} +
+
+ +
+
+ + {/* Right: Controls */} +
+
+

Auswahl

+
+ Auswahl-Typ (Interface / Backend): +
+
+ +
+
+ +
+
+ +
+
+ +
+

Metadaten

+ {(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => ( +
+ + setForm(f => ({ ...f, [key]: e.target.value }))} + /> +
+ ))} +
+ +
+

Auswahlen

+
+ {currentSelections.length === 0 ? ( +
Noch keine Auswahlen hinzugefügt.
+ ) : ( + currentSelections.map((sel, i) => ( +
+ Auswahl {i + 1} ({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`} +
+ )) + )} +
+
+ +
+
+ +
+
+ +
+ {status && ( + {status} + )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx new file mode 100644 index 0000000..50c3449 --- /dev/null +++ b/frontend/src/pages/GenerateIt.tsx @@ -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([]) + const [currentIndex, setCurrentIndex] = useState(-1) + const [objects, setObjects] = useState([]) + const [selectedObj, setSelectedObj] = useState(null) + const [sentences, setSentences] = useState([]) + 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 ( +
+

GenerateIt

+ +
+
+ + Bild: {currentFilename || '–'} + + + +
+
+ +
+
+ +
+
+

Objekte zu diesem Bild

+ { + 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} + /> +
+ +
+ +
+ +
+
+

Alle Sätze

+ +
+
+
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..c0076a9 --- /dev/null +++ b/frontend/src/types.ts @@ -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 +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2e31274 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1f1e13a --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, +}) diff --git a/prompts/create_details.txt b/prompts/create_details.txt new file mode 100644 index 0000000..7721950 --- /dev/null +++ b/prompts/create_details.txt @@ -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. \ No newline at end of file diff --git a/prompts/create_sentence.txt b/prompts/create_sentence.txt new file mode 100644 index 0000000..9642d27 --- /dev/null +++ b/prompts/create_sentence.txt @@ -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" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf61398 --- /dev/null +++ b/requirements.txt @@ -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 + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..6221da7 --- /dev/null +++ b/static/script.js @@ -0,0 +1,1115 @@ +const prevImageBtn = document.getElementById("prevImageBtn"); +const nextImageBtn = document.getElementById("nextImageBtn"); +const currentImageNameEl = document.getElementById("currentImageName"); +const saveImageNavBtn = document.getElementById("saveImageBtn"); +const canvas = document.getElementById("imageCanvas"); +const ctx = canvas ? canvas.getContext("2d") : null; +const saveBtn = document.getElementById("saveCropBtn"); +const addSelectionBtn = document.getElementById("addSelectionBtn"); +const clearAllSelectionsBtn = document.getElementById("clearAllSelectionsBtn"); +const selectionsListEl = document.getElementById("selectionsList"); +const statusEl = document.getElementById("status"); +const modeInputs = document.querySelectorAll('input[name="mode"]'); +const clearSelectionBtn = document.getElementById("clearSelectionBtn"); +const titleInput = document.getElementById("title_de"); +const positionInput = document.getElementById("position_de"); +const actionInput = document.getElementById("action_de"); +const conditionInput = document.getElementById("condition_de"); +const objectsListEl = document.getElementById("objectsList"); +const objectsTagsEl = document.getElementById("objectsTags"); +const generateDetailsBtn = document.getElementById("generateDetailsBtn"); +const generateSentenceBtn = document.getElementById("generateSentenceBtn"); +const detailTitleEl = document.getElementById("detailTitle"); +const detailPositionEl = document.getElementById("detailPosition"); +const detailActionEl = document.getElementById("detailAction"); +const detailConditionEl = document.getElementById("detailCondition"); +const detailHierarchyEl = document.getElementById("detailHierarchy"); +const detailParentEl = document.getElementById("detailParent"); +const detailLabelEnEl = document.getElementById("detailLabelEn"); +const detailLabelDeEl = document.getElementById("detailLabelDe"); +const detailLabelSeEl = document.getElementById("detailLabelSe"); +const detailColorEnEl = document.getElementById("detailColorEn"); +const detailAdjectiveEnEl = document.getElementById("detailAdjectiveEn"); +const detailActionVerbEnEl = document.getElementById("detailActionVerbEn"); +const detailPrepositionEnEl = document.getElementById("detailPrepositionEn"); +const detailRelativePositionEnEl = document.getElementById("detailRelativePositionEn"); +const detailSeasonEnEl = document.getElementById("detailSeasonEn"); +const detailSentenceQuestionSimpleEl = document.getElementById("detailSentenceQuestionSimple"); +const detailSentenceAnswerSimpleEl = document.getElementById("detailSentenceAnswerSimple"); +const detailSentenceQuestionAdvancedEl = document.getElementById("detailSentenceQuestionAdvanced"); +const detailSentenceAnswerAdvancedEl = document.getElementById("detailSentenceAnswerAdvanced"); +const sentencesListEl = document.getElementById("sentencesList"); + +let currentImage = null; +let currentFilename = null; + +let isDragging = false; +let startX = 0; +let startY = 0; +let currentX = 0; +let currentY = 0; +let displayScale = 1; // Verhältnis: Canvas-Größe zu Originalbild + +let mode = "rect"; // "rect" oder "polygon" +let polygonPoints = []; +let isPolygonClosed = false; +let currentObjects = []; // gespeicherte Objekte (Ausschnitte) zum aktuellen Bild +let selectedObjectId = null; +let currentSelections = []; // Gesammelte Auswahlen für das aktuelle Objekt + +let imageList = Array.isArray(window.initialImages) ? window.initialImages : []; +let currentImageIndex = + typeof window.initialImageIndex === "number" ? window.initialImageIndex : imageList.length ? imageList.length - 1 : -1; +const isGeneratePage = window.isGeneratePage === true; + +function setStatus(text, isError = false) { + if (!statusEl) { + // Auf Seiten ohne Status-Element (z.B. GenerateIt) nur in der Konsole loggen + if (text) { + console[isError ? "error" : "log"](text); + } + return; + } + statusEl.textContent = text; + statusEl.className = isError ? "status error" : "status ok"; +} + +function updateDetailsPanel(obj) { + if (!detailTitleEl) return; // Nur auf GenerateIt vorhanden + if (!obj) { + detailTitleEl.textContent = ""; + detailPositionEl.textContent = ""; + detailActionEl.textContent = ""; + detailConditionEl.textContent = ""; + detailHierarchyEl.textContent = ""; + detailParentEl.textContent = ""; + if (detailLabelEnEl) { + detailLabelEnEl.textContent = ""; + detailLabelDeEl.textContent = ""; + detailLabelSeEl.textContent = ""; + detailColorEnEl.textContent = ""; + detailAdjectiveEnEl.textContent = ""; + detailActionVerbEnEl.textContent = ""; + detailPrepositionEnEl.textContent = ""; + detailRelativePositionEnEl.textContent = ""; + detailSeasonEnEl.textContent = ""; + } + if (detailSentenceQuestionSimpleEl) detailSentenceQuestionSimpleEl.textContent = ""; + if (detailSentenceAnswerSimpleEl) detailSentenceAnswerSimpleEl.textContent = ""; + if (detailSentenceQuestionAdvancedEl) detailSentenceQuestionAdvancedEl.textContent = ""; + if (detailSentenceAnswerAdvancedEl) detailSentenceAnswerAdvancedEl.textContent = ""; + return; + } + // Debug: prüfen, welches Objekt gerade angezeigt werden soll + try { + console.log("updateDetailsPanel für Objekt:", obj.id, obj); + } catch (e) { + console.log("updateDetailsPanel aufgerufen", obj); + } + detailTitleEl.textContent = obj.title_de || ""; + detailPositionEl.textContent = obj.position_de || ""; + detailActionEl.textContent = obj.action_de || ""; + detailConditionEl.textContent = obj.condition_de || ""; + detailHierarchyEl.textContent = obj.hierarchy != null ? String(obj.hierarchy) : ""; + // Parent-Index und Name: anhand der index-Eigenschaft und title_de des Parent-Objekts bestimmen + let parentDisplay = ""; + if (obj.parent_id && Array.isArray(currentObjects)) { + const parent = currentObjects.find((o) => o.id === obj.parent_id); + if (parent && typeof parent.index === "number") { + const parentName = parent.title_de || "ohne Titel"; + parentDisplay = `${parent.index} - ${parentName}`; + } + } + detailParentEl.textContent = parentDisplay; + + if (detailLabelEnEl) { + detailLabelEnEl.textContent = obj.label_en || ""; + if (detailLabelDeEl) detailLabelDeEl.textContent = obj.label_de || ""; + if (detailLabelSeEl) detailLabelSeEl.textContent = obj.label_se || ""; + if (detailColorEnEl) detailColorEnEl.textContent = obj.color_en || ""; + if (detailAdjectiveEnEl) detailAdjectiveEnEl.textContent = obj.adjective_en || ""; + if (detailActionVerbEnEl) detailActionVerbEnEl.textContent = obj.action_verb_en || ""; + if (detailPrepositionEnEl) detailPrepositionEnEl.textContent = obj.preposition_en || ""; + if (detailRelativePositionEnEl) detailRelativePositionEnEl.textContent = obj.relative_position_en || ""; + if (detailSeasonEnEl) detailSeasonEnEl.textContent = obj.season_en || ""; + } + if (detailSentenceQuestionSimpleEl && obj.latest_sentence) { + detailSentenceQuestionSimpleEl.textContent = obj.latest_sentence.question_simple_en || ""; + detailSentenceAnswerSimpleEl.textContent = obj.latest_sentence.answer_simple_en || ""; + detailSentenceQuestionAdvancedEl.textContent = obj.latest_sentence.question_advanced_en || ""; + detailSentenceAnswerAdvancedEl.textContent = obj.latest_sentence.answer_advanced_en || ""; + } +} + +function clearCanvas() { + if (!ctx || !canvas) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); +} + +function drawImageAndSelection() { + if (!currentImage || !ctx || !canvas) return; + clearCanvas(); + // Bild vollständig in der aktuellen Canvas-Größe zeichnen (skaliert, ohne Beschnitt) + ctx.drawImage(currentImage, 0, 0, canvas.width, canvas.height); + + // Gespeicherte Objekte als farbige Rahmen/Polygone einzeichnen + if (currentObjects && currentObjects.length > 0) { + for (const obj of currentObjects) { + if (!obj.visible) continue; + const bbox = obj.bbox; + const polygon = obj.polygon; + const hierarchy = obj.hierarchy || 1; + const isSelected = selectedObjectId && obj.id === selectedObjectId; + const indexLabel = typeof obj.index === "number" ? String(obj.index) : ""; + + ctx.save(); + // Linien- und Füllfarbe nach Hierarchie + let stroke = "#14532d"; + let fill = "rgba(20, 83, 45, 0.2)"; // Fallback + if (hierarchy === 1) { + stroke = "#6b7280"; // grau + fill = "rgba(107, 114, 128, 0.2)"; // 20 % + } else if (hierarchy === 2) { + stroke = "#eab308"; // gelb + fill = "rgba(234, 179, 8, 0.3)"; // 30 % + } else if (hierarchy === 3) { + stroke = "#dc2626"; // rot + fill = "rgba(220, 38, 38, 0.3)"; // 30 % + } + ctx.strokeStyle = stroke; + ctx.fillStyle = fill; + ctx.lineWidth = isSelected ? 3 : 2; + ctx.setLineDash(isSelected ? [2, 2] : [4, 3]); + + if (polygon && Array.isArray(polygon) && polygon.length >= 3) { + ctx.beginPath(); + ctx.moveTo(polygon[0].x * displayScale, polygon[0].y * displayScale); + for (let i = 1; i < polygon.length; i++) { + ctx.lineTo(polygon[i].x * displayScale, polygon[i].y * displayScale); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } else if (bbox) { + const bx = bbox.x * displayScale; + const by = bbox.y * displayScale; + const bw = bbox.width * displayScale; + const bh = bbox.height * displayScale; + ctx.fillRect(bx, by, bw, bh); + ctx.strokeRect(bx, by, bw, bh); + } + + // Zusätzlicher weißer Rand für hervorgehobenes Objekt + if (isSelected) { + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.setLineDash([]); + if (polygon && Array.isArray(polygon) && polygon.length >= 3) { + ctx.beginPath(); + ctx.moveTo(polygon[0].x * displayScale, polygon[0].y * displayScale); + for (let i = 1; i < polygon.length; i++) { + ctx.lineTo(polygon[i].x * displayScale, polygon[i].y * displayScale); + } + ctx.closePath(); + ctx.stroke(); + } else if (bbox) { + const bx = bbox.x * displayScale; + const by = bbox.y * displayScale; + const bw = bbox.width * displayScale; + const bh = bbox.height * displayScale; + ctx.strokeRect(bx, by, bw, bh); + } + } + + // Index-Zahl in der Mitte des Objekts anzeigen + if (indexLabel) { + let centerX; + let centerY; + if (bbox) { + centerX = (bbox.x + bbox.width / 2) * displayScale; + centerY = (bbox.y + bbox.height / 2) * displayScale; + } else if (polygon && Array.isArray(polygon) && polygon.length > 0) { + const xs = polygon.map((p) => p.x); + const ys = polygon.map((p) => p.y); + centerX = (Math.min(...xs) + Math.max(...xs)) / 2 * displayScale; + centerY = (Math.min(...ys) + Math.max(...ys)) / 2 * displayScale; + } + if (centerX != null && centerY != null) { + ctx.save(); + ctx.font = "bold 12px system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + // dunkler Hintergrundkreis + ctx.fillStyle = "rgba(15, 23, 42, 0.7)"; + ctx.beginPath(); + ctx.arc(centerX, centerY, 10, 0, Math.PI * 2); + ctx.fill(); + // weiße Zahl + ctx.fillStyle = "#ffffff"; + ctx.fillText(indexLabel, centerX, centerY + 0.5); + ctx.restore(); + } + } + ctx.restore(); + } + } + + if (mode === "rect") { + if (isDragging || (startX !== currentX && startY !== currentY)) { + const x = Math.min(startX, currentX); + const y = Math.min(startY, currentY); + const w = Math.abs(currentX - startX); + const h = Math.abs(currentY - startY); + + if (w > 0 && h > 0) { + ctx.save(); + ctx.strokeStyle = "#f97316"; // Neon-Orange für neue Objekte + ctx.fillStyle = "rgba(249, 115, 22, 0.3)"; // 30 % Füllung + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.fillRect(x, y, w, h); + ctx.strokeRect(x, y, w, h); + ctx.restore(); + } + } + } else if (mode === "polygon") { + if (polygonPoints.length > 0) { + ctx.save(); + ctx.strokeStyle = "#f97316"; // Neon-Orange für neue Objekte + ctx.fillStyle = "rgba(249, 115, 22, 0.3)"; // 30 % Füllung + ctx.lineWidth = 2; + ctx.setLineDash([]); + + ctx.beginPath(); + ctx.moveTo(polygonPoints[0].x, polygonPoints[0].y); + for (let i = 1; i < polygonPoints.length; i++) { + ctx.lineTo(polygonPoints[i].x, polygonPoints[i].y); + } + if (!isPolygonClosed && isDragging) { + // Vorschau-Linie zur aktuellen Mausposition + ctx.lineTo(currentX, currentY); + } + + if (isPolygonClosed) { + ctx.closePath(); + ctx.fill(); + } + ctx.stroke(); + + // Punkte markieren + for (const p of polygonPoints) { + ctx.beginPath(); + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); + ctx.fillStyle = "#ea580c"; + ctx.fill(); + } + + ctx.restore(); + } + } +} + +function resetSelection() { + isDragging = false; + startX = startY = currentX = currentY = 0; + polygonPoints = []; + isPolygonClosed = false; + if (addSelectionBtn) { + addSelectionBtn.disabled = true; + } + drawImageAndSelection(); +} + +// Funktion zum Rendern der Auswahlen-Liste +function renderSelectionsList() { + if (!selectionsListEl) return; + + if (!currentSelections || currentSelections.length === 0) { + selectionsListEl.innerHTML = '
Noch keine Auswahlen hinzugefügt.
'; + if (saveBtn) { + saveBtn.disabled = true; + } + return; + } + + selectionsListEl.innerHTML = currentSelections.map((sel, idx) => { + const num = idx + 1; + if (sel.mode === "rect") { + return ` +
+ Auswahl ${num} (Rechteck): + x=${sel.bbox.x}, y=${sel.bbox.y}, w=${sel.bbox.width}, h=${sel.bbox.height} +
+ `; + } else { + return ` +
+ Auswahl ${num} (Polygon): + ${sel.polygon ? sel.polygon.length + " Punkte" : "–"} +
+ `; + } + }).join(""); + + if (saveBtn) { + saveBtn.disabled = !currentFilename || currentSelections.length === 0; + } +} + +// Auswahl zur Liste hinzufügen +function addCurrentSelection() { + if (!currentFilename) return; + + let selection = null; + + if (mode === "rect") { + const w = Math.abs(currentX - startX); + const h = Math.abs(currentY - startY); + if (w <= 0 || h <= 0) { + setStatus("Bitte zuerst einen Rechteck-Bereich auswählen.", true); + return; + } + const x = Math.min(startX, currentX); + const y = Math.min(startY, currentY); + selection = { + mode: "rect", + bbox: { + x: Math.round(x / displayScale), + y: Math.round(y / displayScale), + width: Math.round(w / displayScale), + height: Math.round(h / displayScale), + }, + }; + } else if (mode === "polygon") { + if (!isPolygonClosed || polygonPoints.length < 3) { + setStatus("Bitte Polygon mit Doppelklick schließen (mind. 3 Punkte).", true); + return; + } + selection = { + mode: "polygon", + polygon: polygonPoints.map((p) => ({ + x: Math.round(p.x / displayScale), + y: Math.round(p.y / displayScale), + })), + }; + } + + if (selection) { + currentSelections.push(selection); + renderSelectionsList(); + resetSelection(); + setStatus(`Auswahl ${currentSelections.length} hinzugefügt.`); + } +} + +async function loadObjectsForCurrentImage() { + if (!currentFilename) { + currentObjects = []; + if (objectsListEl) objectsListEl.innerHTML = ""; + if (objectsTagsEl) objectsTagsEl.innerHTML = ""; + selectedObjectId = null; + updateDetailsPanel(null); + return; + } + try { + const res = await fetch(`/api/objects?filename=${encodeURIComponent(currentFilename)}`); + if (!res.ok) { + throw new Error("Fehler beim Laden der Objekte"); + } + const data = await res.json(); + const objects = (data.objects || []).map((o) => ({ + ...o, + visible: true, + })); + currentObjects = objects; + if (!selectedObjectId && objects.length > 0) { + selectedObjectId = objects[0].id; + } + if (!objectsListEl) { + // Auf Seiten ohne Liste (falls später nötig) + if (isGeneratePage && objects.length > 0) { + updateDetailsPanel(objects[0]); + } + return; + } + + if (objects.length === 0) { + objectsListEl.innerHTML = '
Noch keine Objekte gespeichert.
'; + if (objectsTagsEl) objectsTagsEl.innerHTML = ""; + updateDetailsPanel(null); + return; + } + + objectsListEl.innerHTML = ""; + if (objectsTagsEl) objectsTagsEl.innerHTML = ""; + for (const obj of objects) { + const wrapper = document.createElement("div"); + wrapper.className = "object-item"; + + const header = document.createElement("div"); + header.className = "object-item-header"; + + if (!isGeneratePage) { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = true; + checkbox.addEventListener("click", (ev) => { + ev.stopPropagation(); + const found = currentObjects.find((o) => o.id === obj.id); + if (found) { + found.visible = checkbox.checked; + drawImageAndSelection(); + } + }); + header.appendChild(checkbox); + } + + const img = document.createElement("img"); + if (obj.image_file) { + img.src = `/objects_image/${encodeURIComponent(obj.image_file)}`; + img.alt = obj.title_de || obj.id || ""; + } + + const select = document.createElement("select"); + select.className = "object-hierarchy-select"; + [1, 2, 3].forEach((level) => { + const opt = document.createElement("option"); + opt.value = String(level); + opt.textContent = String(level); + if ((obj.hierarchy || 1) === level) { + opt.selected = true; + } + select.appendChild(opt); + }); + select.addEventListener("click", (ev) => ev.stopPropagation()); + select.addEventListener("change", async () => { + const newVal = parseInt(select.value, 10); + const found = currentObjects.find((o) => o.id === obj.id); + if (found) { + found.hierarchy = newVal; + } + try { + await fetch(`/api/object/${encodeURIComponent(obj.id)}/hierarchy`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hierarchy: newVal }), + }); + } catch (err) { + console.error("Fehler beim Speichern der Hierarchie", err); + } + drawImageAndSelection(); + }); + + const parentSelect = document.createElement("select"); + parentSelect.className = "object-parent-select"; + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "-"; + parentSelect.appendChild(noneOpt); + for (const other of objects) { + if (other.id === obj.id) continue; + const opt = document.createElement("option"); + opt.value = other.id; + opt.textContent = String(other.index); + if (obj.parent_id && obj.parent_id === other.id) { + opt.selected = true; + } + parentSelect.appendChild(opt); + } + parentSelect.addEventListener("click", (ev) => ev.stopPropagation()); + parentSelect.addEventListener("change", async () => { + const value = parentSelect.value || null; + const found = currentObjects.find((o) => o.id === obj.id); + if (found) { + found.parent_id = value; + } + try { + await fetch(`/api/object/${encodeURIComponent(obj.id)}/parent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parent_id: value }), + }); + } catch (err) { + console.error("Fehler beim Speichern der Parent-Relation", err); + } + }); + + const text = document.createElement("div"); + text.className = "object-item-text"; + const title = document.createElement("strong"); + title.textContent = obj.title_de || obj.id || "Ohne Titel"; + const subtitle = document.createElement("span"); + subtitle.textContent = obj.position_de || ""; + + text.appendChild(title); + if (subtitle.textContent) text.appendChild(subtitle); + + header.appendChild(img); + header.appendChild(select); + header.appendChild(parentSelect); + header.appendChild(text); + + wrapper.appendChild(header); + + if (!isGeneratePage) { + const details = document.createElement("div"); + details.className = "object-item-details"; + + const makeRow = (labelText, key, placeholder = "") => { + const row = document.createElement("div"); + const label = document.createElement("label"); + label.textContent = labelText; + const input = document.createElement("input"); + input.type = "text"; + input.value = obj[key] || ""; + if (placeholder) input.placeholder = placeholder; + input.addEventListener("click", (ev) => ev.stopPropagation()); + input.addEventListener("change", () => { + const found = currentObjects.find((o) => o.id === obj.id); + if (found) found[key] = input.value; + }); + row.appendChild(label); + row.appendChild(input); + return { row, input }; + }; + + const { row: titleRow, input: titleInputLocal } = makeRow("Titel", "title_de"); + const { row: posRow, input: posInputLocal } = makeRow("Position", "position_de"); + const { row: actionRow, input: actionInputLocal } = makeRow("Status", "action_de", "z.B. sitzt"); + const { row: condRow, input: condInputLocal } = makeRow("Zustand", "condition_de", "z.B. rostig"); + + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "object-icon-button"; + saveBtn.textContent = "💾"; + saveBtn.addEventListener("click", async (ev) => { + ev.stopPropagation(); + const payload = { + title_de: titleInputLocal.value, + position_de: posInputLocal.value, + action_de: actionInputLocal.value, + condition_de: condInputLocal.value, + }; + try { + await fetch(`/api/object/${encodeURIComponent(obj.id)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const found = currentObjects.find((o) => o.id === obj.id); + if (found) { + Object.assign(found, payload); + } + // Text im Header aktualisieren + title.textContent = payload.title_de || obj.id || "Ohne Titel"; + subtitle.textContent = payload.position_de || ""; + // Details wieder schließen + details.classList.remove("visible"); + } catch (err) { + console.error("Fehler beim Speichern der Objekt-Metadaten", err); + } + }); + + details.appendChild(titleRow); + details.appendChild(posRow); + details.appendChild(actionRow); + details.appendChild(condRow); + details.appendChild(saveBtn); + + wrapper.appendChild(details); + + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.className = "object-icon-button"; + editBtn.textContent = "📝"; + editBtn.addEventListener("click", (ev) => { + ev.stopPropagation(); + selectedObjectId = obj.id; + drawImageAndSelection(); + details.classList.toggle("visible"); + }); + + header.appendChild(editBtn); + } + + wrapper.addEventListener("click", () => { + selectedObjectId = obj.id; + drawImageAndSelection(); + if (isGeneratePage) { + updateDetailsPanel(obj); + loadSentencesForObject(obj.id); + } + }); + + objectsListEl.appendChild(wrapper); + } + + // Tags mit allen Objektnamen unter der Liste + if (objectsTagsEl) { + for (const obj of objects) { + const tag = document.createElement("span"); + tag.className = "object-tag"; + tag.textContent = obj.title_de || obj.id || "Ohne Titel"; + objectsTagsEl.appendChild(tag); + } + } + + // Standard: erstes Objekt im Detail anzeigen (GenerateIt) + if (isGeneratePage && objects.length > 0) { + const first = objects[0]; + if (!selectedObjectId) { + selectedObjectId = first.id; + } + updateDetailsPanel(first); + loadSentencesForObject(first.id); + } + } catch (e) { + console.error(e); + } +} + +modeInputs.forEach((input) => { + input.addEventListener("change", () => { + mode = input.value; + resetSelection(); + }); +}); + +if (clearSelectionBtn) { + clearSelectionBtn.addEventListener("click", () => { + resetSelection(); + }); +} + +function updateImageNav() { + const hasImages = imageList.length > 0 && currentImageIndex >= 0; + if (currentImageNameEl) { + currentImageNameEl.textContent = hasImages ? imageList[currentImageIndex] : "–"; + } + if (prevImageBtn) { + prevImageBtn.disabled = !hasImages || currentImageIndex <= 0; + } + if (nextImageBtn) { + nextImageBtn.disabled = !hasImages || currentImageIndex >= imageList.length - 1; + } + if (saveImageNavBtn) { + saveImageNavBtn.disabled = !hasImages; + } +} + +function loadCurrentImage() { + if (!imageList.length || currentImageIndex < 0 || currentImageIndex >= imageList.length) { + currentFilename = null; + currentImage = null; + clearCanvas(); + updateImageNav(); + loadObjectsForCurrentImage(); + return; + } + + const filename = imageList[currentImageIndex]; + currentFilename = filename; + if (saveBtn) { + saveBtn.disabled = true; + } + setStatus(""); + clearCanvas(); + updateImageNav(); + + if (!canvas || !ctx) { + // Kein Canvas vorhanden (z.B. GenerateIt) -> nur Objekte laden + currentImage = null; + loadObjectsForCurrentImage(); + return; + } + + const img = new Image(); + img.onload = () => { + currentImage = img; + // Bild so skalieren, dass es in die verfügbare Fläche passt (Breite + Höhe) + const wrapper = canvas.parentElement; + const maxWidth = wrapper.clientWidth - 16; + const maxHeight = window.innerHeight * 0.7; + const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1); + displayScale = isFinite(scale) && scale > 0 ? scale : 1; + + canvas.width = img.width * displayScale; + canvas.height = img.height * displayScale; + resetSelection(); + drawImageAndSelection(); + loadObjectsForCurrentImage(); + }; + img.onerror = () => { + setStatus("Fehler beim Laden des Bildes.", true); + }; + img.src = `/pictures/${encodeURIComponent(filename)}`; +} + +if (prevImageBtn) { + prevImageBtn.addEventListener("click", () => { + if (currentImageIndex > 0) { + currentImageIndex -= 1; + loadCurrentImage(); + } + }); +} + +if (nextImageBtn) { + nextImageBtn.addEventListener("click", () => { + if (currentImageIndex < imageList.length - 1) { + currentImageIndex += 1; + loadCurrentImage(); + } + }); +} + +if (saveImageNavBtn) { + saveImageNavBtn.addEventListener("click", async () => { + if (!currentFilename) return; + try { + setStatus("Bild wird gespeichert ..."); + const res = await fetch("/api/image/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename: currentFilename }), + }); + const data = await res.json(); + if (!res.ok) { + setStatus(data.error || "Fehler beim Speichern des Bildes.", true); + return; + } + // Seite neu laden, damit das Bild aus der Übersicht verschwindet + window.location.href = "/draw"; + } catch (err) { + console.error(err); + setStatus("Netzwerk-/Serverfehler beim Bild-Speichern.", true); + } + }); +} + +// Initiales Bild laden (standardmäßig das zuletzt geänderte) +loadCurrentImage(); + +if (canvas) { + canvas.addEventListener("mousedown", (e) => { + if (!currentImage) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + + if (mode === "rect") { + startX = x; + startY = y; + currentX = startX; + currentY = startY; + isDragging = true; + } else if (mode === "polygon") { + if (isPolygonClosed) { + // neue Polygon-Auswahl starten + polygonPoints = []; + isPolygonClosed = false; + } + polygonPoints.push({ x, y }); + isDragging = true; + } + drawImageAndSelection(); + }); + + canvas.addEventListener("mousemove", (e) => { + if (!currentImage) return; + if (!isDragging) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + currentX = (e.clientX - rect.left) * scaleX; + currentY = (e.clientY - rect.top) * scaleY; + drawImageAndSelection(); + }); + + canvas.addEventListener("mouseup", (e) => { + if (!currentImage) return; + isDragging = false; + + if (mode === "rect") { + const w = Math.abs(currentX - startX); + const h = Math.abs(currentY - startY); + if (addSelectionBtn) { + addSelectionBtn.disabled = w <= 0 || h <= 0 || !currentFilename; + } + } else if (mode === "polygon") { + // Doppelklick = Polygon schließen + if (e.detail === 2 && polygonPoints.length >= 3) { + isPolygonClosed = true; + if (addSelectionBtn) { + addSelectionBtn.disabled = !currentFilename; + } + } + } + drawImageAndSelection(); + }); + + canvas.addEventListener("mouseleave", () => { + if (!currentImage) return; + if (isDragging) { + isDragging = false; + drawImageAndSelection(); + } + }); +} + +// Button: Auswahl hinzufügen +if (addSelectionBtn) { + addSelectionBtn.addEventListener("click", () => { + addCurrentSelection(); + }); +} + +// Button: Alle Auswahlen löschen +if (clearAllSelectionsBtn) { + clearAllSelectionsBtn.addEventListener("click", () => { + currentSelections = []; + renderSelectionsList(); + resetSelection(); + setStatus("Alle Auswahlen gelöscht."); + }); +} + +// Button: Objekt speichern (mit allen Auswahlen) +if (saveBtn) { + saveBtn.addEventListener("click", async () => { + if (!currentFilename || !canvas || !ctx) return; + + if (!currentSelections || currentSelections.length === 0) { + setStatus("Bitte mindestens eine Auswahl hinzufügen.", true); + return; + } + + const payload = { + filename: currentFilename, + selections: currentSelections.map((sel, idx) => ({ + number: idx + 1, + mode: sel.mode, + bbox: sel.bbox || null, + polygon: sel.polygon || null, + })), + title_de: titleInput ? titleInput.value || "" : "", + position_de: positionInput ? positionInput.value || "" : "", + action_de: actionInput ? actionInput.value || "" : "", + condition_de: conditionInput ? conditionInput.value || "" : "", + }; + + try { + setStatus("Speichere Objekt mit allen Auswahlen ..."); + 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) { + setStatus(data.error || "Unbekannter Fehler beim Speichern.", true); + return; + } + + // Backend liefert: { id, image_file, meta_file } + setStatus(`Gespeichert – ID: ${data.id} (${currentSelections.length} Auswahlen)`); + + // Auswahlen zurücksetzen + currentSelections = []; + renderSelectionsList(); + resetSelection(); + + // Metadaten-Felder leeren + if (titleInput) titleInput.value = ""; + if (positionInput) positionInput.value = ""; + if (actionInput) actionInput.value = ""; + if (conditionInput) conditionInput.value = ""; + + // Liste der Objekte aktualisieren + loadObjectsForCurrentImage(); + } catch (err) { + console.error(err); + setStatus("Netzwerk-/Serverfehler beim Speichern.", true); + } + }); +} + +// KI‑Details für aktuelles Objekt auf GenerateIt erzeugen +if (generateDetailsBtn) { + generateDetailsBtn.addEventListener("click", async () => { + if (!currentObjects.length) { + alert("Keine Objekte für dieses Bild vorhanden."); + return; + } + let target = currentObjects.find((o) => o.id === selectedObjectId); + if (!target) { + target = currentObjects[0]; + selectedObjectId = target.id; + } + + generateDetailsBtn.disabled = true; + const originalText = generateDetailsBtn.textContent; + generateDetailsBtn.textContent = "⏳ KI‑Details..."; + + try { + const res = await fetch(`/api/object/${encodeURIComponent(target.id)}/generate_details`, { + method: "POST", + }); + const data = await res.json(); + if (!res.ok) { + console.error("Fehler von /generate_details:", data); + alert(data.error || "Fehler beim Generieren der KI‑Details."); + return; + } + + // Objekt in currentObjects aktualisieren + const found = currentObjects.find((o) => o.id === target.id); + if (found) { + console.log("KI-Details erhalten:", data); + Object.assign(found, data); + console.log("Objekt nach Update:", found); + updateDetailsPanel(found); + } else { + console.error("Objekt nicht in currentObjects gefunden:", target.id); + } + } catch (err) { + console.error("Netzwerkfehler bei generate_details", err); + alert("Netzwerkfehler beim Aufruf der KI."); + } finally { + generateDetailsBtn.disabled = false; + generateDetailsBtn.textContent = originalText; + } + }); +} + +// Sätze für ein Objekt von der API laden und im Panel anzeigen +async function loadSentencesForObject(objectId) { + if (!isGeneratePage) return; + try { + const res = await fetch(`/api/object/${encodeURIComponent(objectId)}/sentences`); + if (!res.ok) return; + const data = await res.json(); + const sentences = Array.isArray(data.sentences) ? data.sentences : []; + + // Neuesten Satz in der KI-Sentence-Sektion anzeigen + if (detailSentenceQuestionSimpleEl && detailSentenceAnswerSimpleEl && detailSentenceQuestionAdvancedEl && detailSentenceAnswerAdvancedEl) { + if (!sentences.length) { + detailSentenceQuestionSimpleEl.textContent = ""; + detailSentenceAnswerSimpleEl.textContent = ""; + detailSentenceQuestionAdvancedEl.textContent = ""; + detailSentenceAnswerAdvancedEl.textContent = ""; + } else { + const last = sentences[sentences.length - 1]; + detailSentenceQuestionSimpleEl.textContent = last.question_simple_en || ""; + detailSentenceAnswerSimpleEl.textContent = last.answer_simple_en || ""; + detailSentenceQuestionAdvancedEl.textContent = last.question_advanced_en || ""; + detailSentenceAnswerAdvancedEl.textContent = last.answer_advanced_en || ""; + const found = currentObjects.find((o) => o.id === objectId); + if (found) { + found.latest_sentence = last; + } + } + } + + // Alle Sätze in der Liste anzeigen + renderSentencesList(sentences); + } catch (err) { + console.error("Fehler beim Laden der Sätze", err); + if (sentencesListEl) { + sentencesListEl.innerHTML = '
Fehler beim Laden der Sätze.
'; + } + } +} + +// Funktion zum Rendern der Sätze-Liste +function renderSentencesList(sentences) { + if (!sentencesListEl) return; + + if (!sentences || sentences.length === 0) { + sentencesListEl.innerHTML = '
Noch keine Sätze vorhanden.
'; + return; + } + + // Sätze in umgekehrter Reihenfolge anzeigen (neueste zuerst) + const reversed = [...sentences].reverse(); + sentencesListEl.innerHTML = reversed.map((sentence, idx) => { + const questionSimple = sentence.question_simple_en || ""; + const answerSimple = sentence.answer_simple_en || ""; + const questionAdvanced = sentence.question_advanced_en || ""; + const answerAdvanced = sentence.answer_advanced_en || ""; + return ` +
+
Einfach:
+
${escapeHtml(questionSimple)}
+
${escapeHtml(answerSimple)}
+
Fortgeschritten:
+
${escapeHtml(questionAdvanced)}
+
${escapeHtml(answerAdvanced)}
+
+ `; + }).join(""); +} + +// Hilfsfunktion zum Escapen von HTML +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// KI‑Sentence für aktuelles Objekt auf GenerateIt erzeugen +if (generateSentenceBtn) { + generateSentenceBtn.addEventListener("click", async () => { + if (!currentObjects.length) { + alert("Keine Objekte für dieses Bild vorhanden."); + return; + } + let target = currentObjects.find((o) => o.id === selectedObjectId); + if (!target) { + target = currentObjects[0]; + selectedObjectId = target.id; + } + + generateSentenceBtn.disabled = true; + const originalText = generateSentenceBtn.textContent; + generateSentenceBtn.textContent = "⏳ KI‑Sentence..."; + + try { + const res = await fetch(`/api/object/${encodeURIComponent(target.id)}/generate_sentence`, { + method: "POST", + }); + const data = await res.json(); + if (!res.ok) { + console.error("Fehler von /generate_sentence:", data); + alert(data.error || "Fehler beim Generieren der KI‑Sentence."); + return; + } + + if (data.sentence) { + if (detailSentenceQuestionSimpleEl) detailSentenceQuestionSimpleEl.textContent = data.sentence.question_simple_en || ""; + if (detailSentenceAnswerSimpleEl) detailSentenceAnswerSimpleEl.textContent = data.sentence.answer_simple_en || ""; + if (detailSentenceQuestionAdvancedEl) detailSentenceQuestionAdvancedEl.textContent = data.sentence.question_advanced_en || ""; + if (detailSentenceAnswerAdvancedEl) detailSentenceAnswerAdvancedEl.textContent = data.sentence.answer_advanced_en || ""; + const found = currentObjects.find((o) => o.id === target.id); + if (found) { + found.latest_sentence = data.sentence; + } + // Sätze neu laden, um die Liste zu aktualisieren + await loadSentencesForObject(target.id); + } + } catch (err) { + console.error("Netzwerkfehler bei generate_sentence", err); + alert("Netzwerkfehler beim Aufruf der KI‑Sentence."); + } finally { + generateSentenceBtn.disabled = false; + generateSentenceBtn.textContent = originalText; + } + }); +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..e301a32 --- /dev/null +++ b/static/style.css @@ -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; +} diff --git a/templates/generate.html b/templates/generate.html new file mode 100644 index 0000000..2a7a068 --- /dev/null +++ b/templates/generate.html @@ -0,0 +1,159 @@ + + + + + GenerateIt + + + + +
+

GenerateIt

+ +
+
+ + Bild: {{ current_image or "–" }} + + + +
+
+ +
+
+ +
+ + +
+ + + + + +
+ +
+ +
+
+
+ + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..0c6f791 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,124 @@ + + + + + Bild-Ausschnitt wählen + + + + +
+

Bild-Ausschnitt wählen

+ +
+
+ + Bild: {{ current_image or "–" }} + + +
+
+ +
+
+ +
+ + +
+
+ +
+
+ +
+ + + + + +
+
+
+ + + + + +