diff --git a/app.py b/app.py index ef6f9de..7311e24 100644 --- a/app.py +++ b/app.py @@ -261,6 +261,7 @@ def _serve_spa(): @app.route("/") @app.route("/draw") @app.route("/generate") +@app.route("/annotate") def serve_spa(): return _serve_spa() @@ -1690,6 +1691,192 @@ def setup_words_pictures(): "failed": len(failed), "results": results}) +# ── db_* Collection Routes ──────────────────────────────────────────────────── + +def _find_or_create_db_word(titel_de: str, level: int, token: str) -> tuple: + """Return (word_id, is_new). Creates db_word with status=draft if missing.""" + enc = urllib.parse.quote(titel_de, safe="") + data, status = _directus("GET", f"/items/db_words?filter[titel_de][_eq]={enc}&fields=id&limit=1", token) + if status == 200 and data.get("data"): + return data["data"][0]["id"], False + body = {"status": "draft", "titel_de": titel_de, "level": level} + data, status = _directus("POST", "/items/db_words", token, body) + if status in (200, 201): + return data["data"]["id"], True + raise RuntimeError(f"db_word creation failed ({status}): {data}") + + +@app.route("/api/directus/db-pictures", methods=["GET"]) +def directus_db_pictures(): + token = request.headers.get("Authorization", "") + pic_status = request.args.get("status", "draft") + data, status = _directus("GET", f"/items/db_pictures?filter[status][_eq]={pic_status}&fields=id,picture,blurhash,status&sort=date_created", token) + return jsonify(data), status + + +@app.route("/api/directus/db-pictures/", methods=["PATCH"]) +def directus_db_picture_patch(pic_id): + token = request.headers.get("Authorization", "") + data, status = _directus("PATCH", f"/items/db_pictures/{pic_id}", token, body=request.get_json()) + return jsonify(data), status + + +@app.route("/api/directus/db-objects", methods=["GET", "POST"]) +def directus_db_objects(): + token = request.headers.get("Authorization", "") + if request.method == "GET": + picture_id = request.args.get("picture_id", "") + fields = "id,selections,user_notes,status,picture" + path = f"/items/db_objects?filter[picture][_eq]={picture_id}&fields={fields}&sort=date_created" + data, status = _directus("GET", path, token) + return jsonify(data), status + else: + data, status = _directus("POST", "/items/db_objects", token, body=request.get_json()) + return jsonify(data), status + + +@app.route("/api/directus/db-objects/", methods=["PATCH", "DELETE"]) +def directus_db_object(obj_id): + token = request.headers.get("Authorization", "") + if request.method == "PATCH": + data, status = _directus("PATCH", f"/items/db_objects/{obj_id}", token, body=request.get_json()) + else: + data, status = _directus("DELETE", f"/items/db_objects/{obj_id}", token) + return jsonify(data), status + + +@app.route("/api/directus/db-pictures//words", methods=["GET", "POST"]) +def directus_db_picture_words(pic_id): + token = request.headers.get("Authorization", "") + if request.method == "GET": + data, s = _directus( + "GET", + f"/items/db_words_db_pictures?filter[db_pictures_id][_eq]={pic_id}" + f"&fields=id,db_words_id.id,db_words_id.titel_de,db_words_id.level,db_words_id.status&limit=500", + token, + ) + if s != 200: + return jsonify({"data": []}) + items = [] + for entry in (data.get("data") or []): + word = entry.get("db_words_id") or {} + if not isinstance(word, dict) or not word.get("id"): + continue + if word.get("status") == "archived": + continue + items.append({ + "junction_id": entry.get("id"), + "word_id": word["id"], + "titel_de": word.get("titel_de", ""), + "level": word.get("level") or 50, + "status": word.get("status", ""), + }) + return jsonify({"data": items}) + else: + body = request.get_json(force=True, silent=True) or {} + words_to_save = body.get("words", []) + existing_data, _ = _directus( + "GET", + f"/items/db_words_db_pictures?filter[db_pictures_id][_eq]={pic_id}&fields=db_words_id&limit=500", + token, + ) + existing_ids = set() + for e in (existing_data.get("data") or []): + wid = e.get("db_words_id") + if wid: + existing_ids.add(wid if isinstance(wid, str) else wid.get("id", "")) + saved = 0 + for entry in words_to_save: + titel_de = (entry.get("titel_de") or "").strip() + level = int(entry.get("level") or 50) + if not titel_de: + continue + try: + wid, is_new = _find_or_create_db_word(titel_de, level, token) + if not is_new: + _directus("PATCH", f"/items/db_words/{wid}", token, {"level": level}) + if wid not in existing_ids: + _directus("POST", "/items/db_words_db_pictures", token, {"db_words_id": wid, "db_pictures_id": pic_id}) + existing_ids.add(wid) + saved += 1 + except Exception as e: + print(f"[db_picture_words] error for '{titel_de}': {e}") + return jsonify({"ok": True, "saved": saved}) + + +@app.route("/api/directus/db-objects//pairs", methods=["GET", "POST"]) +def directus_db_object_pairs(obj_id): + token = request.headers.get("Authorization", "") + if request.method == "GET": + junc_data, _ = _directus( + "GET", + f"/items/db_objects_db_pairs?filter[db_objects_id][_eq]={obj_id}&fields=id,db_pairs_id&limit=200", + token, + ) + pair_ids = [e["db_pairs_id"] for e in (junc_data.get("data") or []) if e.get("db_pairs_id")] + if not pair_ids: + return jsonify({"data": []}) + ids_param = urllib.parse.quote(",".join(pair_ids), safe="") + pairs_data, _ = _directus("GET", f"/items/db_pairs?filter[id][_in]={ids_param}&fields=id,status,level&sort=date_created&limit=200", token) + pairs = pairs_data.get("data") or [] + result = [] + for pair in pairs: + pid = pair["id"] + q_junc, _ = _directus("GET", f"/items/db_pairs_db_question?filter[db_pairs_id][_eq]={pid}&fields=db_question_id&limit=10", token) + q_ids = [e["db_question_id"] for e in (q_junc.get("data") or []) if e.get("db_question_id")] + questions = [] + for qid in q_ids: + q_d, _ = _directus("GET", f"/items/db_question/{qid}?fields=id,question_de,level,status", token) + if q_d.get("data"): + questions.append(q_d["data"]) + s_junc, _ = _directus("GET", f"/items/db_pairs_db_statement?filter[db_pairs_id][_eq]={pid}&fields=db_statement_id&limit=10", token) + s_ids = [e["db_statement_id"] for e in (s_junc.get("data") or []) if e.get("db_statement_id")] + statements = [] + for sid in s_ids: + s_d, _ = _directus("GET", f"/items/db_statement/{sid}?fields=id,statement_de,level,status", token) + if s_d.get("data"): + statements.append(s_d["data"]) + result.append({**pair, "questions": questions, "statements": statements}) + return jsonify({"data": result}) + else: + body = request.get_json(force=True, silent=True) or {} + question_de = (body.get("question_de") or "").strip() + statement_de = (body.get("statement_de") or "").strip() + level = int(body.get("level") or 1) + words = body.get("words", []) + if not statement_de: + return jsonify({"error": "statement_de is required"}), 400 + pair_resp, s = _directus("POST", "/items/db_pairs", token, {"status": "draft", "level": level}) + if s not in (200, 201): + return jsonify({"error": "Failed to create pair"}), 500 + pair_id = pair_resp["data"]["id"] + _directus("POST", "/items/db_objects_db_pairs", token, {"db_objects_id": obj_id, "db_pairs_id": pair_id}) + stmt_resp, s = _directus("POST", "/items/db_statement", token, {"status": "draft", "statement_de": statement_de, "level": level}) + if s not in (200, 201): + return jsonify({"error": "Failed to create statement"}), 500 + stmt_id = stmt_resp["data"]["id"] + _directus("POST", "/items/db_pairs_db_statement", token, {"db_pairs_id": pair_id, "db_statement_id": stmt_id}) + q_id = None + if question_de: + q_resp, s = _directus("POST", "/items/db_question", token, {"status": "draft", "question_de": question_de, "level": level}) + if s in (200, 201): + q_id = q_resp["data"]["id"] + _directus("POST", "/items/db_pairs_db_question", token, {"db_pairs_id": pair_id, "db_question_id": q_id}) + for we in words: + titel_de = (we.get("titel_de") or "").strip() + w_level = int(we.get("level") or level) + if not titel_de: + continue + try: + wid, _ = _find_or_create_db_word(titel_de, w_level, token) + _directus("POST", "/items/db_statement_db_words", token, {"db_statement_id": stmt_id, "db_words_id": wid}) + if q_id: + _directus("POST", "/items/db_question_db_words", token, {"db_question_id": q_id, "db_words_id": wid}) + except Exception as e: + print(f"[db_object_pairs] word error '{titel_de}': {e}") + return jsonify({"ok": True, "pair_id": pair_id, "statement_id": stmt_id, "question_id": q_id}) + + if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c4c1be3..8a8b68e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -325,3 +325,131 @@ export async function purgeAllOrphans(token: string): Promise<{ orphans_removed: if (!res.ok) throw new Error('Fehler beim globalen Bereinigen') return data } + +// ── DB Pictures ─────────────────────────────────────────────────────────────── + +import type { DbPicture, DbObject, DbWord, DbPair } from './types' + +export async function getDbPictures(token: string, status = 'draft'): Promise { + const res = await fetch(`/api/directus/db-pictures?status=${status}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Fehler beim Laden der db_pictures') + const data = await res.json() + return data.data as DbPicture[] +} + +export async function updateDbPictureStatus(pictureId: string, status: string, token: string): Promise { + const res = await fetch(`/api/directus/db-pictures/${pictureId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ status }), + }) + if (!res.ok) throw new Error('Fehler beim Aktualisieren des Bild-Status') +} + +// ── DB Objects ──────────────────────────────────────────────────────────────── + +export async function getDbObjects(pictureId: string, token: string): Promise { + const res = await fetch(`/api/directus/db-objects?picture_id=${pictureId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Fehler beim Laden der db_objects') + const data = await res.json() + return data.data as DbObject[] +} + +export async function createDbObject(payload: { + picture: string + selections: import('./types').Selection[] | null + user_notes: string | null +}, token: string): Promise { + const res = await fetch('/api/directus/db-objects', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ ...payload, status: 'draft' }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Erstellen des Objekts') + return data.data as DbObject +} + +export async function updateDbObject( + objId: string, + payload: Partial>, + token: string +): Promise { + const res = await fetch(`/api/directus/db-objects/${objId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Aktualisieren') + return data.data as DbObject +} + +export async function deleteDbObject(objId: string, token: string): Promise { + const res = await fetch(`/api/directus/db-objects/${objId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Fehler beim Löschen des Objekts') +} + +// ── DB Words for a picture ──────────────────────────────────────────────────── + +export async function getDbPictureWords(pictureId: string, token: string): Promise { + const res = await fetch(`/api/directus/db-pictures/${pictureId}/words`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error('Fehler beim Laden der Wörter') + return data.data as DbWord[] +} + +export async function saveDbPictureWords( + pictureId: string, + words: { titel_de: string; level: number }[], + token: string +): Promise<{ saved: number }> { + const res = await fetch(`/api/directus/db-pictures/${pictureId}/words`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ words }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern der Wörter') + return data +} + +// ── DB Pairs for an object ──────────────────────────────────────────────────── + +export async function getDbObjectPairs(objectId: string, token: string): Promise { + const res = await fetch(`/api/directus/db-objects/${objectId}/pairs`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error('Fehler beim Laden der Pairs') + return data.data as DbPair[] +} + +export async function createDbPair( + objectId: string, + payload: { + question_de?: string + statement_de: string + level: number + words: { titel_de: string; level: number }[] + }, + token: string +): Promise<{ ok: boolean; pair_id: string; statement_id: string; question_id: string | null }> { + const res = await fetch(`/api/directus/db-objects/${objectId}/pairs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Fehler beim Erstellen des Pairs') + return data +} diff --git a/frontend/src/pages/DrawIt.tsx b/frontend/src/pages/DrawIt.tsx index 617762b..ed9fa19 100644 --- a/frontend/src/pages/DrawIt.tsx +++ b/frontend/src/pages/DrawIt.tsx @@ -2,12 +2,18 @@ import { useState, useEffect, useCallback, useRef } from 'react' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' import Topbar from '../components/Topbar' import { - getDirectusPictures, directusAssetUrl, type DirectusPicture, - getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject, - updatePictureStatus, getPictureWords, savePictureWords, + getDbPictures, + updateDbPictureStatus, + getDbObjects, + createDbObject, + updateDbObject, + deleteDbObject, + getDbPictureWords, + saveDbPictureWords, + directusAssetUrl, } from '../api' import { useAuth } from '../context/AuthContext' -import type { DirectusObject, Selection, CanvasObject, PictureWord } from '../types' +import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types' const ChevronLeftIcon = () => ( @@ -28,73 +34,45 @@ const TrashIcon = () => ( export default function DrawIt() { const { token } = useAuth() - const [pictureList, setPictureList] = useState([]) + const [pictureList, setPictureList] = useState([]) const [currentIndex, setCurrentIndex] = useState(-1) const [debouncedIndex, setDebouncedIndex] = useState(-1) - const [objects, setObjects] = useState([]) + const [objects, setObjects] = useState([]) const [selectedObjectId, setSelectedObjectId] = useState(null) const [currentSelections, setCurrentSelections] = useState([]) const [userNotes, setUserNotes] = useState('') - const [safeWords, setSafeWords] = useState<{ title: string; level: number }[]>([]) - const [safeWordInput, setSafeWordInput] = useState('') - const [safeWordLevel, setSafeWordLevel] = useState(50) - const [safeWordInputVisible, setSafeWordInputVisible] = useState(false) - const safeWordInputRef = useRef(null) - const [pictureWords, setPictureWords] = useState([]) + // pending words (not yet saved) + const [pendingWords, setPendingWords] = useState<{ titel_de: string; level: number }[]>([]) + const [wordInput, setWordInput] = useState('') + const [wordLevel, setWordLevel] = useState(50) + const [wordInputVisible, setWordInputVisible] = useState(false) + const wordInputRef = useRef(null) + // saved words from Directus + const [pictureWords, setPictureWords] = useState([]) const [savingWords, setSavingWords] = useState(false) - const [parentId, setParentId] = useState(null) const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null) const [mode, setMode] = useState<'rect' | 'polygon'>('polygon') const [hasSelection, setHasSelection] = useState(false) const [saving, setSaving] = useState(false) const [finishing, setFinishing] = useState(false) - const [status, setStatus] = useState('') + const [statusMsg, setStatusMsg] = useState('') const [statusError, setStatusError] = useState(false) const canvasRef = useRef(null) - // Debounce: Bild erst laden wenn 350ms keine weitere Navigation + // Debounce: only load picture data after 350ms of no navigation useEffect(() => { const t = setTimeout(() => setDebouncedIndex(currentIndex), 350) return () => clearTimeout(t) }, [currentIndex]) useEffect(() => { - if (safeWordInputVisible) safeWordInputRef.current?.focus() - }, [safeWordInputVisible]) + if (wordInputVisible) wordInputRef.current?.focus() + }, [wordInputVisible]) - const addSafeWord = () => { - const title = safeWordInput.trim() - if (!title || safeWords.some(w => w.title === title) || pictureWords.some(w => w.title_de === title)) { - setSafeWordInput(''); return - } - setSafeWords(prev => [...prev, { title, level: safeWordLevel }]) - setSafeWordInput('') - setSafeWordLevel(50) - setSafeWordInputVisible(false) - } - - const saveSafeWords = async () => { - if (!currentPicture || !token || safeWords.length === 0) return - setSavingWords(true) - try { - await savePictureWords(currentPicture.id, safeWords.map(w => ({ title_de: w.title, level: w.level })), token) - const updated = await getPictureWords(currentPicture.id, token) - setPictureWords(updated) - setSafeWords([]) - showStatus('Wörter gespeichert.') - } catch (e) { - showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true) - } finally { - setSavingWords(false) - } - } - - // currentPicture folgt dem debouncedIndex → lädt erst wenn Navigation pausiert - const currentPicture: DirectusPicture | null = + const currentPicture: DbPicture | null = debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null - // Map DirectusObject → CanvasObject for rendering const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({ id: obj.id, visible: obj.visible !== false, @@ -103,33 +81,62 @@ export default function DrawIt() { hierarchy: 1, })) + // Load db_pictures with status=draft useEffect(() => { if (!token) return - getDirectusPictures(token) + getDbPictures(token, 'draft') .then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) }) .catch(console.error) }, [token]) + // Load objects + words when picture changes useEffect(() => { if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null) - setPictureWords([]); setSafeWords([]) + setPictureWords([]); setPendingWords([]) return } - getDirectusObjects(currentPicture.id, token) + getDbObjects(currentPicture.id, token) .then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) }) .catch(console.error) - getPictureWords(currentPicture.id, token) + getDbPictureWords(currentPicture.id, token) .then(setPictureWords) .catch(console.error) }, [currentPicture?.id, token]) const showStatus = (msg: string, isError = false) => { - setStatus(msg); setStatusError(isError) + setStatusMsg(msg); setStatusError(isError) } const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), []) + const addWord = () => { + const titel = wordInput.trim() + if (!titel || pendingWords.some(w => w.titel_de === titel) || pictureWords.some(w => w.titel_de === titel)) { + setWordInput(''); return + } + setPendingWords(prev => [...prev, { titel_de: titel, level: wordLevel }]) + setWordInput('') + setWordLevel(50) + setWordInputVisible(false) + } + + const saveWords = async () => { + if (!currentPicture || !token || pendingWords.length === 0) return + setSavingWords(true) + try { + await saveDbPictureWords(currentPicture.id, pendingWords, token) + const updated = await getDbPictureWords(currentPicture.id, token) + setPictureWords(updated) + setPendingWords([]) + showStatus('Wörter gespeichert.') + } catch (e) { + showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true) + } finally { + setSavingWords(false) + } + } + const addSelection = () => { const sel = canvasRef.current?.getCurrentSelection() if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return } @@ -142,16 +149,14 @@ export default function DrawIt() { if (!currentPicture || !token || currentSelections.length === 0) return setSaving(true) try { - const obj = await createDirectusObject({ + const obj = await createDbObject({ picture: currentPicture.id, selections: currentSelections, user_notes: userNotes.trim() || null, - parent: parentId, }, token) setObjects(prev => [...prev, { ...obj, visible: true }]) setCurrentSelections([]) setUserNotes('') - setParentId(null) canvasRef.current?.resetSelection() showStatus('Objekt gespeichert.') } catch (e) { @@ -161,11 +166,12 @@ export default function DrawIt() { } } + // Mark picture as objects_created and remove from list const finishPicture = async () => { if (!currentPicture || !token) return setFinishing(true) try { - await updatePictureStatus(currentPicture.id, 'drawing_created', token) + await updateDbPictureStatus(currentPicture.id, 'objects_created', token) setPictureList(prev => prev.filter(p => p.id !== currentPicture.id)) setCurrentIndex(i => Math.max(0, i - 1)) setObjects([]) @@ -180,7 +186,7 @@ export default function DrawIt() { const saveNoteEdit = async () => { if (!editingNotes || !token) return try { - await updateDirectusObject(editingNotes.id, { user_notes: editingNotes.notes }, token) + await updateDbObject(editingNotes.id, { user_notes: editingNotes.notes }, token) setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o)) setEditingNotes(null) showStatus('Notizen gespeichert.') @@ -192,7 +198,7 @@ export default function DrawIt() { const deleteObject = async (objId: string) => { if (!token) return try { - await deleteDirectusObject(objId, token) + await deleteDbObject(objId, token) setObjects(prev => prev.filter(o => o.id !== objId)) if (selectedObjectId === objId) setSelectedObjectId(null) showStatus('Objekt gelöscht.') @@ -250,7 +256,6 @@ export default function DrawIt() {
Objekt {i + 1} {obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'} - {obj.parent && ↳ Kind von #{objects.findIndex(o => o.id === obj.parent) + 1}}
- {status &&
{status}
} + {statusMsg &&
{statusMsg}
} @@ -430,26 +423,31 @@ export default function DrawIt() { diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx index 5a50902..bc2e881 100644 --- a/frontend/src/pages/GenerateIt.tsx +++ b/frontend/src/pages/GenerateIt.tsx @@ -1,149 +1,297 @@ import { useState, useEffect, useRef } from 'react' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' -import GenerateObjectsList from '../components/GenerateObjectsList' - import Topbar from '../components/Topbar' import { - getDirectusPictures, + getDbPictures, + getDbObjects, + getDbObjectPairs, + createDbPair, directusAssetUrl, - type DirectusPicture, - getDirectusObjects, - generateQuestions, - publishQuestions, - getObjectQuestions, - getObjectWords, - deleteQuestion, - deleteWord, - purgeOrphans, - purgeAllOrphans, - type GenerateStats, - type ObjectQuestion, - type ObjectWord, } from '../api' import { useAuth } from '../context/AuthContext' -import type { DirectusObject, CanvasObject } from '../types' +import type { DbPicture, DbObject, DbPair, CanvasObject } from '../types' const ChevronLeftIcon = () => ( ) - const ChevronRightIcon = () => ( ) -const GenerateIcon = () => ( - - - -) +// ── PairForm ────────────────────────────────────────────────────────────────── -const PublishIcon = () => ( - - - -) - -const SaveIcon = () => ( - - - - - -) - -// ── Prompt layout system ────────────────────────────────────────────────────── - -const DEFAULT_PROMPT = `Du bist ein erfahrener Sprachlernexperte. Du erhältst die Beschreibung eines Objekts aus einem Bild (Titel, Position, Zustand, Aktion) sowie ggf. dessen Elternobjekt als Kontext. Deine Aufgabe: Erstelle ausschließlich für das genannte Objekt (nicht für das Elternobjekt) Sprachlernfragen auf 10 Niveaustufen (1–10): - -* Stufe 1–2 (Anfänger): Einfachste Erkennungs- oder Ja/Nein-Fragen, z.B. „Kannst du den Hund sehen?" -* Stufe 3–5 (Grundstufe): Beschreibende Fragen zu Farbe, Form, Position -* Stufe 6–8 (Mittelstufe): Fragen zu Funktion, Vergleich oder Kontext -* Stufe 9–10 (Fortgeschritten): Sprachlich anspruchsvolle, kreative oder erklärende Fragen – die Komplexität liegt in Grammatik, Wortschatz und Satzbau, nicht im abstrakten Denken - -Regeln: - -* Jede Frage muss sich direkt auf das Objekt beziehen -* \`words\`: Enthält alle einzigartigen Tokens aus Frage UND Antwort zusammen (Satzzeichen ausgenommen, keine Duplikate) -* \`short_answer\`: Ein einzelnes treffendes Wort als Kurzantwort (z.B. „Ja", „schwarz", „wendig") -* \`distractor_words\`: Genau 5 Wörter, die thematisch passen, aber NICHT in Frage oder Antwort vorkommen und NICHT die Antwort sind -* Gib ausschließlich valides JSON aus – kein Text, kein Markdown - -Ausgabeformat: -{ - "levels": [ - { - "level": 1, - "question": "Kannst du den Hund sehen?", - "answer": "Ja, ich kann den Hund sehen.", - "short_answer": "Ja", - "words": ["Kannst", "du", "den", "Hund", "sehen", "Ja", "ich", "kann"], - "distractor_words": ["Nein", "vielleicht", "Katze", "hören", "groß"] - } - ] +interface PendingWord { + titel_de: string + level: number } -Informationen: {user-notes_object} -Elternobjekt: {user-notes_parentobject}` - -interface PromptLayout { - name: string - prompt: string +interface PairFormProps { + objectId: string + token: string + onSaved: () => void + onCancel: () => void } -const STORAGE_KEY = 'cm_prompt_layouts' +function PairForm({ objectId, token, onSaved, onCancel }: PairFormProps) { + const [level, setLevel] = useState(5) + const [questionDe, setQuestionDe] = useState('') + const [statementDe, setStatementDe] = useState('') + const [words, setWords] = useState([]) + const [wordInput, setWordInput] = useState('') + const [wordLevel, setWordLevel] = useState(50) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const wordInputRef = useRef(null) -function loadLayouts(): PromptLayout[] { - const defaultLayout: PromptLayout = { name: 'Standard', prompt: DEFAULT_PROMPT } - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (!stored) return [defaultLayout] - const parsed = JSON.parse(stored) as PromptLayout[] - const hasDefault = parsed.some(l => l.name === 'Standard') - return hasDefault ? parsed : [defaultLayout, ...parsed] - } catch { - return [defaultLayout] + const addWord = () => { + const t = wordInput.trim() + if (!t || words.some(w => w.titel_de === t)) { setWordInput(''); return } + setWords(prev => [...prev, { titel_de: t, level: wordLevel }]) + setWordInput('') + setWordLevel(50) + wordInputRef.current?.focus() } + + const handleSave = async () => { + if (!statementDe.trim()) { setError('Aussage (statement_de) ist Pflicht.'); return } + setSaving(true) + setError('') + try { + await createDbPair(objectId, { + question_de: questionDe.trim() || undefined, + statement_de: statementDe.trim(), + level, + words, + }, token) + onSaved() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Speichern') + } finally { + setSaving(false) + } + } + + return ( +
+ {/* Level slider */} +
+ + setLevel(Number(e.target.value))} + style={{ width: '100%' }} + /> +
+ + {/* Question (optional) */} +
+ +