feat: Pro-Pair Übersetzen-&-Prüfen mit Review-Modal
- 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 <noreply@anthropic.com>
This commit is contained in:
157
src/components/PairReviewModal.jsx
Normal file
157
src/components/PairReviewModal.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 backdrop-blur-sm p-4 overflow-y-auto">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl my-8 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">🔍</span>
|
||||||
|
<span className="font-semibold text-slate-800">Pair prüfen</span>
|
||||||
|
<span className="text-xs bg-slate-200 text-slate-600 rounded px-1.5 py-0.5">{pair.answer_type}</span>
|
||||||
|
<span className="font-mono text-xs text-slate-400">{pair.id?.slice(0, 8)}…</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-2xl leading-none" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — Übersetzungen zum Gegenprüfen */}
|
||||||
|
<div className="px-6 py-5 overflow-y-auto">
|
||||||
|
<div className="grid gap-3" style={{ gridTemplateColumns: '5rem repeat(3, 1fr)' }}>
|
||||||
|
<div />
|
||||||
|
{LANGS.map(l => (
|
||||||
|
<div key={l.code} className="text-center text-sm font-medium text-slate-500">{l.flag} {l.code.toUpperCase()}</div>
|
||||||
|
))}
|
||||||
|
{rows.map(row => (
|
||||||
|
<Row key={row.label} row={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="text-sm text-slate-400 text-center py-6">Kein übersetzbarer Inhalt gefunden.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fehler / fehlende Übersetzungen */}
|
||||||
|
{missing && (
|
||||||
|
<div className="mx-6 mb-2 bg-amber-50 border border-amber-200 text-amber-800 text-sm rounded-xl px-4 py-2">
|
||||||
|
<b>Reviewed nicht möglich — Übersetzung unvollständig.</b> Fehlt: {missing.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 mb-2 bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl px-4 py-2">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer — Aktionen */}
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-slate-200">
|
||||||
|
<button onClick={onClose} disabled={!!busy}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg text-slate-500 hover:bg-slate-100 disabled:opacity-40">Abbrechen</button>
|
||||||
|
<button onClick={handleBlock} disabled={!!busy}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-40">
|
||||||
|
{busy === 'block' ? 'Läuft …' : '🚫 Blocked'}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleReview} disabled={!!busy}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-40">
|
||||||
|
{busy === 'review' ? 'Läuft …' : '✓ Reviewed'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ row }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold text-slate-400 self-center">{row.label}</div>
|
||||||
|
{LANGS.map(l => {
|
||||||
|
const val = row.cell(l.code);
|
||||||
|
return (
|
||||||
|
<div key={l.code} className={`text-sm rounded-lg border px-2.5 py-1.5 ${val ? `bg-slate-50 border-slate-200 ${row.color}` : 'bg-red-50 border-red-200 text-red-400 italic'}`}>
|
||||||
|
{val || 'fehlt'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,7 +61,10 @@ export async function apiFetch(path, options = {}) {
|
|||||||
if (res.status === 401) { logout(); window.location.href = '/login'; return; }
|
if (res.status === 401) { logout(); window.location.href = '/login'; return; }
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
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;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { apiFetch, apiPost, apiPatch, apiLink, apiUnlink, apiDelete, getUserLang } from '../lib/api';
|
import { apiFetch, apiPost, apiPatch, apiLink, apiUnlink, apiDelete, getUserLang } from '../lib/api';
|
||||||
import { STATUS_COLORS } from '../lib/tables';
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
import PairReviewModal from '../components/PairReviewModal';
|
||||||
|
|
||||||
// ─── Word / placeholder helpers ───────────────────────────────────────────────
|
// ─── Word / placeholder helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -662,8 +663,11 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Server gibt bei fehlenden Übersetzungen 409 mit `missing: [...]` zurück
|
// Server gibt bei fehlenden Übersetzungen 409 mit `missing: [...]` zurück
|
||||||
const m = (e.message || '').replace(/^Übersetzung unvollständig\s*/, '');
|
const missing = e.payload?.missing;
|
||||||
alert('Reviewed nicht möglich: ' + (m || e.message));
|
const detail = Array.isArray(missing) && missing.length
|
||||||
|
? `\nFehlt: ${missing.join(', ')}`
|
||||||
|
: '';
|
||||||
|
alert('Reviewed nicht möglich: ' + e.message + detail);
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,8 +1174,20 @@ function AutoCreateAllButton({ currentPicture, objects }) {
|
|||||||
|
|
||||||
// ─── Right panel: Pairs ───────────────────────────────────────────────────────
|
// ─── 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 [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) {
|
if (!selectedObject) {
|
||||||
return (
|
return (
|
||||||
@@ -1219,8 +1235,13 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
|
|||||||
{LEVEL_LABELS[pair.difficulty_level] || pair.difficulty_level}
|
{LEVEL_LABELS[pair.difficulty_level] || pair.difficulty_level}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={() => handleTranslate(pair)} disabled={translatingId === pair.id}
|
||||||
|
className="ml-auto text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-2 py-0.5 rounded transition-colors disabled:opacity-50 disabled:cursor-wait"
|
||||||
|
title="Fehlende Übersetzungen ergänzen und prüfen">
|
||||||
|
{translatingId === pair.id ? 'Läuft …' : '🪄 Übersetzen & prüfen'}
|
||||||
|
</button>
|
||||||
<button onClick={() => setEditingId(pair.id)}
|
<button onClick={() => setEditingId(pair.id)}
|
||||||
className="ml-auto text-xs text-slate-400 hover:text-indigo-600 px-1.5 py-0.5 rounded hover:bg-indigo-50 transition-colors" title="Bearbeiten">✏️</button>
|
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">✏️</button>
|
||||||
</div>
|
</div>
|
||||||
{pair.question && (
|
{pair.question && (
|
||||||
<p className="text-xs text-slate-600 truncate">
|
<p className="text-xs text-slate-600 truncate">
|
||||||
@@ -1246,6 +1267,15 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{reviewData && (
|
||||||
|
<PairReviewModal
|
||||||
|
pair={reviewData.pair}
|
||||||
|
content={reviewData.content}
|
||||||
|
onClose={() => setReviewData(null)}
|
||||||
|
onDone={() => { setReviewData(null); (onReloadAll || onPairsReload)(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1527,6 +1557,33 @@ export default function ContentCreation() {
|
|||||||
.finally(() => setLoadingPairs(false));
|
.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 (
|
return (
|
||||||
<Layout back="/content" fullHeight>
|
<Layout back="/content" fullHeight>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
@@ -1616,6 +1673,7 @@ export default function ContentCreation() {
|
|||||||
loadingPairs={loadingPairs}
|
loadingPairs={loadingPairs}
|
||||||
onPairSaved={pair => setObjectPairs(prev => [pair, ...prev])}
|
onPairSaved={pair => setObjectPairs(prev => [pair, ...prev])}
|
||||||
onPairsReload={reloadPairs}
|
onPairsReload={reloadPairs}
|
||||||
|
onReloadAll={reloadAfterReview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user