From 7680f4f9e32f6cbb6d02f15e4b2ca8c25857fb80 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 3 Jun 2026 07:36:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20TranslationHub,=20Pair-Review-Button=20?= =?UTF-8?q?(Action-Bar),=20reviewed=20f=C3=BCr=20Bilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.jsx | 2 + src/components/Layout.jsx | 1 + src/lib/tables.js | 2 +- src/pages/ContentCreation.jsx | 70 ++++++++++++++++++++- src/pages/Dashboard.jsx | 7 +++ src/pages/TranslationHub.jsx | 114 ++++++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/pages/TranslationHub.jsx diff --git a/src/App.jsx b/src/App.jsx index 21ccdc9..44aa0a2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import AudioHub from './pages/AudioHub'; import WordGenerator from './pages/WordGenerator'; import Publish from './pages/Publish'; import Settings from './pages/Settings'; +import TranslationHub from './pages/TranslationHub'; function RequireAuth({ children }) { const user = getUser(); @@ -28,6 +29,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 7e651c3..eed51a2 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -4,6 +4,7 @@ import { getUser, logout } from '../lib/api'; const NAV = [ { label: 'Dashboard', path: '/', match: p => p === '/' }, { 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: 'Veröffentlichen',path: '/publish', match: p => p.startsWith('/publish') }, { label: 'Datenbank', path: '/db', match: p => p.startsWith('/db') }, diff --git a/src/lib/tables.js b/src/lib/tables.js index 971555c..e21083b 100644 --- a/src/lib/tables.js +++ b/src/lib/tables.js @@ -53,7 +53,7 @@ export const TABLES = { }, editableFields: { design: { type: 'text' }, - status: { type: 'select', options: ['uploaded', 'published', 'blocked'] }, + status: { type: 'select', options: ['uploaded', 'reviewed', 'published', 'blocked'] }, picture_link: { type: 'text' }, blurhash: { type: 'text' }, generation_prompt:{ type: 'textarea' }, diff --git a/src/pages/ContentCreation.jsx b/src/pages/ContentCreation.jsx index aab8249..e84189c 100644 --- a/src/pages/ContentCreation.jsx +++ b/src/pages/ContentCreation.jsx @@ -654,11 +654,77 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
{[1,2,3].map(i =>
)}
); + 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 (
-
+
- + + {pairStatus} + +
+ {pairStatus === 'draft' && ( + + )} + {pairStatus === 'reviewed' && ( + + )} + {pairStatus !== 'blocked' && ( + + )} + {pairStatus === 'blocked' && ( + + )} +
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 1deeeed..784b8b7 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -12,6 +12,13 @@ const TILES = [ path: '/content', 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', icon: '🔊', diff --git a/src/pages/TranslationHub.jsx b/src/pages/TranslationHub.jsx new file mode 100644 index 0000000..8223fb9 --- /dev/null +++ b/src/pages/TranslationHub.jsx @@ -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 ( + +
+

Übersetzungen

+

+ Fehlende Sprachen per Claude automatisch übersetzen. Placeholder (z.B. {`{{Apfel.w:...}}`}) + bleiben strukturell erhalten, das Label wird mit-übersetzt und korrekt gebeugt. +

+ +
+ Logik: 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 translated gesetzt. +
+ + {error &&
{error}
} + {progress &&
{progress}
} + +
+ {SOURCES.map(table => ( +
+

{SOURCE_LABELS[table]}

+
+ {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 ( +
+
+ {lang.flag} {lang.label} + {coverage ? `${have}/${total}` : '…'} +
+
+
+
+
+ + {missing ? `${missing} fehlen` : 'vollständig'} + + +
+
+ ); + })} +
+
+ ))} +
+
+ + ); +}