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}
/>
)}