From ae947214661f350b2499dce2e5e68bf8e89067f9 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 5 Jun 2026 14:28:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Pro-Pair=20=C3=9Cbersetzen-&-Pr=C3=BCfe?= =?UTF-8?q?n=20mit=20Review-Modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PairReviewModal: zeigt Frage/Positiv/Negativ in de/en/sv (Wörter bei word-Typ) zum Gegenprüfen, mit Reviewed/Blocked-Buttons; bei 409 wird die missing-Liste inline angezeigt. - ContentCreation: pro Pair-Karte '🪄 Übersetzen & prüfen' (ruft /pairs/:id/translate, öffnet Modal); nach Review werden Pairs, Objekte und Bilder neu geladen. - api.js: Fehler-Payload (z.B. { missing }) wird am Error durchgereicht. Co-Authored-By: Claude Opus 4.8 --- src/components/PairReviewModal.jsx | 157 +++++++++++++++++++++++++++++ src/lib/api.js | 5 +- src/pages/ContentCreation.jsx | 66 +++++++++++- 3 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 src/components/PairReviewModal.jsx diff --git a/src/components/PairReviewModal.jsx b/src/components/PairReviewModal.jsx new file mode 100644 index 0000000..2b59744 --- /dev/null +++ b/src/components/PairReviewModal.jsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { apiPost, apiPatch } from '../lib/api'; + +const LANGS = [ + { code: 'de', flag: '🇩🇪' }, + { code: 'en', flag: '🇬🇧' }, + { code: 'sv', flag: '🇸🇪' }, +]; + +// Platzhalter {{label.type:uuid}} → nur das Label anzeigen. +function strip(text) { + if (!text) return ''; + return text.replace(/\{\{([^}]+)\}\}/g, (_, inner) => { + const m = inner.match(/^(.+?)\.[a-z]+:[0-9a-f-]{36}$/i); + return m ? m[1] : inner; + }); +} + +// Baut die Anzeigezeilen (Frage / Positiv / Negativ) aus dem content-Bündel. +function buildRows(content) { + if (!content) return []; + const rows = []; + const isWord = content.answer_type === 'word'; + + if (content.question) { + rows.push({ + label: 'Frage', color: 'text-slate-700', + cell: l => strip(content.question[`sentence_${l}`] || ''), + }); + } + if (content.positive) { + rows.push({ + label: 'Positiv', color: 'text-green-700', + cell: l => isWord + ? content.positive.words.map(w => w[`titel_${l}`] || '—').join(', ') + : strip(content.positive.sentence[`positive_sentence_${l}`] || ''), + }); + } + // Negativ nur zeigen, wenn überhaupt Inhalt vorhanden ist + const negHasContent = content.negative && ( + content.answer_type === 'word' + ? content.negative.words.length > 0 + : LANGS.some(l => (content.negative.sentence[`negative_sentence_${l}`] || '').trim()) + ); + if (negHasContent) { + rows.push({ + label: 'Negativ', color: 'text-red-600', + cell: l => isWord + ? content.negative.words.map(w => w[`titel_${l}`] || '—').join(', ') + : strip(content.negative.sentence[`negative_sentence_${l}`] || ''), + }); + } + return rows; +} + +export default function PairReviewModal({ pair, content, onClose, onDone }) { + const [busy, setBusy] = useState(null); // 'review' | 'block' + const [missing, setMissing] = useState(null); + const [error, setError] = useState(null); + + const rows = buildRows(content); + + async function handleReview() { + setBusy('review'); setMissing(null); setError(null); + try { + await apiPost(`/pairs/${pair.id}/review`, {}); + onDone(); + } catch (e) { + const m = e.payload?.missing; + if (Array.isArray(m) && m.length) setMissing(m); + else setError(e.message); + } finally { setBusy(null); } + } + + async function handleBlock() { + setBusy('block'); setMissing(null); setError(null); + try { + await apiPatch('/pairs', pair.id, { status: 'blocked' }); + onDone(); + } catch (e) { + setError(e.message); + } finally { setBusy(null); } + } + + return ( +
+
+ {/* Header */} +
+
+ 🔍 + Pair prüfen + {pair.answer_type} + {pair.id?.slice(0, 8)}… +
+ +
+ + {/* Body — Übersetzungen zum Gegenprüfen */} +
+
+
+ {LANGS.map(l => ( +
{l.flag} {l.code.toUpperCase()}
+ ))} + {rows.map(row => ( + + ))} +
+ {rows.length === 0 && ( +

Kein übersetzbarer Inhalt gefunden.

+ )} +
+ + {/* Fehler / fehlende Übersetzungen */} + {missing && ( +
+ Reviewed nicht möglich — Übersetzung unvollständig. Fehlt: {missing.join(', ')} +
+ )} + {error && ( +
{error}
+ )} + + {/* Footer — Aktionen */} +
+ + + +
+
+
+ ); +} + +function Row({ row }) { + return ( + <> +
{row.label}
+ {LANGS.map(l => { + const val = row.cell(l.code); + return ( +
+ {val || 'fehlt'} +
+ ); + })} + + ); +} diff --git a/src/lib/api.js b/src/lib/api.js index e49c6f7..87d876b 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -61,7 +61,10 @@ export async function apiFetch(path, options = {}) { if (res.status === 401) { logout(); window.location.href = '/login'; return; } if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || `HTTP ${res.status}`); + const e = new Error(err.error || `HTTP ${res.status}`); + e.status = res.status; + e.payload = err; // vollständige Server-Antwort (z.B. { missing: [...] }) + throw e; } if (res.status === 204) return null; return res.json(); diff --git a/src/pages/ContentCreation.jsx b/src/pages/ContentCreation.jsx index e84189c..29a717b 100644 --- a/src/pages/ContentCreation.jsx +++ b/src/pages/ContentCreation.jsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import Layout from '../components/Layout'; import { apiFetch, apiPost, apiPatch, apiLink, apiUnlink, apiDelete, getUserLang } from '../lib/api'; import { STATUS_COLORS } from '../lib/tables'; +import PairReviewModal from '../components/PairReviewModal'; // ─── Word / placeholder helpers ─────────────────────────────────────────────── @@ -662,8 +663,11 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) { onSaved(); } catch (e) { // Server gibt bei fehlenden Übersetzungen 409 mit `missing: [...]` zurück - const m = (e.message || '').replace(/^Übersetzung unvollständig\s*/, ''); - alert('Reviewed nicht möglich: ' + (m || e.message)); + const missing = e.payload?.missing; + const detail = Array.isArray(missing) && missing.length + ? `\nFehlt: ${missing.join(', ')}` + : ''; + alert('Reviewed nicht möglich: ' + e.message + detail); } finally { setSaving(false); } } @@ -1170,8 +1174,20 @@ function AutoCreateAllButton({ currentPicture, objects }) { // ─── Right panel: Pairs ─────────────────────────────────────────────────────── -function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onPairSaved, onPairsReload }) { +function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onPairSaved, onPairsReload, onReloadAll }) { const [editingId, setEditingId] = useState(null); + const [translatingId, setTranslatingId] = useState(null); + const [reviewData, setReviewData] = useState(null); // { pair, content } + + async function handleTranslate(pair) { + setTranslatingId(pair.id); + try { + const res = await apiPost(`/pairs/${pair.id}/translate`, {}); + setReviewData({ pair, content: res.content }); + } catch (e) { + alert('Übersetzen fehlgeschlagen: ' + e.message); + } finally { setTranslatingId(null); } + } if (!selectedObject) { return ( @@ -1219,8 +1235,13 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP {LEVEL_LABELS[pair.difficulty_level] || pair.difficulty_level} )} + + className="text-xs text-slate-400 hover:text-indigo-600 px-1.5 py-0.5 rounded hover:bg-indigo-50 transition-colors" title="Bearbeiten">✏️
{pair.question && (

@@ -1246,6 +1267,15 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP ))} + + {reviewData && ( + setReviewData(null)} + onDone={() => { setReviewData(null); (onReloadAll || onPairsReload)(); }} + /> + )} ); } @@ -1527,6 +1557,33 @@ export default function ContentCreation() { .finally(() => setLoadingPairs(false)); } + function reloadObjects() { + if (!currentPicture) return; + apiFetch(`/objects?picture_id=${currentPicture.id}&limit=100`) + .then(async data => { + const objs = Array.isArray(data) ? data : []; + const withWords = await Promise.all(objs.map(async obj => { + try { const words = await apiFetch(`/objects/${obj.id}/words`); return { ...obj, _words: Array.isArray(words) ? words : [] }; } + catch { return { ...obj, _words: [] }; } + })); + setObjects(withWords); + }) + .catch(() => {}); + } + + function reloadPictures() { + apiFetch('/pictures?limit=500') + .then(data => setPictures(Array.isArray(data) ? data : [])) + .catch(() => {}); + } + + // Nach Review eines Pairs: Pair-, Objekt- und Bild-Status (kaskadiert auf 'reviewed') auffrischen. + function reloadAfterReview() { + reloadPairs(); + reloadObjects(); + reloadPictures(); + } + return (

@@ -1616,6 +1673,7 @@ export default function ContentCreation() { loadingPairs={loadingPairs} onPairSaved={pair => setObjectPairs(prev => [pair, ...prev])} onPairsReload={reloadPairs} + onReloadAll={reloadAfterReview} /> )}