Compare commits
19 Commits
7564f23ef1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e71e7c1b96 | ||
|
|
f5c7fbcd57 | ||
| e78be430c7 | |||
| 96c436e0e9 | |||
| e383cacd45 | |||
| af00d3323d | |||
| 2cec5bc362 | |||
| ae25dc9428 | |||
| 86145941eb | |||
| 11e3ce8770 | |||
| 2a6d203d1c | |||
| 840996fce9 | |||
| 350614d6e0 | |||
| 4fd9c3c4e4 | |||
| ae94721466 | |||
| 7680f4f9e3 | |||
| 9eecee9ace | |||
| 465c6e4954 | |||
| 232ba1ece5 |
10
src/App.jsx
10
src/App.jsx
@@ -6,6 +6,11 @@ import DatabaseAdmin from './pages/DatabaseAdmin';
|
|||||||
import TableView from './pages/TableView';
|
import TableView from './pages/TableView';
|
||||||
import ContentHub from './pages/ContentHub';
|
import ContentHub from './pages/ContentHub';
|
||||||
import ContentCreation from './pages/ContentCreation';
|
import ContentCreation from './pages/ContentCreation';
|
||||||
|
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 }) {
|
function RequireAuth({ children }) {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
@@ -23,6 +28,11 @@ export default function App() {
|
|||||||
<Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} />
|
<Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} />
|
||||||
<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="/translations" element={<RequireAuth><TranslationHub /></RequireAuth>} />
|
||||||
|
<Route path="/content/words" element={<RequireAuth><WordGenerator /></RequireAuth>} />
|
||||||
|
<Route path="/publish" element={<RequireAuth><Publish /></RequireAuth>} />
|
||||||
|
<Route path="/settings" element={<RequireAuth><Settings /></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { getUser, logout } from '../lib/api';
|
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') },
|
||||||
|
{ label: 'Einstellungen', path: '/settings',match: p => p.startsWith('/settings') },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Layout({ children, back, fullHeight = false }) {
|
export default function Layout({ children, back, fullHeight = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
@@ -11,30 +22,36 @@ export default function Layout({ children, back, fullHeight = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fullHeight ? 'h-screen flex flex-col bg-slate-100 overflow-hidden' : 'min-h-screen bg-slate-100'}>
|
<div className={fullHeight ? 'h-screen flex flex-col bg-slate-50 overflow-hidden' : 'min-h-screen bg-slate-50'}>
|
||||||
<header className="bg-indigo-700 text-white px-6 py-3 flex items-center justify-between shadow flex-shrink-0">
|
<header className="bg-white border-b border-slate-200 px-6 py-2.5 flex items-center justify-between flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
{back && (
|
{back && (
|
||||||
<button
|
<button onClick={() => navigate(back)} className="text-slate-400 hover:text-slate-700 text-sm">←</button>
|
||||||
onClick={() => navigate(back)}
|
|
||||||
className="text-indigo-200 hover:text-white text-sm mr-1"
|
|
||||||
>
|
|
||||||
← Zurück
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<span className="text-xl font-bold tracking-tight">🐟 snakkimo CMT</span>
|
<button onClick={() => navigate('/')} className="text-lg font-bold tracking-tight text-indigo-700 shrink-0">
|
||||||
|
🐟 snakkimo
|
||||||
|
</button>
|
||||||
|
<nav className="hidden md:flex items-center gap-1 text-sm ml-2">
|
||||||
|
{NAV.map(item => {
|
||||||
|
const active = item.match(location.pathname);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||||
|
active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<button onClick={() => navigate('/')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Dashboard</button>
|
<span className="text-slate-400 hidden sm:inline">{user?.email}</span>
|
||||||
<button onClick={() => navigate('/db')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Datenbank</button>
|
<button onClick={handleLogout}
|
||||||
<button onClick={() => navigate('/content')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Content</button>
|
className="text-slate-500 hover:text-red-600 px-2 py-1 rounded-lg transition-colors">
|
||||||
</nav>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<span className="text-indigo-200">{user?.email}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 px-3 py-1 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
140
src/components/PairReviewModal.jsx
Normal file
140
src/components/PairReviewModal.jsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { apiPost, apiPatch } from '../lib/api';
|
||||||
|
import { buildRows } from '../lib/pairRows';
|
||||||
|
import PlaceholderText from './PlaceholderText';
|
||||||
|
|
||||||
|
const LANGS = [
|
||||||
|
{ code: 'de', flag: '🇩🇪' },
|
||||||
|
{ code: 'en', flag: '🇬🇧' },
|
||||||
|
{ code: 'sv', flag: '🇸🇪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PairReviewModal({ pair, content, onClose, onDone, onRetranslate }) {
|
||||||
|
const [busy, setBusy] = useState(null); // 'review' | 'block' | 'retranslate'
|
||||||
|
const [missing, setMissing] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const rows = buildRows(content);
|
||||||
|
|
||||||
|
async function handleRetranslate() {
|
||||||
|
setBusy('retranslate'); setMissing(null); setError(null);
|
||||||
|
try {
|
||||||
|
await onRetranslate();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 items-center" style={{ gridTemplateColumns: '7rem 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 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 gap-2 px-6 py-4 border-t border-slate-200">
|
||||||
|
{onRetranslate && (
|
||||||
|
<button onClick={handleRetranslate} disabled={!!busy}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-40"
|
||||||
|
title="Alle Zielsprachen neu übersetzen (überschreibt vorhandene)">
|
||||||
|
{busy === 'retranslate' ? 'Läuft …' : '🔄 Neu übersetzen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<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 }) {
|
||||||
|
if (row.kind === 'single') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold text-slate-400">{row.label}</div>
|
||||||
|
<div className={`col-span-3 text-sm rounded-lg border px-2.5 py-1.5 ${row.value ? `bg-slate-50 border-slate-200 ${row.color}` : 'bg-red-50 border-red-200 text-red-400 italic'}`}>
|
||||||
|
{row.value || 'fehlt'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold text-slate-400">{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 ? <PlaceholderText text={val} /> : 'fehlt'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/PlaceholderText.jsx
Normal file
29
src/components/PlaceholderText.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { parsePlaceholderSegments } from '../lib/pairRows';
|
||||||
|
|
||||||
|
// Rendert Satztext mit farbig markierten Placeholdern:
|
||||||
|
// {{label.o:id}} → indigo (Objekt-Verknüpfung, passend zu den 🔗-Buttons)
|
||||||
|
// {{label.w:id}} → emerald (Wort-Verknüpfung)
|
||||||
|
// ⟦PHn:label⟧ → rot (geleaktes Übersetzungs-Token, Datenfehler)
|
||||||
|
const KIND_STYLES = {
|
||||||
|
object: { className: 'bg-indigo-100 text-indigo-800 rounded px-0.5', title: 'Objekt-Placeholder' },
|
||||||
|
word: { className: 'bg-emerald-100 text-emerald-800 rounded px-0.5', title: 'Wort-Placeholder' },
|
||||||
|
broken: { className: 'bg-red-100 text-red-700 rounded px-0.5 line-through', title: 'Kaputtes Übersetzungs-Token — bitte reparieren' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlaceholderText({ text }) {
|
||||||
|
const segs = parsePlaceholderSegments(text);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segs.map((s, i) => {
|
||||||
|
const style = s.kind && KIND_STYLES[s.kind];
|
||||||
|
if (!style) return <span key={i}>{s.text}</span>;
|
||||||
|
return (
|
||||||
|
<mark key={i} className={style.className}
|
||||||
|
title={`${style.title}${s.id ? ` · ${s.id.slice(0, 8)}…` : ''}`}>
|
||||||
|
{s.text}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
74
src/lib/pairRows.js
Normal file
74
src/lib/pairRows.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Geteilte Anzeige-Helfer für Pair-Inhalte (3-Sprachen-Grid).
|
||||||
|
// Genutzt von PairReviewModal und der Veröffentlichen-Seite.
|
||||||
|
|
||||||
|
// Platzhalter {{label.type:uuid}} → nur das Label anzeigen.
|
||||||
|
export 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zerlegt einen Satz in Segmente für die farbige Placeholder-Anzeige:
|
||||||
|
// { text, kind: 'object' | 'word' | 'broken' | null, id? }
|
||||||
|
// 'broken' = geleakte ⟦PHn:label⟧-Tokens aus der Übersetzung (sollten nicht vorkommen).
|
||||||
|
const SEGMENT_RE = /\{\{([^.{}]+)\.(w|o):([0-9a-f-]{36})\}\}|⟦PH\d+:([^⟧]*)⟧/g;
|
||||||
|
|
||||||
|
export function parsePlaceholderSegments(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
const str = String(text);
|
||||||
|
const segs = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of str.matchAll(SEGMENT_RE)) {
|
||||||
|
if (m.index > last) segs.push({ text: str.slice(last, m.index), kind: null });
|
||||||
|
if (m[1] !== undefined) segs.push({ text: m[1], kind: m[2] === 'o' ? 'object' : 'word', id: m[3] });
|
||||||
|
else segs.push({ text: m[4], kind: 'broken' });
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < str.length) segs.push({ text: str.slice(last), kind: null });
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baut die Anzeigezeilen je nach answer_type. Jede Zeile ist entweder
|
||||||
|
// - { kind: 'lang', label, color, cell(l) } → eine Zelle pro Sprache (übersetzbar)
|
||||||
|
// - { kind: 'single', label, color, value } → ein einzelner Wert (nicht sprachabhängig)
|
||||||
|
export function buildRows(content) {
|
||||||
|
if (!content) return [];
|
||||||
|
const type = content?.answer_type;
|
||||||
|
const rows = [];
|
||||||
|
const wordsCell = (stmt) => (l) =>
|
||||||
|
(stmt?.words || []).map(w => w[`titel_${l}`] || '—').join(', ');
|
||||||
|
// Roher Text inkl. {{…}}-Placeholder — die Anzeige läuft über <PlaceholderText>.
|
||||||
|
const sentenceCell = (stmt, prefix) => (l) =>
|
||||||
|
stmt?.sentence?.[`${prefix}_${l}`] || '';
|
||||||
|
|
||||||
|
// Frage (yes_no / question / word)
|
||||||
|
if (content.question) {
|
||||||
|
rows.push({ kind: 'lang', label: 'Frage', color: 'text-slate-700',
|
||||||
|
cell: l => content.question[`sentence_${l}`] || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'yes_no') {
|
||||||
|
// Ja/Nein-Antwort ist ein boolescher Wert, keine Übersetzung
|
||||||
|
const a = content.positive?.answer;
|
||||||
|
rows.push({ kind: 'single', label: 'Antwort', color: 'text-green-700',
|
||||||
|
value: a === true ? '✓ Ja' : a === false ? '✗ Nein' : null });
|
||||||
|
} else if (type === 'word') {
|
||||||
|
rows.push({ kind: 'lang', label: 'Positiv-Wörter', color: 'text-green-700',
|
||||||
|
cell: wordsCell(content.positive) });
|
||||||
|
// 'word' braucht laut Datenmodell Negativ-Wörter → Zeile immer zeigen, fehlende sichtbar machen
|
||||||
|
rows.push({ kind: 'lang', label: 'Negativ-Wörter', color: 'text-red-600',
|
||||||
|
cell: wordsCell(content.negative) });
|
||||||
|
} else {
|
||||||
|
// text / question → Sätze
|
||||||
|
if (content.positive)
|
||||||
|
rows.push({ kind: 'lang', label: 'Positiv', color: 'text-green-700',
|
||||||
|
cell: sentenceCell(content.positive, 'positive_sentence') });
|
||||||
|
// 'question' = Frage + Positiv + Negativ → Negativ-Zeile immer zeigen, auch wenn leer
|
||||||
|
if (type === 'question')
|
||||||
|
rows.push({ kind: 'lang', label: 'Negativ', color: 'text-red-600',
|
||||||
|
cell: sentenceCell(content.negative, 'negative_sentence') });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
105
src/lib/tables.js
Normal file → Executable file
105
src/lib/tables.js
Normal file → Executable file
@@ -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' },
|
||||||
@@ -85,7 +85,7 @@ export const TABLES = {
|
|||||||
editableFields: {
|
editableFields: {
|
||||||
notes: { type: 'textarea' },
|
notes: { type: 'textarea' },
|
||||||
blocked_topic:{ type: 'text' },
|
blocked_topic:{ type: 'text' },
|
||||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||||
},
|
},
|
||||||
fetchRelated: [
|
fetchRelated: [
|
||||||
{
|
{
|
||||||
@@ -127,7 +127,7 @@ export const TABLES = {
|
|||||||
answer_type: { type: 'select', options: ['yes_no', 'text', 'question', 'word'] },
|
answer_type: { type: 'select', options: ['yes_no', 'text', 'question', 'word'] },
|
||||||
difficulty_level: { type: 'number', min: 1, max: 50 },
|
difficulty_level: { type: 'number', min: 1, max: 50 },
|
||||||
blocked_topic: { type: 'text' },
|
blocked_topic: { type: 'text' },
|
||||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||||
},
|
},
|
||||||
fetchRelated: [],
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
@@ -145,7 +145,7 @@ export const TABLES = {
|
|||||||
sentence_en: { type: 'textarea' },
|
sentence_en: { type: 'textarea' },
|
||||||
sentence_sv: { type: 'textarea' },
|
sentence_sv: { type: 'textarea' },
|
||||||
blocked_topic:{ type: 'text' },
|
blocked_topic:{ type: 'text' },
|
||||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||||
},
|
},
|
||||||
fetchRelated: [],
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
@@ -167,7 +167,7 @@ export const TABLES = {
|
|||||||
negative_sentence_sv: { type: 'textarea' },
|
negative_sentence_sv: { type: 'textarea' },
|
||||||
answer: { type: 'boolean' },
|
answer: { type: 'boolean' },
|
||||||
blocked_topic: { type: 'text' },
|
blocked_topic: { type: 'text' },
|
||||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||||
},
|
},
|
||||||
fetchRelated: [],
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
@@ -273,12 +273,107 @@ export const TABLES = {
|
|||||||
},
|
},
|
||||||
fetchRelated: [],
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
audios: {
|
||||||
|
label: 'Audios',
|
||||||
|
icon: '🔊',
|
||||||
|
endpoint: '/audios',
|
||||||
|
statusField: 'status',
|
||||||
|
primaryLabel: 'text',
|
||||||
|
columns: ['text', 'language', 'source_table', 'source_field', 'status', 'audio_link', 'voice_id', 'created_at'],
|
||||||
|
linkedFields: {},
|
||||||
|
editableFields: {
|
||||||
|
text: { type: 'textarea' },
|
||||||
|
status: { type: 'select', options: ['generated', 'published', 'blocked'] },
|
||||||
|
language: { type: 'select', options: ['de', 'en', 'sv'] },
|
||||||
|
source_table: { type: 'select', options: ['words', 'questions', 'statements'] },
|
||||||
|
source_id: { type: 'text' },
|
||||||
|
source_field: { type: 'text' },
|
||||||
|
voice_id: { type: 'text' },
|
||||||
|
model_id: { type: 'text' },
|
||||||
|
audio_link: { type: 'text' },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
'prompt-styles': {
|
||||||
|
label: 'Prompt Styles',
|
||||||
|
icon: '🎨',
|
||||||
|
endpoint: '/prompt-styles',
|
||||||
|
statusField: null,
|
||||||
|
primaryLabel: 'text_en',
|
||||||
|
columns: ['type', 'kategorie_id', 'text_en'],
|
||||||
|
linkedFields: { kategorie_id: 'categories' },
|
||||||
|
editableFields: {
|
||||||
|
type: { type: 'select', options: ['fix', 'atmosphere', 'setting'] },
|
||||||
|
kategorie_id: { type: 'text' },
|
||||||
|
text_en: { type: 'textarea' },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
'picture-jobs': {
|
||||||
|
label: 'Picture Jobs',
|
||||||
|
icon: '🖼️⚙️',
|
||||||
|
endpoint: '/picture-jobs',
|
||||||
|
statusField: 'status',
|
||||||
|
primaryLabel: 'id',
|
||||||
|
columns: ['id', 'status', 'kategorie_id', 'prompt_fix', 'prompt_atmosphere', 'prompt_setting', 'picture_id', 'created_at'],
|
||||||
|
linkedFields: {
|
||||||
|
kategorie_id: 'categories',
|
||||||
|
prompt_fix: 'prompt-styles',
|
||||||
|
prompt_atmosphere: 'prompt-styles',
|
||||||
|
prompt_setting: 'prompt-styles',
|
||||||
|
picture_id: 'pictures',
|
||||||
|
},
|
||||||
|
editableFields: {
|
||||||
|
status: { type: 'select', options: ['pending', 'generating', 'done', 'failed'] },
|
||||||
|
prompt_final: { type: 'textarea' },
|
||||||
|
kategorie_id: { type: 'text' },
|
||||||
|
prompt_fix: { type: 'text' },
|
||||||
|
prompt_atmosphere: { type: 'text' },
|
||||||
|
prompt_setting: { type: 'text' },
|
||||||
|
picture_id: { type: 'text' },
|
||||||
|
},
|
||||||
|
fetchRelated: [
|
||||||
|
{
|
||||||
|
key: 'words',
|
||||||
|
label: 'Wörter',
|
||||||
|
endpoint: id => `/picture-jobs/${id}/words`,
|
||||||
|
display: w => w.titel_de || w.id,
|
||||||
|
targetTable: 'words',
|
||||||
|
linkEndpoint: (id, targetId) => `/picture-jobs/${id}/words/${targetId}`,
|
||||||
|
searchEndpoint: '/words',
|
||||||
|
searchLabel: w => w.titel_de || w.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
'tts-settings': {
|
||||||
|
label: 'TTS-Stimmen',
|
||||||
|
icon: '🗣️',
|
||||||
|
endpoint: '/tts-settings',
|
||||||
|
statusField: null,
|
||||||
|
primaryLabel: 'language',
|
||||||
|
columns: ['language', 'voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style', 'updated_at'],
|
||||||
|
linkedFields: {},
|
||||||
|
editableFields: {
|
||||||
|
voice_id: { type: 'text' },
|
||||||
|
model_id: { type: 'text' },
|
||||||
|
speed: { type: 'number' },
|
||||||
|
stability: { type: 'number' },
|
||||||
|
similarity_boost: { type: 'number' },
|
||||||
|
style: { type: 'number' },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_COLORS = {
|
export const STATUS_COLORS = {
|
||||||
published: 'bg-violet-100 text-violet-800',
|
published: 'bg-violet-100 text-violet-800',
|
||||||
blocked: 'bg-red-100 text-red-800',
|
blocked: 'bg-red-100 text-red-800',
|
||||||
draft: 'bg-gray-100 text-gray-600',
|
draft: 'bg-gray-100 text-gray-600',
|
||||||
|
reviewed: 'bg-teal-100 text-teal-800',
|
||||||
uploaded: 'bg-blue-100 text-blue-800',
|
uploaded: 'bg-blue-100 text-blue-800',
|
||||||
requested: 'bg-yellow-100 text-yellow-800',
|
requested: 'bg-yellow-100 text-yellow-800',
|
||||||
translated: 'bg-indigo-100 text-indigo-800',
|
translated: 'bg-indigo-100 text-indigo-800',
|
||||||
|
|||||||
155
src/pages/AudioHub.jsx
Normal file
155
src/pages/AudioHub.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { apiFetch, apiPost, apiPatch, fetchAll } from '../lib/api';
|
||||||
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!total) return 0;
|
||||||
|
return Math.round((have / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioHub() {
|
||||||
|
const [coverage, setCoverage] = useState(null); // { 'words|de': {total, withAudio, missing} }
|
||||||
|
const [audios, setAudios] = useState([]);
|
||||||
|
const [busy, setBusy] = useState(null); // `${table}|${lang}` currently generating
|
||||||
|
const [progress, setProgress] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const loadCoverage = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { coverage } = await apiFetch('/audios/coverage');
|
||||||
|
const map = {};
|
||||||
|
for (const g of coverage) map[`${g.source_table}|${g.language}`] = g;
|
||||||
|
setCoverage(map);
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAudios = useCallback(async () => {
|
||||||
|
try { setAudios(await fetchAll('/audios')); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadCoverage(); loadAudios(); }, [loadCoverage, loadAudios]);
|
||||||
|
|
||||||
|
async function generateMissing(table, lang) {
|
||||||
|
const key = `${table}|${lang}`;
|
||||||
|
setBusy(key); setError(null); setProgress('Generiere fehlende Audios …');
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/audios/generate-batch', { source_table: table, language: lang });
|
||||||
|
setProgress(`Fertig: ${res.generated} erzeugt${res.failed ? `, ${res.failed} fehlgeschlagen` : ''}.`);
|
||||||
|
await loadCoverage();
|
||||||
|
await loadAudios();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setProgress(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
setTimeout(() => setProgress(null), 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStatus(id, status) {
|
||||||
|
await apiPatch('/audios', id, { status });
|
||||||
|
await loadAudios();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-2">Audio / TTS</h1>
|
||||||
|
<p className="text-slate-500 mb-4">
|
||||||
|
Hier siehst du, wie viele Sätze & Wörter noch <b>kein Audio</b> haben, und kannst die
|
||||||
|
Sprachausgabe (ElevenLabs) per Knopfdruck erzeugen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 text-blue-800 text-sm rounded-xl px-4 py-3 mb-6">
|
||||||
|
<b>So funktioniert's:</b> „Benötigt" zählt geprüfte/veröffentlichte Inhalte
|
||||||
|
(Wörter ab Status <i>generated</i>, Fragen/Statements ab <i>reviewed</i>) mit Text in der jeweiligen
|
||||||
|
Sprache. Erst wenn ein Pair für die Zielsprache komplett vertont ist, erscheint es in der App.
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coverage-Matrix */}
|
||||||
|
<div className="space-y-6 mb-10">
|
||||||
|
{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?.withAudio ?? 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={() => generateMissing(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-purple-300 text-white cursor-wait'
|
||||||
|
: 'bg-purple-600 text-white hover:bg-purple-500'}`}
|
||||||
|
>
|
||||||
|
{busy === key ? 'Läuft …' : 'Generieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audio-Liste */}
|
||||||
|
<h2 className="font-semibold text-slate-700 mb-3">Erzeugte Audios ({audios.length})</h2>
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
|
||||||
|
{audios.length === 0 && (
|
||||||
|
<div className="p-5 text-sm text-slate-400">Noch keine Audios erzeugt.</div>
|
||||||
|
)}
|
||||||
|
{audios.slice(0, 100).map(a => (
|
||||||
|
<div key={a.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-xs uppercase font-semibold text-slate-400 w-8">{a.language}</span>
|
||||||
|
<span className="text-xs text-slate-400 w-24 truncate">{a.source_table}</span>
|
||||||
|
<span className="flex-1 text-sm text-slate-700 truncate">{a.text}</span>
|
||||||
|
{a.audio_link && <audio controls src={a.audio_link} className="h-8" />}
|
||||||
|
<span className={`${STATUS_COLORS[a.status] || ''} rounded-full px-2 py-0.5 text-xs font-medium`}>{a.status}</span>
|
||||||
|
{a.status !== 'published' && (
|
||||||
|
<button onClick={() => setStatus(a.id, 'published')}
|
||||||
|
className="text-xs text-violet-600 hover:text-violet-800">Freigeben</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -464,7 +465,7 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
|
|
||||||
// ─── EditPairForm ─────────────────────────────────────────────────────────────
|
// ─── EditPairForm ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted, onSavedAndTranslate }) {
|
||||||
const lang = getUserLang();
|
const lang = getUserLang();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -591,7 +592,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
finally { setCreatingWord(false); }
|
finally { setCreatingWord(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave({ translateAfter = false } = {}) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const ph = t => withPlaceholders(t, wordMap, objectAssignments);
|
const ph = t => withPlaceholders(t, wordMap, objectAssignments);
|
||||||
@@ -645,7 +646,8 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
if (pair.answer_type !== type) patchPair.answer_type = type;
|
if (pair.answer_type !== type) patchPair.answer_type = type;
|
||||||
if (pair.status !== pairStatus) patchPair.status = pairStatus;
|
if (pair.status !== pairStatus) patchPair.status = pairStatus;
|
||||||
if (Object.keys(patchPair).length) await apiPatch('/pairs', pair.id, patchPair);
|
if (Object.keys(patchPair).length) await apiPatch('/pairs', pair.id, patchPair);
|
||||||
onSaved();
|
if (translateAfter && onSavedAndTranslate) onSavedAndTranslate({ ...pair, answer_type: type });
|
||||||
|
else onSaved();
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
@@ -654,11 +656,80 @@ 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 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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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','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>
|
||||||
@@ -767,10 +838,17 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
<div className="pt-2 border-t border-amber-100 space-y-1.5">
|
<div className="pt-2 border-t border-amber-100 space-y-1.5">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={onCancel} className="flex-1 py-1.5 text-xs font-medium rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors">Abbrechen</button>
|
<button onClick={onCancel} className="flex-1 py-1.5 text-xs font-medium rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors">Abbrechen</button>
|
||||||
<button onClick={handleSave} disabled={saving} className="flex-1 py-1.5 text-xs font-medium rounded-lg bg-amber-500 hover:bg-amber-600 disabled:opacity-40 text-white transition-colors">
|
<button onClick={() => handleSave()} disabled={saving} className="flex-1 py-1.5 text-xs font-medium rounded-lg bg-amber-500 hover:bg-amber-600 disabled:opacity-40 text-white transition-colors">
|
||||||
{saving ? 'Speichern…' : '✓ Speichern'}
|
{saving ? 'Speichern…' : '✓ Speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{onSavedAndTranslate && (
|
||||||
|
<button onClick={() => handleSave({ translateAfter: true })} disabled={saving}
|
||||||
|
className="w-full py-1.5 text-xs font-medium rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-colors"
|
||||||
|
title="Pair speichern und direkt das Übersetzen-&-Prüfen-Modal öffnen">
|
||||||
|
{saving ? 'Speichern…' : '💾 Speichern & übersetzen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!confirmDelete ? (
|
{!confirmDelete ? (
|
||||||
<button onClick={() => setConfirmDelete(true)}
|
<button onClick={() => setConfirmDelete(true)}
|
||||||
className="w-full py-1.5 text-xs font-medium rounded-lg border border-red-200 text-red-400 hover:bg-red-50 hover:text-red-600 hover:border-red-300 transition-colors">
|
className="w-full py-1.5 text-xs font-medium rounded-lg border border-red-200 text-red-400 hover:bg-red-50 hover:text-red-600 hover:border-red-300 transition-colors">
|
||||||
@@ -795,7 +873,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
|
|||||||
|
|
||||||
// ─── Left panel: Object list ──────────────────────────────────────────────────
|
// ─── Left panel: Object list ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAddObject, onSelectObject, currentPicture, onObjectStatusChange }) {
|
function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAddObject, onSelectObject, currentPicture, onObjectStatusChange, onPicturesReload }) {
|
||||||
return (
|
return (
|
||||||
<aside className="w-1/5 min-w-[180px] border-r border-slate-200 bg-white flex flex-col overflow-hidden">
|
<aside className="w-1/5 min-w-[180px] border-r border-slate-200 bg-white flex flex-col overflow-hidden">
|
||||||
<div className="px-3 py-2.5 border-b border-slate-100 bg-slate-50 flex-shrink-0 space-y-1.5">
|
<div className="px-3 py-2.5 border-b border-slate-100 bg-slate-50 flex-shrink-0 space-y-1.5">
|
||||||
@@ -809,7 +887,10 @@ function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAd
|
|||||||
<span className="text-base leading-none">+</span> Objekt hinzufügen
|
<span className="text-base leading-none">+</span> Objekt hinzufügen
|
||||||
</button>
|
</button>
|
||||||
{objects.length > 0 && (
|
{objects.length > 0 && (
|
||||||
<AutoCreateAllButton currentPicture={currentPicture} objects={objects} />
|
<>
|
||||||
|
<ReleaseButton currentPicture={currentPicture} objects={objects} onPicturesReload={onPicturesReload} />
|
||||||
|
<AutoCreateAllButton currentPicture={currentPicture} objects={objects} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
@@ -832,7 +913,7 @@ function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAd
|
|||||||
<span className="text-xs font-bold text-slate-400 shrink-0">#{i + 1}</span>
|
<span className="text-xs font-bold text-slate-400 shrink-0">#{i + 1}</span>
|
||||||
<StatusSelect
|
<StatusSelect
|
||||||
value={obj.status}
|
value={obj.status}
|
||||||
options={['draft', 'published', 'blocked']}
|
options={['draft', 'reviewed', 'published', 'blocked']}
|
||||||
onChange={s => onObjectStatusChange(obj.id, s)}
|
onChange={s => onObjectStatusChange(obj.id, s)}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
@@ -1097,15 +1178,181 @@ function AutoCreateAllButton({ currentPicture, objects }) {
|
|||||||
return (
|
return (
|
||||||
<button onClick={handleAutoCreateAll}
|
<button onClick={handleAutoCreateAll}
|
||||||
className="w-full py-1.5 text-xs font-medium rounded-lg bg-violet-600 hover:bg-violet-700 text-white transition-colors flex items-center justify-center gap-1.5">
|
className="w-full py-1.5 text-xs font-medium rounded-lg bg-violet-600 hover:bg-violet-700 text-white transition-colors flex items-center justify-center gap-1.5">
|
||||||
✨ Auto Pairs erstellen
|
✨ Auto Pairs erstellen (manuell)
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Freigeben: serverseitige Pipeline (Pairs → Übersetzung → KI-Review → Audio) ──
|
||||||
|
|
||||||
|
function ReleaseButton({ currentPicture, objects, onPicturesReload }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
// Optimistischer lokaler Status, bis der nächste Pictures-Reload den Serverstand bringt
|
||||||
|
const [localStatus, setLocalStatus] = useState(null);
|
||||||
|
useEffect(() => { setLocalStatus(null); setError(''); }, [currentPicture?.id]);
|
||||||
|
|
||||||
|
const status = localStatus || currentPicture?.pipeline_status || 'none';
|
||||||
|
|
||||||
|
async function call(path) {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await apiPost(path, {});
|
||||||
|
setLocalStatus('queued');
|
||||||
|
onPicturesReload?.();
|
||||||
|
} catch (e) { setError(e.payload?.error || e.message); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'queued' || status === 'running') {
|
||||||
|
return (
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-sky-700 bg-sky-50 border border-sky-200 rounded-lg flex items-center gap-2">
|
||||||
|
<svg className="animate-spin h-3 w-3 text-sky-500 shrink-0" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Pipeline läuft… Fortschritt unter „Veröffentlichen"</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'ready') {
|
||||||
|
return (
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg font-medium text-center">
|
||||||
|
✓ Bereit zur Freigabe — siehe „Veröffentlichen"
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'published') {
|
||||||
|
return (
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded-lg text-center">
|
||||||
|
Veröffentlicht
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
Pipeline fehlgeschlagen{currentPicture?.pipeline_error ? `: ${currentPicture.pipeline_error}` : ''}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => call(`/pipeline/retry/${currentPicture.id}`)} disabled={busy}
|
||||||
|
className="w-full py-1.5 text-xs font-medium rounded-lg bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white transition-colors">
|
||||||
|
{busy ? 'Starte…' : '↻ Erneut versuchen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<button onClick={() => call(`/pipeline/release/${currentPicture.id}`)}
|
||||||
|
disabled={busy || !currentPicture || objects.length === 0}
|
||||||
|
className="w-full py-2 text-xs font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 text-white transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
{busy ? 'Starte…' : '🚀 Bild freigeben (Auto-Pipeline)'}
|
||||||
|
</button>
|
||||||
|
{error && <div className="text-xs text-red-600 px-1">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Geführter Review-Flow (Wizard) ──────────────────────────────────────────
|
||||||
|
// Läuft die Pairs des ausgewählten Objekts der Reihe nach durch. Wiederverwendung:
|
||||||
|
// EditPairForm (Editor + alle Aktionen) und PairReviewModal (Übersetzungs-Prüf-Grid).
|
||||||
|
|
||||||
|
function PairReviewWizard({ pairs, allObjects, onClose, onPairsReload }) {
|
||||||
|
const [queue] = useState(() => pairs); // Snapshot — stabile Navigation
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [reviewData, setReviewData] = useState(null); // { pair, content } | null
|
||||||
|
const pair = queue[index];
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
if (index + 1 < queue.length) { setIndex(index + 1); setReviewData(null); }
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTranslate(p) {
|
||||||
|
const res = await apiPost(`/pairs/${p.id}/translate`, {});
|
||||||
|
setReviewData({ pair: p, content: res.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetranslate(p) {
|
||||||
|
const res = await apiPost(`/pairs/${p.id}/translate`, { overwrite: true });
|
||||||
|
setReviewData({ pair: p, content: res.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pair) { onClose(); return null; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 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-lg my-8 flex flex-col">
|
||||||
|
{/* Header mit Fortschritt + Navigation */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-b border-slate-200">
|
||||||
|
<span className="text-lg">🚀</span>
|
||||||
|
<span className="font-semibold text-slate-800 text-sm">
|
||||||
|
Pair {index + 1}/{queue.length}
|
||||||
|
</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 className="ml-auto flex items-center gap-1.5">
|
||||||
|
<button onClick={() => setIndex(i => Math.max(0, i - 1))} disabled={index === 0}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg text-slate-500 hover:bg-slate-100 disabled:opacity-30"
|
||||||
|
title="Vorheriges Pair">‹ Zurück</button>
|
||||||
|
<button onClick={advance}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg text-slate-500 hover:bg-slate-100"
|
||||||
|
title="Ohne Änderung zum nächsten Pair">Überspringen →</button>
|
||||||
|
<button onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-xl leading-none px-1" aria-label="Flow beenden">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — vollständiger Editor pro Pair (remountet via key) */}
|
||||||
|
<div className="px-5 py-4 overflow-y-auto">
|
||||||
|
<EditPairForm
|
||||||
|
key={pair.id}
|
||||||
|
pair={pair}
|
||||||
|
allObjects={allObjects}
|
||||||
|
onSaved={() => { onPairsReload(); advance(); }}
|
||||||
|
onCancel={onClose}
|
||||||
|
onDeleted={() => { onPairsReload(); advance(); }}
|
||||||
|
onSavedAndTranslate={(savedPair) => handleTranslate(savedPair)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewData && (
|
||||||
|
<PairReviewModal
|
||||||
|
pair={reviewData.pair}
|
||||||
|
content={reviewData.content}
|
||||||
|
onClose={() => setReviewData(null)}
|
||||||
|
onDone={() => { onPairsReload(); advance(); }}
|
||||||
|
onRetranslate={() => handleRetranslate(reviewData.pair)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 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 }
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetranslate(pair) {
|
||||||
|
const res = await apiPost(`/pairs/${pair.id}/translate`, { overwrite: true });
|
||||||
|
setReviewData({ pair, content: res.content });
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedObject) {
|
if (!selectedObject) {
|
||||||
return (
|
return (
|
||||||
@@ -1119,10 +1366,17 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-2/5 border-l border-slate-200 bg-white flex flex-col overflow-hidden">
|
<aside className="w-2/5 border-l border-slate-200 bg-white flex flex-col overflow-hidden">
|
||||||
<div className="px-3 py-2.5 border-b border-slate-100 bg-slate-50 flex-shrink-0">
|
<div className="px-3 py-2.5 border-b border-slate-100 bg-slate-50 flex-shrink-0 flex items-center gap-2">
|
||||||
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||||
Pairs — Objekt #{selectedObject._index + 1}
|
Pairs — Objekt #{selectedObject._index + 1}
|
||||||
</h2>
|
</h2>
|
||||||
|
{objectPairs.length > 0 && (
|
||||||
|
<button onClick={() => setWizardOpen(true)}
|
||||||
|
className="ml-auto text-xs font-medium text-violet-700 bg-violet-50 hover:bg-violet-100 px-2 py-0.5 rounded transition-colors"
|
||||||
|
title="Alle Pairs dieses Objekts nacheinander prüfen">
|
||||||
|
🚀 Review-Flow starten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="p-3 border-b border-slate-100">
|
<div className="p-3 border-b border-slate-100">
|
||||||
@@ -1141,7 +1395,8 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
|
|||||||
<EditPairForm pair={pair} allObjects={allObjects}
|
<EditPairForm pair={pair} allObjects={allObjects}
|
||||||
onSaved={() => { setEditingId(null); onPairsReload(); }}
|
onSaved={() => { setEditingId(null); onPairsReload(); }}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
onDeleted={id => { setEditingId(null); onPairsReload(); }} />
|
onDeleted={id => { setEditingId(null); onPairsReload(); }}
|
||||||
|
onSavedAndTranslate={(savedPair) => { setEditingId(null); onPairsReload(); handleTranslate(savedPair); }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-slate-200 p-3 bg-slate-50 space-y-1.5">
|
<div className="rounded-lg border border-slate-200 p-3 bg-slate-50 space-y-1.5">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
@@ -1153,8 +1408,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">
|
||||||
@@ -1180,6 +1440,25 @@ 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)(); }}
|
||||||
|
onRetranslate={() => handleRetranslate(reviewData.pair)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wizardOpen && (
|
||||||
|
<PairReviewWizard
|
||||||
|
pairs={objectPairs}
|
||||||
|
allObjects={allObjects}
|
||||||
|
onClose={() => { setWizardOpen(false); (onReloadAll || onPairsReload)(); }}
|
||||||
|
onPairsReload={onPairsReload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1327,6 +1606,7 @@ export default function ContentCreation() {
|
|||||||
const [loadingPairs, setLoadingPairs] = useState(false);
|
const [loadingPairs, setLoadingPairs] = useState(false);
|
||||||
|
|
||||||
const [markingDone, setMarkingDone] = useState(false);
|
const [markingDone, setMarkingDone] = useState(false);
|
||||||
|
const [deletingPicture, setDeletingPicture] = useState(false);
|
||||||
|
|
||||||
const currentPicture = pictures[pictureIndex] || null;
|
const currentPicture = pictures[pictureIndex] || null;
|
||||||
const selectedObjectId = typeof mode === 'string' && mode !== 'add' ? mode : null;
|
const selectedObjectId = typeof mode === 'string' && mode !== 'add' ? mode : null;
|
||||||
@@ -1335,15 +1615,21 @@ export default function ContentCreation() {
|
|||||||
? { ...selectedObject, _index: objects.indexOf(selectedObject) }
|
? { ...selectedObject, _index: objects.indexOf(selectedObject) }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Load all pictures
|
// Load all pictures (published sind fertig und werden hier nicht mehr bearbeitet)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingPictures(true);
|
setLoadingPictures(true);
|
||||||
apiFetch('/pictures?limit=500')
|
apiFetch('/pictures?limit=500')
|
||||||
.then(data => setPictures(Array.isArray(data) ? data : []))
|
.then(data => setPictures(Array.isArray(data) ? data.filter(p => p.status !== 'published') : []))
|
||||||
.catch(() => setPictures([]))
|
.catch(() => setPictures([]))
|
||||||
.finally(() => setLoadingPictures(false));
|
.finally(() => setLoadingPictures(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Index im gültigen Bereich halten, wenn die Liste schrumpft
|
||||||
|
useEffect(() => {
|
||||||
|
if (pictureIndex > 0 && pictureIndex >= pictures.length)
|
||||||
|
setPictureIndex(Math.max(0, pictures.length - 1));
|
||||||
|
}, [pictures.length, pictureIndex]);
|
||||||
|
|
||||||
// Load objects when picture changes
|
// Load objects when picture changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture) return;
|
if (!currentPicture) return;
|
||||||
@@ -1441,10 +1727,27 @@ export default function ContentCreation() {
|
|||||||
if (!currentPicture) return;
|
if (!currentPicture) return;
|
||||||
try {
|
try {
|
||||||
await apiPatch('/pictures', currentPicture.id, { status: newStatus });
|
await apiPatch('/pictures', currentPicture.id, { status: newStatus });
|
||||||
setPictures(prev => prev.map((p, i) => i === pictureIndex ? { ...p, status: newStatus } : p));
|
if (newStatus === 'published') {
|
||||||
|
// Veröffentlichte Bilder verschwinden aus der Bearbeitungs-Ansicht
|
||||||
|
setPictures(prev => prev.filter((_, i) => i !== pictureIndex));
|
||||||
|
} else {
|
||||||
|
setPictures(prev => prev.map((p, i) => i === pictureIndex ? { ...p, status: newStatus } : p));
|
||||||
|
}
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeletePicture() {
|
||||||
|
if (!currentPicture) return;
|
||||||
|
const ok = window.confirm('Bild endgültig löschen? Entfernt das Bild (S3 + Datenbank) inklusive aller Objekte und Pairs.');
|
||||||
|
if (!ok) return;
|
||||||
|
setDeletingPicture(true);
|
||||||
|
try {
|
||||||
|
await apiDelete('/pictures', currentPicture.id);
|
||||||
|
setPictures(prev => prev.filter((_, i) => i !== pictureIndex));
|
||||||
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
|
finally { setDeletingPicture(false); }
|
||||||
|
}
|
||||||
|
|
||||||
async function handleObjectStatusChange(objectId, newStatus) {
|
async function handleObjectStatusChange(objectId, newStatus) {
|
||||||
try {
|
try {
|
||||||
await apiPatch('/objects', objectId, { status: newStatus });
|
await apiPatch('/objects', objectId, { status: newStatus });
|
||||||
@@ -1461,6 +1764,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.filter(p => p.status !== 'published') : []))
|
||||||
|
.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">
|
||||||
@@ -1494,6 +1824,12 @@ export default function ContentCreation() {
|
|||||||
{currentPicture.objects_created && (
|
{currentPicture.objects_created && (
|
||||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">✓ Objekte</span>
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">✓ Objekte</span>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={handleDeletePicture}
|
||||||
|
disabled={deletingPicture}
|
||||||
|
title="Bild löschen (S3 + Datenbank, inkl. Objekte und Pairs)"
|
||||||
|
className="px-2 py-1 text-xs rounded-lg border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-40 transition-colors">
|
||||||
|
{deletingPicture ? 'Lösche…' : '🗑'}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleMarkDone}
|
<button onClick={handleMarkDone}
|
||||||
@@ -1515,6 +1851,7 @@ export default function ContentCreation() {
|
|||||||
onSelectObject={handleSelectObject}
|
onSelectObject={handleSelectObject}
|
||||||
currentPicture={currentPicture}
|
currentPicture={currentPicture}
|
||||||
onObjectStatusChange={handleObjectStatusChange}
|
onObjectStatusChange={handleObjectStatusChange}
|
||||||
|
onPicturesReload={reloadPictures}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ImageCanvas
|
<ImageCanvas
|
||||||
@@ -1550,6 +1887,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>
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ const TOOLS = [
|
|||||||
path: '/content/creation',
|
path: '/content/creation',
|
||||||
ready: true,
|
ready: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'words',
|
||||||
|
title: 'Wörter generieren',
|
||||||
|
icon: '🪄',
|
||||||
|
description: 'Neue Vokabeln zu einem Thema per KI erstellen, prüfen und übernehmen.',
|
||||||
|
path: '/content/words',
|
||||||
|
ready: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audio',
|
||||||
|
title: 'Audio / TTS',
|
||||||
|
icon: '🔊',
|
||||||
|
description: 'Sehen was noch kein Audio hat und Sprachausgabe generieren.',
|
||||||
|
path: '/audio',
|
||||||
|
ready: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'publish',
|
key: 'publish',
|
||||||
title: 'Veröffentlichen',
|
title: 'Veröffentlichen',
|
||||||
|
|||||||
@@ -1,7 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import { fetchAll } from '../lib/api';
|
||||||
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
const TILES = [
|
const TILES = [
|
||||||
|
{
|
||||||
|
title: 'Content erstellen',
|
||||||
|
icon: '✏️',
|
||||||
|
description: 'Objekte markieren, Pairs & Sätze erstellen und prüfen.',
|
||||||
|
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: '🔊',
|
||||||
|
description: 'Sehen was noch kein Audio hat und Sprachausgabe generieren.',
|
||||||
|
path: '/audio',
|
||||||
|
color: 'border-purple-200 hover:border-purple-400 hover:bg-purple-50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Wörter generieren',
|
||||||
|
icon: '🪄',
|
||||||
|
description: 'Neue Wörter zu einem Thema per KI erzeugen lassen.',
|
||||||
|
path: '/content/words',
|
||||||
|
color: 'border-emerald-200 hover:border-emerald-400 hover:bg-emerald-50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Veröffentlichen',
|
||||||
|
icon: '🚀',
|
||||||
|
description: 'Fast fertige Inhalte sehen und mit einem Klick live schalten.',
|
||||||
|
path: '/publish',
|
||||||
|
color: 'border-violet-200 hover:border-violet-400 hover:bg-violet-50',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Datenbankverwaltung',
|
title: 'Datenbankverwaltung',
|
||||||
icon: '🗄️',
|
icon: '🗄️',
|
||||||
@@ -10,36 +48,91 @@ const TILES = [
|
|||||||
color: 'border-indigo-200 hover:border-indigo-400 hover:bg-indigo-50',
|
color: 'border-indigo-200 hover:border-indigo-400 hover:bg-indigo-50',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Contentverwaltung',
|
title: 'Einstellungen',
|
||||||
icon: '✏️',
|
icon: '⚙️',
|
||||||
description: 'Inhalte erstellen, bearbeiten und veröffentlichen.',
|
description: 'TTS-Stimmen pro Sprache und weitere Konfiguration.',
|
||||||
path: null,
|
path: '/settings',
|
||||||
color: 'border-slate-200 hover:border-slate-300 bg-slate-50 opacity-60 cursor-not-allowed',
|
color: 'border-slate-200 hover:border-slate-400 hover:bg-slate-100',
|
||||||
soon: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Pipeline-Stufen, die den Lebenszyklus eines Inhalts abbilden.
|
||||||
|
const PIPELINES = [
|
||||||
|
{ key: 'pictures', label: 'Bilder', endpoint: '/pictures', stages: ['uploaded', 'published', 'blocked'] },
|
||||||
|
{ key: 'objects', label: 'Objekte', endpoint: '/objects', stages: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||||
|
{ key: 'pairs', label: 'Pairs', endpoint: '/pairs', stages: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||||
|
{ key: 'words', label: 'Wörter', endpoint: '/words', stages: ['requested', 'translated', 'generated', 'published', 'blocked'] },
|
||||||
|
{ key: 'audios', label: 'Audios', endpoint: '/audios', stages: ['generated', 'published', 'blocked'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function countByStatus(rows) {
|
||||||
|
const out = {};
|
||||||
|
for (const r of rows || []) out[r.status] = (out[r.status] || 0) + 1;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [counts, setCounts] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
(async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
PIPELINES.map(async p => {
|
||||||
|
try { return [p.key, countByStatus(await fetchAll(p.endpoint))]; }
|
||||||
|
catch { return [p.key, {}]; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (active) setCounts(Object.fromEntries(entries));
|
||||||
|
})();
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<h2 className="text-xl font-semibold text-slate-700 mb-6">Dashboard</h2>
|
<h2 className="text-xl font-semibold text-slate-700 mb-2">Dashboard</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 max-w-3xl">
|
<p className="text-sm text-slate-500 mb-6 max-w-2xl">
|
||||||
|
Der Inhalts-Lebenszyklus: <b>draft</b> (erstellt) → <b>reviewed</b> (im Tool geprüft) →{' '}
|
||||||
|
<b>published</b> (fertig inkl. Audio, in der App sichtbar). Nur veröffentlichte Inhalte mit
|
||||||
|
Bild und Audio erscheinen für Lernende.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Pipeline-Übersicht */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5 mb-8 max-w-4xl">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-600 uppercase tracking-wide mb-4">Pipeline-Übersicht</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{PIPELINES.map(p => (
|
||||||
|
<div key={p.key} className="flex items-center gap-3">
|
||||||
|
<div className="w-20 shrink-0 text-sm font-medium text-slate-700">{p.label}</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{p.stages.map(stage => {
|
||||||
|
const n = counts?.[p.key]?.[stage] ?? null;
|
||||||
|
const cls = STATUS_COLORS[stage] || 'bg-gray-100 text-gray-600';
|
||||||
|
return (
|
||||||
|
<span key={stage} className={`${cls} rounded-full px-2.5 py-0.5 text-xs font-medium`}>
|
||||||
|
{stage}: {counts ? (n ?? 0) : '…'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-3">Zählung bis max. 500 pro Tabelle.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Werkzeuge */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 max-w-4xl">
|
||||||
{TILES.map(tile => (
|
{TILES.map(tile => (
|
||||||
<div
|
<div
|
||||||
key={tile.title}
|
key={tile.title}
|
||||||
onClick={() => tile.path && navigate(tile.path)}
|
onClick={() => navigate(tile.path)}
|
||||||
className={`bg-white rounded-2xl border-2 p-6 transition-all ${tile.color} ${tile.path ? 'cursor-pointer' : ''}`}
|
className={`bg-white rounded-2xl border-2 p-6 transition-all cursor-pointer ${tile.color}`}
|
||||||
>
|
>
|
||||||
<div className="text-4xl mb-3">{tile.icon}</div>
|
<div className="text-4xl mb-3">{tile.icon}</div>
|
||||||
<h3 className="font-semibold text-slate-800 text-lg mb-1">{tile.title}</h3>
|
<h3 className="font-semibold text-slate-800 text-lg mb-1">{tile.title}</h3>
|
||||||
<p className="text-slate-500 text-sm">{tile.description}</p>
|
<p className="text-slate-500 text-sm">{tile.description}</p>
|
||||||
{tile.soon && (
|
|
||||||
<span className="mt-3 inline-block text-xs bg-slate-200 text-slate-500 rounded-full px-2 py-0.5">
|
|
||||||
Demnächst
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
427
src/pages/Publish.jsx
Normal file
427
src/pages/Publish.jsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { apiFetch, apiPost } from '../lib/api';
|
||||||
|
import { buildRows } from '../lib/pairRows';
|
||||||
|
import PlaceholderText from '../components/PlaceholderText';
|
||||||
|
|
||||||
|
const LANGS = [
|
||||||
|
{ code: 'de', flag: '🇩🇪' },
|
||||||
|
{ code: 'en', flag: '🇬🇧' },
|
||||||
|
{ code: 'sv', flag: '🇸🇪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_LABELS = {
|
||||||
|
pairs: 'Pairs werden generiert',
|
||||||
|
translate: 'Übersetzen',
|
||||||
|
review: 'KI-Korrektur',
|
||||||
|
audio: 'Audio wird erzeugt',
|
||||||
|
finish: 'Abschluss',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_BADGES = {
|
||||||
|
text: 'bg-slate-100 text-slate-600',
|
||||||
|
yes_no: 'bg-sky-50 text-sky-700',
|
||||||
|
question: 'bg-violet-50 text-violet-700',
|
||||||
|
word: 'bg-amber-50 text-amber-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_LABELS = { 1: 'Leicht', 2: 'Mittel' };
|
||||||
|
|
||||||
|
// ─── In-Arbeit-Karte ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProgressCard({ pic, onRetry }) {
|
||||||
|
const prog = pic.pipeline_progress || {};
|
||||||
|
let label = STEP_LABELS[pic.pipeline_step] || 'Wartet…';
|
||||||
|
let pct = 5;
|
||||||
|
if (pic.pipeline_step === 'pairs' && prog.objectsTotal) {
|
||||||
|
label += ` — Objekt ${Math.min(prog.objectsDone + 1, prog.objectsTotal)}/${prog.objectsTotal}, ${prog.pairsCreated || 0} Pairs`;
|
||||||
|
pct = 5 + (prog.objectsDone / prog.objectsTotal) * 30;
|
||||||
|
} else if (pic.pipeline_step === 'translate' && prog.pairsTotal) {
|
||||||
|
label += ` — Pair ${prog.translatedPairs}/${prog.pairsTotal}`;
|
||||||
|
pct = 35 + (prog.translatedPairs / prog.pairsTotal) * 25;
|
||||||
|
} else if (pic.pipeline_step === 'review' && prog.pairsTotal) {
|
||||||
|
label += ` — Pair ${prog.reviewedPairs || 0}/${prog.pairsTotal}, ${prog.correctionsApplied || 0} Korrektur(en)`;
|
||||||
|
pct = 60 + ((prog.reviewedPairs || 0) / prog.pairsTotal) * 10;
|
||||||
|
} else if (pic.pipeline_step === 'audio' && prog.audiosTotal) {
|
||||||
|
label += ` — ${prog.audiosDone}/${prog.audiosTotal}`;
|
||||||
|
pct = 70 + (prog.audiosDone / prog.audiosTotal) * 30;
|
||||||
|
}
|
||||||
|
const failed = pic.pipeline_status === 'failed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-xl border p-3 flex items-center gap-3 ${failed ? 'border-red-300' : 'border-slate-200'}`}>
|
||||||
|
{pic.picture_link
|
||||||
|
? <img src={pic.picture_link} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" />
|
||||||
|
: <div className="w-14 h-14 rounded-lg bg-slate-100 shrink-0" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-700 truncate">{pic.design || pic.id.slice(0, 8)}</div>
|
||||||
|
{failed ? (
|
||||||
|
<div className="text-xs text-red-600 mt-0.5">
|
||||||
|
Fehlgeschlagen ({STEP_LABELS[pic.pipeline_step] || pic.pipeline_step}): {pic.pipeline_error || 'Unbekannter Fehler'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">{label}…</div>
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded-full mt-1.5 overflow-hidden">
|
||||||
|
<div className="h-full bg-sky-500 rounded-full transition-all" style={{ width: `${Math.min(pct, 98)}%` }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{failed && (
|
||||||
|
<button onClick={() => onRetry(pic.id)}
|
||||||
|
className="shrink-0 text-xs px-3 py-1.5 rounded-lg font-medium bg-amber-500 hover:bg-amber-600 text-white">
|
||||||
|
↻ Erneut
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pair-Zeile (flach, 3 Sprachspalten, Flag-Toggle) ────────────────────────
|
||||||
|
|
||||||
|
const FIELD_LABELS = { sentence: 'Frage', positive_sentence: 'Positiv', negative_sentence: 'Negativ' };
|
||||||
|
|
||||||
|
function PairRow({ pair, flagged, onToggleFlag, objIndexById, onAssign, assigning }) {
|
||||||
|
const rows = buildRows(pair.content);
|
||||||
|
const candidates = pair.candidates || [];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border p-2.5 transition-colors ${
|
||||||
|
flagged ? 'border-red-300 bg-red-50/60 opacity-70' : 'border-slate-200 bg-white'}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Badges + Audio-Indikatoren */}
|
||||||
|
<div className="w-20 shrink-0 space-y-1">
|
||||||
|
<span className={`inline-block text-[10px] font-medium rounded px-1.5 py-0.5 ${TYPE_BADGES[pair.answer_type] || ''}`}>
|
||||||
|
{pair.answer_type}
|
||||||
|
</span>
|
||||||
|
{pair.difficulty_level && (
|
||||||
|
<span className="block text-[10px] text-slate-400">{LEVEL_LABELS[pair.difficulty_level] || pair.difficulty_level}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{LANGS.map(l => {
|
||||||
|
const a = pair.audio?.[l.code];
|
||||||
|
const ok = a?.ready;
|
||||||
|
return (
|
||||||
|
<span key={l.code} title={ok ? `${l.code}: komplett` : `${l.code}: ${(a?.missing || []).join(', ') || 'unvollständig'}`}
|
||||||
|
className={`text-[10px] ${ok ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||||
|
{ok ? '🔊' : '⚠'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 Sprachspalten */}
|
||||||
|
<div className={`flex-1 grid grid-cols-3 gap-2 min-w-0 ${flagged ? 'line-through decoration-red-300' : ''}`}>
|
||||||
|
{LANGS.map(l => (
|
||||||
|
<div key={l.code} className="min-w-0 text-xs space-y-0.5">
|
||||||
|
{rows.map(row => {
|
||||||
|
if (row.kind === 'single') {
|
||||||
|
return l.code === 'de'
|
||||||
|
? <div key={row.label} className={`${row.color} font-medium`}>{row.value || <span className="text-red-400 italic">fehlt</span>}</div>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const val = row.cell(l.code);
|
||||||
|
const isQuestion = row.label === 'Frage';
|
||||||
|
return (
|
||||||
|
<div key={row.label} className={`${row.color} ${isQuestion ? 'italic' : ''} break-words`}>
|
||||||
|
{val ? <PlaceholderText text={val} /> : <span className="text-red-400 italic not-italic">— {row.label} fehlt —</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flag-Toggle */}
|
||||||
|
<button onClick={onToggleFlag}
|
||||||
|
title={flagged ? 'Wieder aufnehmen' : 'Vom Veröffentlichen ausschließen (wird geblockt)'}
|
||||||
|
className={`shrink-0 text-sm px-2 py-1 rounded-lg border transition-colors ${
|
||||||
|
flagged ? 'border-red-300 bg-red-100 text-red-600' : 'border-slate-200 text-slate-300 hover:text-red-500 hover:border-red-200'}`}>
|
||||||
|
🚩
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Objekt-Zuweisung: erkannte Objekt-Wörter im Satz, ein Klick pro Fund */}
|
||||||
|
{!flagged && candidates.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-slate-100">
|
||||||
|
{candidates.map((c, i) => {
|
||||||
|
const key = `${c.source_id}|${c.source_field}|${c.word_id}|${c.object_id}`;
|
||||||
|
const objNo = (objIndexById[c.object_id] ?? 0) + 1;
|
||||||
|
return (
|
||||||
|
<button key={i} onClick={() => onAssign(c)} disabled={assigning === key}
|
||||||
|
title={`„${c.label}" im ${FIELD_LABELS[c.source_field] || c.source_field}-Satz mit Objekt #${objNo} verknüpfen (alle Sprachen)`}
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded-full border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-50 transition-colors">
|
||||||
|
{assigning === key ? '…' : `🔗 „${c.label}" → Objekt #${objNo} · ${FIELD_LABELS[c.source_field] || ''}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bereit-Karte: ganzes Bild-Bundle ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReadyCard({ bundle, onPublished, onRefresh }) {
|
||||||
|
const [excluded, setExcluded] = useState(() => new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [notReady, setNotReady] = useState(null);
|
||||||
|
const [done, setDone] = useState(null);
|
||||||
|
const [assigning, setAssigning] = useState(null);
|
||||||
|
const [audioFilling, setAudioFilling] = useState(false);
|
||||||
|
const [translateFilling, setTranslateFilling] = useState(false);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-4 animate-pulse h-24" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPairs = bundle.objects.flatMap(o => o.pairs);
|
||||||
|
const wordCount = new Set(bundle.objects.flatMap(o => [
|
||||||
|
...o.words.map(w => w.id),
|
||||||
|
...o.pairs.flatMap(p => [
|
||||||
|
...(p.content?.positive?.words || []).map(w => w.id),
|
||||||
|
...(p.content?.negative?.words || []).map(w => w.id),
|
||||||
|
]),
|
||||||
|
])).size;
|
||||||
|
const incompletePairs = allPairs.filter(p => LANGS.some(l => !p.audio?.[l.code]?.ready));
|
||||||
|
|
||||||
|
function toggleFlag(id) {
|
||||||
|
setExcluded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const objIndexById = Object.fromEntries(bundle.objects.map((o, i) => [o.id, i]));
|
||||||
|
|
||||||
|
async function assign(c) {
|
||||||
|
const key = `${c.source_id}|${c.source_field}|${c.word_id}|${c.object_id}`;
|
||||||
|
setAssigning(key); setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/pipeline/assign-object', {
|
||||||
|
source_table: c.source_table, source_id: c.source_id, source_field: c.source_field,
|
||||||
|
object_id: c.object_id, word_id: c.word_id,
|
||||||
|
});
|
||||||
|
if (res.skipped_langs?.length)
|
||||||
|
setError(`Zugewiesen, aber in ${res.skipped_langs.join(', ')} nicht gefunden (abweichende Wortform) — ggf. dort manuell prüfen.`);
|
||||||
|
await onRefresh?.();
|
||||||
|
} catch (e) { setError(e.payload?.error || e.message); }
|
||||||
|
finally { setAssigning(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillTranslations() {
|
||||||
|
setTranslateFilling(true); setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiPost(`/pipeline/picture/${bundle.picture.id}/translate-fill`, {});
|
||||||
|
if (res.failed) setError(`${res.translated} Pairs übersetzt, ${res.failed} fehlgeschlagen: ${res.errors?.[0]?.error || ''}`);
|
||||||
|
await onRefresh?.();
|
||||||
|
} catch (e) { setError(e.payload?.error || e.message); }
|
||||||
|
finally { setTranslateFilling(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillAudio() {
|
||||||
|
setAudioFilling(true); setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiPost(`/pipeline/picture/${bundle.picture.id}/audio-fill`, {});
|
||||||
|
if (res.failed) setError(`${res.generated} Audios erzeugt, ${res.failed} fehlgeschlagen: ${res.errors?.[0]?.error || ''}`);
|
||||||
|
await onRefresh?.();
|
||||||
|
} catch (e) { setError(e.payload?.error || e.message); }
|
||||||
|
finally { setAudioFilling(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
setBusy(true); setError(null); setNotReady(null);
|
||||||
|
try {
|
||||||
|
const res = await apiPost(`/pipeline/picture/${bundle.picture.id}/publish`, {
|
||||||
|
excluded_pair_ids: [...excluded],
|
||||||
|
});
|
||||||
|
setDone(res);
|
||||||
|
onPublished?.(bundle.picture.id);
|
||||||
|
} catch (e) {
|
||||||
|
const nr = e.payload?.notReady;
|
||||||
|
if (Array.isArray(nr) && nr.length) setNotReady(nr);
|
||||||
|
else setError(e.payload?.error || e.message);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="bg-emerald-50 rounded-xl border border-emerald-300 p-3 flex items-center gap-3">
|
||||||
|
{bundle.picture.picture_link &&
|
||||||
|
<img src={bundle.picture.picture_link} alt="" className="w-10 h-10 rounded-lg object-cover" />}
|
||||||
|
<div className="text-sm text-emerald-800 font-medium">
|
||||||
|
✓ Veröffentlicht — {done.published_pairs} Pairs{done.blocked_pairs ? `, ${done.blocked_pairs} ausgeschlossen` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishCount = allPairs.length - excluded.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-emerald-300 overflow-hidden">
|
||||||
|
{/* Kopf */}
|
||||||
|
<div className="flex items-center gap-4 p-4 border-b border-slate-100">
|
||||||
|
{bundle.picture.picture_link
|
||||||
|
? <img src={bundle.picture.picture_link} alt="" className="w-28 h-28 rounded-xl object-cover shrink-0" />
|
||||||
|
: <div className="w-28 h-28 rounded-xl bg-slate-100 shrink-0" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-slate-800 truncate">{bundle.picture.design || bundle.picture.id.slice(0, 8)}</div>
|
||||||
|
<div className="text-sm text-slate-500 mt-0.5">
|
||||||
|
{bundle.objects.length} Objekt{bundle.objects.length !== 1 ? 'e' : ''} · {allPairs.length} Pairs · {wordCount} Wörter
|
||||||
|
</div>
|
||||||
|
{incompletePairs.length > 0 && (
|
||||||
|
<div className="text-xs text-amber-600 mt-1">
|
||||||
|
⚠ {incompletePairs.length} Pair{incompletePairs.length !== 1 ? 's' : ''} unvollständig (Text/Audio) — erst Übersetzungen, dann Audios nachholen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Nachhol-Aktionen immer verfügbar: Reihenfolge Übersetzen → Audio (Audio braucht alle Sprachen) */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||||
|
<button onClick={fillTranslations} disabled={translateFilling || audioFilling}
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded-full border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-50">
|
||||||
|
{translateFilling ? 'Übersetze…' : '🌍 Übersetzungen nachholen'}
|
||||||
|
</button>
|
||||||
|
<button onClick={fillAudio} disabled={audioFilling || translateFilling}
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:opacity-50">
|
||||||
|
{audioFilling ? 'Generiere…' : '🔊 Fehlende Audios generieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs font-medium bg-emerald-100 text-emerald-700 rounded-full px-2.5 py-1">bereit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Objekte + Pairs */}
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{bundle.objects.map((obj, i) => (
|
||||||
|
<div key={obj.id}>
|
||||||
|
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||||
|
#{i + 1} {obj.words.map(w => w.titel_de || w.titel_en).filter(Boolean).join(', ') || 'Objekt'}
|
||||||
|
<span className="font-normal normal-case text-slate-400 ml-2">{obj.pairs.length} Pairs</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{obj.pairs.map(p => (
|
||||||
|
<PairRow key={p.id} pair={p} flagged={excluded.has(p.id)} onToggleFlag={() => toggleFlag(p.id)}
|
||||||
|
objIndexById={objIndexById} onAssign={assign} assigning={assigning} />
|
||||||
|
))}
|
||||||
|
{obj.pairs.length === 0 && <div className="text-xs text-slate-400 italic">Keine Pairs</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fehler / Publish */}
|
||||||
|
{notReady && (
|
||||||
|
<div className="mx-4 mb-3 bg-amber-50 border border-amber-200 text-amber-800 text-xs rounded-xl px-3 py-2 space-y-0.5">
|
||||||
|
<b>Noch nicht veröffentlichbar:</b>
|
||||||
|
{notReady.slice(0, 8).map((n, i) => (
|
||||||
|
<div key={i}>{n.lang}: {n.missing.join(', ')} <span className="font-mono opacity-50">({n.pair_id.slice(0, 8)})</span></div>
|
||||||
|
))}
|
||||||
|
{notReady.length > 8 && <div>… und {notReady.length - 8} weitere</div>}
|
||||||
|
<div className="pt-1">Tipp: betroffene Pairs flaggen 🚩, dann erneut veröffentlichen.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="mx-4 mb-3 bg-red-50 border border-red-200 text-red-700 text-xs rounded-xl px-3 py-2">{error}</div>}
|
||||||
|
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<button onClick={publish} disabled={busy || publishCount === 0}
|
||||||
|
className="w-full py-2.5 text-sm font-semibold rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-colors">
|
||||||
|
{busy ? 'Veröffentliche…' : `✓ Bild veröffentlichen — ${publishCount} Pairs${excluded.size ? ` (${excluded.size} ausgeschlossen)` : ''}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Seite ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Publish() {
|
||||||
|
const [overview, setOverview] = useState(null); // null = lädt
|
||||||
|
const [bundles, setBundles] = useState({}); // pictureId → bundle
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const fetchingRef = useRef(new Set()); // Bundle-Requests, die schon laufen
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const rows = await apiFetch('/pipeline/overview');
|
||||||
|
setOverview(rows);
|
||||||
|
setError(null);
|
||||||
|
// Bundles für fertige Bilder nachladen (einmalig pro Bild)
|
||||||
|
for (const pic of rows) {
|
||||||
|
if (pic.pipeline_status === 'ready' && !fetchingRef.current.has(pic.id)) {
|
||||||
|
fetchingRef.current.add(pic.id);
|
||||||
|
apiFetch(`/pipeline/picture/${pic.id}/bundle`)
|
||||||
|
.then(b => setBundles(prev => ({ ...prev, [pic.id]: b })))
|
||||||
|
.catch(() => fetchingRef.current.delete(pic.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setOverview(prev => prev ?? []);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 5000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function retry(id) {
|
||||||
|
try { await apiPost(`/pipeline/retry/${id}`, {}); load(); }
|
||||||
|
catch (e) { setError(e.payload?.error || e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBundle(id) {
|
||||||
|
const b = await apiFetch(`/pipeline/picture/${id}/bundle`);
|
||||||
|
setBundles(prev => ({ ...prev, [id]: b }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = overview || [];
|
||||||
|
const inFlight = rows.filter(p => ['queued', 'running'].includes(p.pipeline_status));
|
||||||
|
const failed = rows.filter(p => p.pipeline_status === 'failed');
|
||||||
|
const ready = rows.filter(p => p.pipeline_status === 'ready');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-1">Veröffentlichen</h1>
|
||||||
|
<p className="text-slate-500 mb-4">
|
||||||
|
Automatisch erstellte Inhalte prüfen und pro Bild mit einem Klick freigeben.
|
||||||
|
Einzelne Pairs per 🚩 ausschließen — sie werden beim Veröffentlichen geblockt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4 text-sm text-slate-500">
|
||||||
|
{inFlight.length > 0 && <span className="text-sky-600 font-medium">{inFlight.length} in Arbeit</span>}
|
||||||
|
{failed.length > 0 && <span className="text-red-600 font-medium">{failed.length} fehlgeschlagen</span>}
|
||||||
|
<span className="text-emerald-600 font-medium">{ready.length} bereit</span>
|
||||||
|
</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>}
|
||||||
|
|
||||||
|
{overview === null ? (
|
||||||
|
<div className="space-y-2 animate-pulse">{[1, 2, 3].map(i => <div key={i} className="h-20 bg-slate-100 rounded-xl" />)}</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-8 text-center text-slate-400">
|
||||||
|
Nichts in der Pipeline. Gib unter <b>Inhalte</b> ein Bild frei (🚀), dann erscheinen die Ergebnisse hier.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...failed, ...inFlight].map(pic => (
|
||||||
|
<ProgressCard key={pic.id} pic={pic} onRetry={retry} />
|
||||||
|
))}
|
||||||
|
{ready.map(pic => (
|
||||||
|
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()}
|
||||||
|
onRefresh={() => refreshBundle(pic.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/pages/Settings.jsx
Normal file
190
src/pages/Settings.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
const LANGS = [
|
||||||
|
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||||
|
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||||
|
{ code: 'sv', label: 'Svenska', flag: '🇸🇪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function put(language, body) {
|
||||||
|
return apiFetch(`/tts-settings/${language}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineSettings() {
|
||||||
|
const [pairsPerObject, setPairsPerObject] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch('/pipeline/settings')
|
||||||
|
.then(s => setPairsPerObject(s.pairs_per_object ?? 5))
|
||||||
|
.catch(e => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true); setError(null); setMsg(null);
|
||||||
|
try {
|
||||||
|
const s = await apiFetch('/pipeline/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ pairs_per_object: Number(pairsPerObject) || 5 }),
|
||||||
|
});
|
||||||
|
setPairsPerObject(s.pairs_per_object);
|
||||||
|
setMsg('Pipeline-Einstellungen gespeichert.');
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setSaving(false); setTimeout(() => setMsg(null), 3000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-lg font-bold text-slate-800 mb-1">Pipeline</h2>
|
||||||
|
<p className="text-slate-500 text-sm mb-4">Automatische Content-Erstellung beim Freigeben eines Bildes.</p>
|
||||||
|
{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>}
|
||||||
|
{msg && <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-2 mb-4">{msg}</div>}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<label className="text-sm flex-1 max-w-xs">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Pairs pro Objekt</span>
|
||||||
|
<input type="number" min="1" max="20" value={pairsPerObject}
|
||||||
|
onChange={e => setPairsPerObject(e.target.value)}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
<span className="block text-xs text-slate-400 mt-1">4–6 empfohlen — mehr erhöht Review-Aufwand und API-Kosten.</span>
|
||||||
|
</label>
|
||||||
|
<button onClick={save} disabled={saving}
|
||||||
|
className={`text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||||
|
saving ? 'bg-indigo-300 text-white' : 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||||
|
}`}>
|
||||||
|
{saving ? 'Speichere …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [rows, setRows] = useState({}); // language → settings
|
||||||
|
const [voices, setVoices] = useState(null); // null = lädt/nicht verfügbar
|
||||||
|
const [saving, setSaving] = useState(null);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const list = await apiFetch('/tts-settings');
|
||||||
|
const map = {};
|
||||||
|
for (const s of list) map[s.language] = s;
|
||||||
|
setRows(map);
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
apiFetch('/tts-settings/voices/available').then(setVoices).catch(() => setVoices(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update(lang, patch) {
|
||||||
|
setRows(r => ({ ...r, [lang]: { ...(r[lang] || { language: lang }), ...patch } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(lang) {
|
||||||
|
setSaving(lang); setError(null); setMsg(null);
|
||||||
|
try {
|
||||||
|
const s = rows[lang] || {};
|
||||||
|
if (!s.voice_id) { setError(`Voice-ID für ${lang} fehlt`); setSaving(null); return; }
|
||||||
|
await put(lang, {
|
||||||
|
voice_id: s.voice_id,
|
||||||
|
model_id: s.model_id || 'eleven_multilingual_v2',
|
||||||
|
speed: Number(s.speed ?? 1.0),
|
||||||
|
stability: Number(s.stability ?? 0.5),
|
||||||
|
similarity_boost: Number(s.similarity_boost ?? 0.75),
|
||||||
|
style: Number(s.style ?? 0.0),
|
||||||
|
});
|
||||||
|
setMsg(`Stimme für ${lang.toUpperCase()} gespeichert.`);
|
||||||
|
await load();
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setSaving(null); setTimeout(() => setMsg(null), 3000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-1">Einstellungen</h1>
|
||||||
|
<p className="text-slate-500 mb-6">Sprachausgabe (ElevenLabs) — eine Stimme pro Sprache, zentral konfiguriert.</p>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
{msg && <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-2 mb-4">{msg}</div>}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
|
||||||
|
{LANGS.map(lang => {
|
||||||
|
const s = rows[lang.code] || {};
|
||||||
|
return (
|
||||||
|
<div key={lang.code} className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-semibold text-slate-700">{lang.flag} {lang.label}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => save(lang.code)}
|
||||||
|
disabled={saving === lang.code}
|
||||||
|
className={`text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||||
|
saving === lang.code ? 'bg-indigo-300 text-white' : 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saving === lang.code ? 'Speichere …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Voice-ID</span>
|
||||||
|
{voices?.length > 0 && (
|
||||||
|
<select value={voices.some(v => v.voice_id === s.voice_id) ? s.voice_id : ''}
|
||||||
|
onChange={e => e.target.value && update(lang.code, { voice_id: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm mb-1 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||||
|
<option value="">— Stimme aus dem Account wählen —</option>
|
||||||
|
{voices.map(v => (
|
||||||
|
<option key={v.voice_id} value={v.voice_id}>
|
||||||
|
{v.name}{v.labels?.accent ? ` (${v.labels.accent})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<input value={s.voice_id || ''} onChange={e => update(lang.code, { voice_id: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
{voices?.length > 0 && s.voice_id && !voices.some(v => v.voice_id === s.voice_id) && (
|
||||||
|
<span className="block text-xs text-red-500 mt-1">
|
||||||
|
⚠ Diese Voice-ID existiert nicht im ElevenLabs-Account — Audio-Generierung schlägt fehl.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Geschwindigkeit</span>
|
||||||
|
<input type="number" step="0.05" min="0.5" max="1.5" value={s.speed ?? 1.0}
|
||||||
|
onChange={e => update(lang.code, { speed: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Stabilität</span>
|
||||||
|
<input type="number" step="0.05" min="0" max="1" value={s.stability ?? 0.5}
|
||||||
|
onChange={e => update(lang.code, { stability: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Similarity Boost</span>
|
||||||
|
<input type="number" step="0.05" min="0" max="1" value={s.similarity_boost ?? 0.75}
|
||||||
|
onChange={e => update(lang.code, { similarity_boost: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-3">
|
||||||
|
Diese Werte werden bei jeder Audio-Generierung verwendet. Änderungen wirken auf neu erzeugte Audios.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PipelineSettings />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import RecordModal from '../components/RecordModal';
|
import RecordModal from '../components/RecordModal';
|
||||||
@@ -12,25 +12,29 @@ function truncate(str, n = 60) {
|
|||||||
return s.length > n ? s.slice(0, n) + '…' : s;
|
return s.length > n ? s.slice(0, n) + '…' : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CellValue({ col, value, linkedFields, navigate }) {
|
function CellValue({ col, value, linkedFields, fkLookup, navigate }) {
|
||||||
if (value == null || value === '') return <span className="text-slate-300">—</span>;
|
if (value == null || value === '') return <span className="text-slate-300">—</span>;
|
||||||
|
|
||||||
// Array of IDs (e.g. picture_ids, word_ids)
|
// Array of IDs (e.g. picture_ids, word_ids)
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) return <span className="text-slate-300">[]</span>;
|
if (value.length === 0) return <span className="text-slate-300">[]</span>;
|
||||||
const targetTable = linkedFields[col];
|
const targetTableKey = linkedFields[col];
|
||||||
|
const lookup = targetTableKey && fkLookup[targetTableKey];
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{value.slice(0, 3).map(id => (
|
{value.slice(0, 3).map(id => {
|
||||||
<button
|
const label = lookup?.[id];
|
||||||
key={id}
|
return (
|
||||||
onClick={e => { e.stopPropagation(); targetTable && navigate(`/db/${targetTable}?id=${id}`); }}
|
<button
|
||||||
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
key={id}
|
||||||
title={String(id)}
|
onClick={e => { e.stopPropagation(); targetTableKey && navigate(`/db/${targetTableKey}?id=${id}`); }}
|
||||||
>
|
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
||||||
{String(id).slice(0, 8)}
|
title={String(id)}
|
||||||
</button>
|
>
|
||||||
))}
|
{label ? truncate(label, 20) : String(id).slice(0, 8)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{value.length > 3 && (
|
{value.length > 3 && (
|
||||||
<span className="text-xs text-slate-400">+{value.length - 3}</span>
|
<span className="text-xs text-slate-400">+{value.length - 3}</span>
|
||||||
)}
|
)}
|
||||||
@@ -40,14 +44,20 @@ function CellValue({ col, value, linkedFields, navigate }) {
|
|||||||
|
|
||||||
// Single UUID FK (e.g. question_id)
|
// Single UUID FK (e.g. question_id)
|
||||||
if (typeof value === 'string' && linkedFields[col]) {
|
if (typeof value === 'string' && linkedFields[col]) {
|
||||||
const targetTable = linkedFields[col];
|
const targetTableKey = linkedFields[col];
|
||||||
|
const lookup = fkLookup[targetTableKey];
|
||||||
|
const label = lookup?.[value];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTable}?id=${value}`); }}
|
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTableKey}?id=${value}`); }}
|
||||||
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 max-w-[200px] text-left"
|
||||||
title={value}
|
title={value}
|
||||||
>
|
>
|
||||||
{value.slice(0, 8)}…
|
{label ? (
|
||||||
|
<span>{truncate(label, 24)} <span className="opacity-50 font-mono">{value.slice(0, 6)}</span></span>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono">{value.slice(0, 8)}…</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,18 +73,68 @@ function CellValue({ col, value, linkedFields, navigate }) {
|
|||||||
return <span className="text-xs text-slate-500">{new Date(value).toLocaleDateString('de-DE')}</span>;
|
return <span className="text-xs text-slate-500">{new Date(value).toLocaleDateString('de-DE')}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picture link — show as thumbnail + link
|
// Picture/audio link
|
||||||
if (col === 'picture_link') {
|
if (col === 'picture_link' || col === 'audio_link') {
|
||||||
return (
|
return (
|
||||||
<a href={value} target="_blank" rel="noreferrer" className="text-xs text-indigo-600 underline">
|
<a href={value} target="_blank" rel="noreferrer" className="text-xs text-indigo-600 underline">
|
||||||
Bild öffnen
|
{col === 'audio_link' ? 'Audio öffnen' : 'Bild öffnen'}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if (value === true || value === false) {
|
||||||
|
return <span className={`text-xs font-mono ${value ? 'text-emerald-600' : 'text-slate-400'}`}>{String(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
return <span className="text-sm text-slate-700">{truncate(value)}</span>;
|
return <span className="text-sm text-slate-700">{truncate(value)}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ColPanel({ allColumns, visibleSet, onToggle, onReset, anchorRef, onClose }) {
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e) {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||||||
|
anchorRef.current && !anchorRef.current.contains(e.target)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [onClose, anchorRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="absolute right-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-xl shadow-lg w-56 max-h-80 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wide">Spalten</span>
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-xs text-indigo-500 hover:text-indigo-700 font-medium"
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="py-1">
|
||||||
|
{allColumns.map(col => (
|
||||||
|
<label key={col} className="flex items-center gap-2.5 px-3 py-1.5 hover:bg-slate-50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleSet.has(col)}
|
||||||
|
onChange={() => onToggle(col)}
|
||||||
|
className="accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700 font-mono">{col}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TableView() {
|
export default function TableView() {
|
||||||
const { tableKey } = useParams();
|
const { tableKey } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -90,6 +150,23 @@ export default function TableView() {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [showColPanel, setShowColPanel] = useState(false);
|
||||||
|
const [sortCol, setSortCol] = useState(null);
|
||||||
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
|
const [fkLookup, setFkLookup] = useState({});
|
||||||
|
const colBtnRef = useRef(null);
|
||||||
|
|
||||||
|
const COLS_KEY = `cols_${tableKey}`;
|
||||||
|
|
||||||
|
const [visibleCols, setVisibleCols] = useState(null);
|
||||||
|
|
||||||
|
// Load column prefs from localStorage whenever tableKey changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(`cols_${tableKey}`);
|
||||||
|
setVisibleCols(saved ? JSON.parse(saved) : null);
|
||||||
|
} catch { setVisibleCols(null); }
|
||||||
|
}, [tableKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -100,12 +177,69 @@ export default function TableView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setRows([]);
|
||||||
|
setSortCol(null);
|
||||||
|
setFkLookup({});
|
||||||
fetchAll(meta.endpoint)
|
fetchAll(meta.endpoint)
|
||||||
.then(data => setRows(Array.isArray(data) ? data : []))
|
.then(data => setRows(Array.isArray(data) ? data : []))
|
||||||
.catch(err => setError(err.message))
|
.catch(err => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [meta]);
|
}, [meta]);
|
||||||
|
|
||||||
|
// Derive all available columns from first loaded row
|
||||||
|
const allColumns = useMemo(() => {
|
||||||
|
if (rows.length === 0) return meta?.columns ?? [];
|
||||||
|
return Object.keys(rows[0]);
|
||||||
|
}, [rows, meta]);
|
||||||
|
|
||||||
|
// Effective visible columns
|
||||||
|
const visibleSet = useMemo(() => {
|
||||||
|
return new Set(visibleCols ?? meta?.columns ?? []);
|
||||||
|
}, [visibleCols, meta]);
|
||||||
|
|
||||||
|
const effectiveCols = useMemo(() => {
|
||||||
|
return allColumns.filter(c => visibleSet.has(c));
|
||||||
|
}, [allColumns, visibleSet]);
|
||||||
|
|
||||||
|
function toggleCol(col) {
|
||||||
|
const current = visibleCols ?? meta.columns;
|
||||||
|
const next = current.includes(col)
|
||||||
|
? current.filter(c => c !== col)
|
||||||
|
: [...current, col];
|
||||||
|
setVisibleCols(next);
|
||||||
|
localStorage.setItem(COLS_KEY, JSON.stringify(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCols() {
|
||||||
|
setVisibleCols(null);
|
||||||
|
localStorage.removeItem(COLS_KEY);
|
||||||
|
setShowColPanel(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load FK lookup tables when rows are available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meta || rows.length === 0) return;
|
||||||
|
const targets = new Set(
|
||||||
|
Object.values(meta.linkedFields)
|
||||||
|
.map(v => typeof v === 'string' ? v : null)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
targets.forEach(async targetKey => {
|
||||||
|
if (fkLookup[targetKey]) return;
|
||||||
|
const targetMeta = TABLES[targetKey];
|
||||||
|
if (!targetMeta) return;
|
||||||
|
try {
|
||||||
|
const data = await fetchAll(targetMeta.endpoint);
|
||||||
|
setFkLookup(prev => ({
|
||||||
|
...prev,
|
||||||
|
[targetKey]: Object.fromEntries(
|
||||||
|
data.map(r => [r.id, r[targetMeta.primaryLabel] || r.id])
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}, [rows, meta]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const statuses = useMemo(() => {
|
const statuses = useMemo(() => {
|
||||||
const set = new Set(rows.map(r => r.status).filter(Boolean));
|
const set = new Set(rows.map(r => r.status).filter(Boolean));
|
||||||
return [...set].sort();
|
return [...set].sort();
|
||||||
@@ -124,6 +258,23 @@ export default function TableView() {
|
|||||||
});
|
});
|
||||||
}, [rows, statusFilter, textFilter]);
|
}, [rows, statusFilter, textFilter]);
|
||||||
|
|
||||||
|
const sortedFiltered = useMemo(() => {
|
||||||
|
if (!sortCol) return filtered;
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
const av = a[sortCol], bv = b[sortCol];
|
||||||
|
if (av == null && bv == null) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
const cmp = String(av).localeCompare(String(bv), 'de', { numeric: true });
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}, [filtered, sortCol, sortDir]);
|
||||||
|
|
||||||
|
function handleSort(col) {
|
||||||
|
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
else { setSortCol(col); setSortDir('asc'); }
|
||||||
|
}
|
||||||
|
|
||||||
function handleRecordSaved(updated) {
|
function handleRecordSaved(updated) {
|
||||||
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
|
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
|
||||||
}
|
}
|
||||||
@@ -151,21 +302,44 @@ export default function TableView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout back="/db">
|
<Layout back="/db">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
||||||
<h2 className="text-xl font-semibold text-slate-700">
|
<h2 className="text-xl font-semibold text-slate-700">
|
||||||
{meta.icon} {meta.label}
|
{meta.icon} {meta.label}
|
||||||
<span className="ml-2 text-base font-normal text-slate-400">
|
<span className="ml-2 text-base font-normal text-slate-400">
|
||||||
({filtered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
|
({sortedFiltered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
{hasCreateForm(tableKey) && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{/* Column toggle */}
|
||||||
onClick={() => setShowCreate(true)}
|
<div className="relative">
|
||||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
<button
|
||||||
>
|
ref={colBtnRef}
|
||||||
<span className="text-lg leading-none">+</span> Neu
|
onClick={() => setShowColPanel(p => !p)}
|
||||||
</button>
|
className="flex items-center gap-1.5 border border-slate-300 hover:border-slate-400 text-slate-600 hover:text-slate-800 text-sm px-3 py-1.5 rounded-lg transition-colors bg-white"
|
||||||
)}
|
>
|
||||||
|
Spalten {showColPanel ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
{showColPanel && (
|
||||||
|
<ColPanel
|
||||||
|
allColumns={allColumns}
|
||||||
|
visibleSet={visibleSet}
|
||||||
|
onToggle={toggleCol}
|
||||||
|
onReset={resetCols}
|
||||||
|
anchorRef={colBtnRef}
|
||||||
|
onClose={() => setShowColPanel(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCreateForm(tableKey) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">+</span> Neu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -210,35 +384,47 @@ export default function TableView() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50 border-b border-slate-200">
|
<tr className="bg-slate-50 border-b border-slate-200">
|
||||||
{meta.columns.map(col => (
|
{effectiveCols.map(col => (
|
||||||
<th key={col} className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-nowrap">
|
<th
|
||||||
{col}
|
key={col}
|
||||||
|
onClick={() => handleSort(col)}
|
||||||
|
className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-nowrap cursor-pointer hover:bg-slate-100 select-none transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{col}
|
||||||
|
{sortCol === col ? (
|
||||||
|
<span className="text-indigo-500">{sortDir === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300 opacity-0 group-hover:opacity-100">▲</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-3 py-2.5 w-10" />
|
<th className="px-3 py-2.5 w-10" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.length === 0 && (
|
{sortedFiltered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={meta.columns.length + 1} className="text-center py-10 text-slate-400">
|
<td colSpan={effectiveCols.length + 1} className="text-center py-10 text-slate-400">
|
||||||
Keine Einträge gefunden
|
Keine Einträge gefunden
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{filtered.map((row, i) => (
|
{sortedFiltered.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id || i}
|
key={row.id || i}
|
||||||
onClick={() => setModalRecord(row)}
|
onClick={() => setModalRecord(row)}
|
||||||
className={`group border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer
|
className={`group border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer
|
||||||
${highlightId && row.id === highlightId ? 'bg-indigo-50 ring-1 ring-indigo-300' : ''}`}
|
${highlightId && row.id === highlightId ? 'bg-indigo-50 ring-1 ring-indigo-300' : ''}`}
|
||||||
>
|
>
|
||||||
{meta.columns.map(col => (
|
{effectiveCols.map(col => (
|
||||||
<td key={col} className="px-4 py-2.5 align-top max-w-[280px]">
|
<td key={col} className="px-4 py-2.5 align-top max-w-[280px]">
|
||||||
<CellValue
|
<CellValue
|
||||||
col={col}
|
col={col}
|
||||||
value={row[col]}
|
value={row[col]}
|
||||||
linkedFields={meta.linkedFields}
|
linkedFields={meta.linkedFields}
|
||||||
|
fkLookup={fkLookup}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/pages/WordGenerator.jsx
Normal file
173
src/pages/WordGenerator.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { apiPost, apiLink, fetchAll } from '../lib/api';
|
||||||
|
|
||||||
|
const DIFFICULTIES = [
|
||||||
|
{ value: '', label: 'beliebig' },
|
||||||
|
{ value: 'einfach', label: 'einfach' },
|
||||||
|
{ value: 'mittel', label: 'mittel' },
|
||||||
|
{ value: 'schwer', label: 'schwer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WordGenerator() {
|
||||||
|
const [topic, setTopic] = useState('');
|
||||||
|
const [count, setCount] = useState(15);
|
||||||
|
const [difficulty, setDifficulty] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState('');
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
|
||||||
|
const [rows, setRows] = useState([]); // [{ titel_de, titel_en, titel_sv, include, saved, error }]
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [done, setDone] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll('/categories').then(setCategories).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (!topic.trim()) return;
|
||||||
|
setLoading(true); setError(null); setDone(null); setRows([]);
|
||||||
|
try {
|
||||||
|
const { words } = await apiPost('/claude/generate-words', {
|
||||||
|
topic: topic.trim(), count: Number(count) || 15, difficulty: difficulty || undefined,
|
||||||
|
});
|
||||||
|
setRows((words || []).map(w => ({ ...w, include: true, saved: false, error: null })));
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(i, patch) {
|
||||||
|
setRows(rs => rs.map((r, idx) => idx === i ? { ...r, ...patch } : r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true); setError(null); setDone(null);
|
||||||
|
let ok = 0, fail = 0;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const r = rows[i];
|
||||||
|
if (!r.include || r.saved) continue;
|
||||||
|
try {
|
||||||
|
const word = await apiPost('/words', {
|
||||||
|
titel_de: r.titel_de || null,
|
||||||
|
titel_en: r.titel_en || null,
|
||||||
|
titel_sv: r.titel_sv || null,
|
||||||
|
status: 'translated', // Claude liefert alle Sprachen → bereit für Bildgenerierung
|
||||||
|
});
|
||||||
|
if (categoryId) {
|
||||||
|
await apiLink(`/words/${word.id}/categories/${categoryId}`).catch(() => {});
|
||||||
|
}
|
||||||
|
updateRow(i, { saved: true, error: null });
|
||||||
|
ok++;
|
||||||
|
} catch (e) {
|
||||||
|
updateRow(i, { error: e.message });
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
setDone(`${ok} Wörter übernommen${fail ? `, ${fail} fehlgeschlagen` : ''} (Status: translated).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const includedCount = rows.filter(r => r.include && !r.saved).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout back="/content">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-2">Wörter generieren</h1>
|
||||||
|
<p className="text-slate-500 mb-4">
|
||||||
|
Lass dir per KI neue Vokabeln zu einem Thema erstellen, prüfe sie und übernimm sie in die Datenbank.
|
||||||
|
</p>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-3 mb-6">
|
||||||
|
Übernommene Wörter erhalten den Status <b>translated</b> (alle Sprachen vorhanden) und werden so
|
||||||
|
automatisch von der Bildgenerierung aufgegriffen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Eingabe */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5 mb-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Thema / Kategorie</label>
|
||||||
|
<input
|
||||||
|
value={topic}
|
||||||
|
onChange={e => setTopic(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && generate()}
|
||||||
|
placeholder='z.B. "Wörter zum Thema Küche" oder "Tiere im Wald"'
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Anzahl</label>
|
||||||
|
<input type="number" min={1} max={50} value={count}
|
||||||
|
onChange={e => setCount(e.target.value)}
|
||||||
|
className="w-24 border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Schwierigkeit</label>
|
||||||
|
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
|
||||||
|
className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-400">
|
||||||
|
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[180px]">
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Kategorie verknüpfen (optional)</label>
|
||||||
|
<select value={categoryId} onChange={e => setCategoryId(e.target.value)}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-400">
|
||||||
|
<option value="">— keine —</option>
|
||||||
|
{categories.map(c => <option key={c.id} value={c.id}>{c.titel_de || c.titel_en || c.id}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={generate}
|
||||||
|
disabled={loading || !topic.trim()}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||||
|
${loading || !topic.trim() ? 'bg-slate-200 text-slate-400 cursor-not-allowed' : 'bg-emerald-600 text-white hover:bg-emerald-500'}`}
|
||||||
|
>
|
||||||
|
{loading ? 'Generiere …' : '🪄 Wörter generieren'}
|
||||||
|
</button>
|
||||||
|
</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>}
|
||||||
|
{done && <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-2 mb-4">{done}</div>}
|
||||||
|
|
||||||
|
{/* Vorschau */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold text-slate-700">Vorschau ({rows.length})</h2>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving || includedCount === 0}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||||
|
${saving || includedCount === 0 ? 'bg-slate-200 text-slate-400 cursor-not-allowed' : 'bg-indigo-600 text-white hover:bg-indigo-500'}`}
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere …' : `${includedCount} übernehmen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-[28px_1fr_1fr_1fr_70px] gap-2 text-xs font-semibold text-slate-400 uppercase tracking-wide px-1">
|
||||||
|
<span></span><span>Deutsch</span><span>English</span><span>Svenska</span><span></span>
|
||||||
|
</div>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className={`grid grid-cols-[28px_1fr_1fr_1fr_70px] gap-2 items-center ${r.saved ? 'opacity-50' : ''}`}>
|
||||||
|
<input type="checkbox" checked={r.include} disabled={r.saved}
|
||||||
|
onChange={e => updateRow(i, { include: e.target.checked })} className="justify-self-center" />
|
||||||
|
<input value={r.titel_de} disabled={r.saved} onChange={e => updateRow(i, { titel_de: e.target.value })}
|
||||||
|
className="border border-slate-200 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-400" />
|
||||||
|
<input value={r.titel_en} disabled={r.saved} onChange={e => updateRow(i, { titel_en: e.target.value })}
|
||||||
|
className="border border-slate-200 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-400" />
|
||||||
|
<input value={r.titel_sv} disabled={r.saved} onChange={e => updateRow(i, { titel_sv: e.target.value })}
|
||||||
|
className="border border-slate-200 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-400" />
|
||||||
|
<span className="text-xs text-center">
|
||||||
|
{r.saved ? <span className="text-emerald-600">✓</span> : r.error ? <span className="text-red-500" title={r.error}>Fehler</span> : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user