feat: TranslationHub, Pair-Review-Button (Action-Bar), reviewed für Bilder
- TranslationHub (/translations): Coverage-Matrix, Batch-Übersetzen pro Tabelle×Sprache - Pair Status: StatusSelect ersetzt durch Action-Bar ✓Reviewed/↩Draft/🚫Block, Reviewed kaskadiert via /pairs/:id/review (Backend prüft 3-Sprachen-Vollständigkeit) - Top-Nav: 'Übersetzungen' zwischen Inhalte und Audio - Dashboard: Übersetzungen-Kachel - tables.js: pictures-Status um 'reviewed' erweitert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import AudioHub from './pages/AudioHub';
|
|||||||
import WordGenerator from './pages/WordGenerator';
|
import WordGenerator from './pages/WordGenerator';
|
||||||
import Publish from './pages/Publish';
|
import Publish from './pages/Publish';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import TranslationHub from './pages/TranslationHub';
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
@@ -28,6 +29,7 @@ export default function App() {
|
|||||||
<Route path="/content" element={<RequireAuth><ContentHub /></RequireAuth>} />
|
<Route path="/content" element={<RequireAuth><ContentHub /></RequireAuth>} />
|
||||||
<Route path="/content/creation" element={<RequireAuth><ContentCreation /></RequireAuth>} />
|
<Route path="/content/creation" element={<RequireAuth><ContentCreation /></RequireAuth>} />
|
||||||
<Route path="/audio" element={<RequireAuth><AudioHub /></RequireAuth>} />
|
<Route path="/audio" element={<RequireAuth><AudioHub /></RequireAuth>} />
|
||||||
|
<Route path="/translations" element={<RequireAuth><TranslationHub /></RequireAuth>} />
|
||||||
<Route path="/content/words" element={<RequireAuth><WordGenerator /></RequireAuth>} />
|
<Route path="/content/words" element={<RequireAuth><WordGenerator /></RequireAuth>} />
|
||||||
<Route path="/publish" element={<RequireAuth><Publish /></RequireAuth>} />
|
<Route path="/publish" element={<RequireAuth><Publish /></RequireAuth>} />
|
||||||
<Route path="/settings" element={<RequireAuth><Settings /></RequireAuth>} />
|
<Route path="/settings" element={<RequireAuth><Settings /></RequireAuth>} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getUser, logout } from '../lib/api';
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ label: 'Dashboard', path: '/', match: p => p === '/' },
|
{ label: 'Dashboard', path: '/', match: p => p === '/' },
|
||||||
{ label: 'Inhalte', path: '/content', match: p => p.startsWith('/content') },
|
{ label: 'Inhalte', path: '/content', match: p => p.startsWith('/content') },
|
||||||
|
{ label: 'Übersetzungen', path: '/translations', match: p => p.startsWith('/translations') },
|
||||||
{ label: 'Audio', path: '/audio', match: p => p.startsWith('/audio') },
|
{ label: 'Audio', path: '/audio', match: p => p.startsWith('/audio') },
|
||||||
{ label: 'Veröffentlichen',path: '/publish', match: p => p.startsWith('/publish') },
|
{ label: 'Veröffentlichen',path: '/publish', match: p => p.startsWith('/publish') },
|
||||||
{ label: 'Datenbank', path: '/db', match: p => p.startsWith('/db') },
|
{ label: 'Datenbank', path: '/db', match: p => p.startsWith('/db') },
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const TABLES = {
|
|||||||
},
|
},
|
||||||
editableFields: {
|
editableFields: {
|
||||||
design: { type: 'text' },
|
design: { type: 'text' },
|
||||||
status: { type: 'select', options: ['uploaded', 'published', 'blocked'] },
|
status: { type: 'select', options: ['uploaded', 'reviewed', 'published', 'blocked'] },
|
||||||
picture_link: { type: 'text' },
|
picture_link: { type: 'text' },
|
||||||
blurhash: { type: 'text' },
|
blurhash: { type: 'text' },
|
||||||
generation_prompt:{ type: 'textarea' },
|
generation_prompt:{ type: 'textarea' },
|
||||||
|
|||||||
@@ -654,11 +654,77 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
<div className="space-y-2 p-1 animate-pulse">{[1,2,3].map(i => <div key={i} className="h-8 bg-amber-100 rounded" />)}</div>
|
<div className="space-y-2 p-1 animate-pulse">{[1,2,3].map(i => <div key={i} className="h-8 bg-amber-100 rounded" />)}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleReview() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiPost(`/pairs/${pair.id}/review`, {});
|
||||||
|
setPairStatus('reviewed');
|
||||||
|
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));
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPairStatusDirect(newStatus) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiPatch('/pairs', pair.id, { status: newStatus });
|
||||||
|
setPairStatus(newStatus);
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler: ' + e.message);
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-amber-200 rounded-xl p-3 bg-amber-50/20 space-y-3">
|
<div className="border border-amber-200 rounded-xl p-3 bg-amber-50/20 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wide shrink-0">Status</label>
|
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wide shrink-0">Status</label>
|
||||||
<StatusSelect value={pairStatus} options={['draft','reviewed','published','blocked']} onChange={setPairStatus} saving={saving} />
|
<span className={`${STATUS_COLORS[pairStatus] || 'bg-gray-100 text-gray-600'} rounded-full px-2 py-0.5 text-xs font-medium`}>
|
||||||
|
{pairStatus}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
{pairStatus === 'draft' && (
|
||||||
|
<button
|
||||||
|
onClick={handleReview}
|
||||||
|
disabled={saving}
|
||||||
|
title="Pair + Frage + Statements auf 'reviewed' setzen. Voraussetzung: alle 3 Sprachen gefüllt."
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors
|
||||||
|
${saving ? 'bg-teal-300 text-white' : 'bg-teal-600 text-white hover:bg-teal-500'}`}
|
||||||
|
>
|
||||||
|
✓ Reviewed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pairStatus === 'reviewed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPairStatusDirect('draft')}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
↩ Zurück auf draft
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pairStatus !== 'blocked' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPairStatusDirect('blocked')}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-red-50 text-red-700 hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
🚫 Block
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pairStatus === 'blocked' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPairStatusDirect('draft')}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
Reaktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Typ</label>
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Typ</label>
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const TILES = [
|
|||||||
path: '/content',
|
path: '/content',
|
||||||
color: 'border-amber-200 hover:border-amber-400 hover:bg-amber-50',
|
color: 'border-amber-200 hover:border-amber-400 hover:bg-amber-50',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Übersetzungen',
|
||||||
|
icon: '🌍',
|
||||||
|
description: 'Fehlende Sprachen per KI nachziehen — Placeholder bleiben erhalten.',
|
||||||
|
path: '/translations',
|
||||||
|
color: 'border-emerald-200 hover:border-emerald-400 hover:bg-emerald-50',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Audio / TTS',
|
title: 'Audio / TTS',
|
||||||
icon: '🔊',
|
icon: '🔊',
|
||||||
|
|||||||
114
src/pages/TranslationHub.jsx
Normal file
114
src/pages/TranslationHub.jsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { apiFetch, apiPost } from '../lib/api';
|
||||||
|
|
||||||
|
const SOURCE_LABELS = { words: 'Wörter', questions: 'Fragen', statements: 'Statements' };
|
||||||
|
const LANGS = [
|
||||||
|
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||||
|
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||||
|
{ code: 'sv', label: 'Svenska', flag: '🇸🇪' },
|
||||||
|
];
|
||||||
|
const SOURCES = ['words', 'questions', 'statements'];
|
||||||
|
|
||||||
|
function pct(have, total) { return total ? Math.round((have / total) * 100) : 0; }
|
||||||
|
|
||||||
|
export default function TranslationHub() {
|
||||||
|
const [coverage, setCoverage] = useState(null); // 'words|de' → {total, have, missing}
|
||||||
|
const [busy, setBusy] = useState(null); // `${table}|${to}`
|
||||||
|
const [progress, setProgress] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { coverage } = await apiFetch('/claude/translation-coverage');
|
||||||
|
const map = {};
|
||||||
|
for (const g of coverage) map[`${g.source_table}|${g.language}`] = g;
|
||||||
|
setCoverage(map);
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
async function translateMissing(table, to) {
|
||||||
|
const key = `${table}|${to}`;
|
||||||
|
setBusy(key); setError(null); setProgress(`Übersetze fehlende ${SOURCE_LABELS[table]} nach ${to.toUpperCase()} …`);
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/claude/translate-missing', { source_table: table, to });
|
||||||
|
setProgress(`Fertig: ${res.translated} übersetzt${res.failed ? `, ${res.failed} fehlgeschlagen` : ''}.`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setProgress(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
setTimeout(() => setProgress(null), 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-1">Übersetzungen</h1>
|
||||||
|
<p className="text-slate-500 mb-4">
|
||||||
|
Fehlende Sprachen per Claude automatisch übersetzen. Placeholder (z.B. <code className="px-1 bg-slate-100 rounded">{`{{Apfel.w:...}}`}</code>)
|
||||||
|
bleiben strukturell erhalten, das Label wird mit-übersetzt und korrekt gebeugt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-3 mb-6">
|
||||||
|
<b>Logik:</b> Es werden nur Zeilen übersetzt, die in mindestens einer Sprache Text haben und in der Zielsprache leer sind.
|
||||||
|
Quell-Sprache wird automatisch gewählt (Default: erste gefüllte Sprache).
|
||||||
|
Nach „Alle übersetzen" werden die Wörter mit allen 3 Sprachen automatisch auf Status <b>translated</b> gesetzt.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl px-4 py-2 mb-4">{error}</div>}
|
||||||
|
{progress && <div className="bg-amber-50 border border-amber-200 text-amber-800 text-sm rounded-xl px-4 py-2 mb-4">{progress}</div>}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{SOURCES.map(table => (
|
||||||
|
<div key={table} className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||||
|
<h2 className="font-semibold text-slate-700 mb-4">{SOURCE_LABELS[table]}</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{LANGS.map(lang => {
|
||||||
|
const g = coverage?.[`${table}|${lang.code}`];
|
||||||
|
const total = g?.total ?? 0;
|
||||||
|
const have = g?.have ?? 0;
|
||||||
|
const missing = g?.missing ?? 0;
|
||||||
|
const key = `${table}|${lang.code}`;
|
||||||
|
const p = pct(have, total);
|
||||||
|
return (
|
||||||
|
<div key={lang.code} className="border border-slate-100 rounded-xl p-3 bg-slate-50/50">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700">{lang.flag} {lang.label}</span>
|
||||||
|
<span className="text-xs text-slate-500">{coverage ? `${have}/${total}` : '…'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-200 rounded-full overflow-hidden mb-2">
|
||||||
|
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${p}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-xs ${missing ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||||
|
{missing ? `${missing} fehlen` : 'vollständig'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={!missing || busy === key}
|
||||||
|
onClick={() => translateMissing(table, lang.code)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors
|
||||||
|
${!missing
|
||||||
|
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||||
|
: busy === key
|
||||||
|
? 'bg-emerald-300 text-white cursor-wait'
|
||||||
|
: 'bg-emerald-600 text-white hover:bg-emerald-500'}`}
|
||||||
|
>
|
||||||
|
{busy === key ? 'Läuft …' : '🪄 Übersetzen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user