Compare commits

..

11 Commits

Author SHA1 Message Date
Tim Leikauf
e71e7c1b96 fix: prompt-styles kategorie_id als linkedField zu categories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:15:34 +02:00
Tim Leikauf
f5c7fbcd57 feat: prompt_styles + picture_jobs in CMT Datenbankübersicht
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:02:55 +02:00
e78be430c7 feat: Placeholder farbig markieren in Freigabe-View und Pair-Modal
Objekt-Placeholder indigo, Wort-Placeholder grün, geleakte ⟦PHn⟧-Tokens rot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:43:46 +02:00
96c436e0e9 feat: Published-Bilder ausblenden + Bild-Lösch-Button in Content Erstellen
Die Blätter-Ansicht lädt veröffentlichte Bilder nicht mehr (Filter beim
Laden/Reload, Entfernen beim manuellen Publish). Neuer 🗑-Button löscht
ein Bild komplett (S3 + DB inkl. Objekte und Pairs via API-Kaskade).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:27:57 +02:00
e383cacd45 feat: KI-Korrektur-Schritt in der Pipeline-Fortschrittsanzeige
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:41:35 +02:00
af00d3323d feat: Voice-Auswahl aus ElevenLabs-Account in den TTS-Einstellungen
Dropdown mit den Account-Stimmen (GET /tts-settings/voices/available)
plus Warnung, wenn die gespeicherte Voice-ID nicht im Account existiert
— so fällt eine ungültige Stimme (Ursache der fehlenden sv-Audios)
sofort auf statt still fehlzuschlagen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:04:13 +02:00
2cec5bc362 feat: 'Übersetzungen nachholen' + 'Audios nachholen' immer verfügbar
Beide Nachhol-Aktionen jetzt dauerhaft im Karten-Header (nicht nur bei
unvollständigen Pairs) — Reihenfolge Übersetzen → Audio, da Audio alle
Sprachen braucht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:03:25 +02:00
ae25dc9428 feat: Objekt-Zuweisung + Audio-Nachholen in der Veröffentlichen-Ansicht
- Pro Pair: erkannte Objekt-Wörter als 🔗-Chips, ein Klick weist das Wort
  in allen Sprachen dem Objekt zu ({{wort.o:objectId}})
- '🔊 Fehlende Audios generieren' bei unvollständigen Pairs
- Bundle-Refresh nach Zuweisung/Audio-Fill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:48:13 +02:00
86145941eb feat: Auto-Pipeline-Flow (Bild freigeben → Veröffentlichen-Übersicht)
- ContentCreation: '🚀 Bild freigeben'-Button startet die serverseitige
  Pipeline (Pairs → Übersetzung → Audio); Status-Chip pro Bild
- Veröffentlichen-Seite neu: Live-Fortschritt (Polling), pro Bild eine
  flache Karte mit Objekten, Pairs (3 Sprachspalten), Audio-Indikatoren,
  🚩-Flag zum Ausschließen, Ein-Klick-Publish des ganzen Bildes
- Settings: Pipeline-Sektion (Pairs pro Objekt)
- lib/pairRows.js: geteilte 3-Sprachen-Anzeige-Helfer (aus PairReviewModal)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:57:25 +02:00
11e3ce8770 feat: Geführter Pair-Review-Flow (Wizard) pro Objekt
'🚀 Review-Flow starten' läuft die Pairs des Objekts der Reihe nach durch:
Inline bearbeiten + speichern oder 'Speichern & übersetzen' (Prüf-Grid),
Reviewed/Blocked — danach automatisch das nächste Pair, bis alle durch sind.
Orchestrierungs-Hülle um EditPairForm + PairReviewModal, keine Logik dupliziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:14:37 +02:00
2a6d203d1c fix: Negativ-Zeile im Review-Modal immer zeigen (fehlt sichtbar machen)
question/word-Pairs zeigen die Negativ-Zeile jetzt auch wenn leer ('fehlt'
statt stilles Ausblenden), damit eine fehlende Negativ-Antwort auffaellt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:14:28 +02:00
7 changed files with 838 additions and 144 deletions

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { apiPost, apiPatch } from '../lib/api';
import { buildRows } from '../lib/pairRows';
import PlaceholderText from './PlaceholderText';
const LANGS = [
{ code: 'de', flag: '🇩🇪' },
@@ -7,58 +9,6 @@ const LANGS = [
{ code: 'sv', flag: '🇸🇪' },
];
// Platzhalter {{label.type:uuid}} → nur das Label anzeigen.
function strip(text) {
if (!text) return '';
return text.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
const m = inner.match(/^(.+?)\.[a-z]+:[0-9a-f-]{36}$/i);
return m ? m[1] : inner;
});
}
// Baut die Anzeigezeilen 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)
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(', ');
const sentenceCell = (stmt, prefix) => (l) =>
strip(stmt?.sentence?.[`${prefix}_${l}`] || '');
// Frage (yes_no / question / word)
if (content.question) {
rows.push({ kind: 'lang', label: 'Frage', color: 'text-slate-700',
cell: l => strip(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) });
if (content.negative?.words?.length)
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') });
const negHasContent = content.negative &&
LANGS.some(l => (content.negative.sentence?.[`negative_sentence_${l}`] || '').trim());
if (negHasContent)
rows.push({ kind: 'lang', label: 'Negativ', color: 'text-red-600',
cell: sentenceCell(content.negative, 'negative_sentence') });
}
return rows;
}
export default function PairReviewModal({ pair, content, onClose, onDone, onRetranslate }) {
const [busy, setBusy] = useState(null); // 'review' | 'block' | 'retranslate'
const [missing, setMissing] = useState(null);
@@ -181,7 +131,7 @@ function Row({ row }) {
const val = row.cell(l.code);
return (
<div key={l.code} className={`text-sm rounded-lg border px-2.5 py-1.5 ${val ? `bg-slate-50 border-slate-200 ${row.color}` : 'bg-red-50 border-red-200 text-red-400 italic'}`}>
{val || 'fehlt'}
{val ? <PlaceholderText text={val} /> : 'fehlt'}
</div>
);
})}

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

74
src/lib/pairRows.js Normal file
View 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;
}

53
src/lib/tables.js Normal file → Executable file
View File

@@ -296,6 +296,59 @@ export const TABLES = {
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: '🗣️',

View File

@@ -873,7 +873,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted, onSavedA
// ─── 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 (
<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">
@@ -887,7 +887,10 @@ function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAd
<span className="text-base leading-none"></span> Objekt hinzufügen
</button>
{objects.length > 0 && (
<>
<ReleaseButton currentPicture={currentPicture} objects={objects} onPicturesReload={onPicturesReload} />
<AutoCreateAllButton currentPicture={currentPicture} objects={objects} />
</>
)}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
@@ -1175,17 +1178,166 @@ function AutoCreateAllButton({ currentPicture, objects }) {
return (
<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">
✨ Auto Pairs erstellen
✨ Auto Pairs erstellen (manuell)
</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 ───────────────────────────────────────────────────────
function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onPairSaved, onPairsReload, onReloadAll }) {
const [editingId, setEditingId] = useState(null);
const [translatingId, setTranslatingId] = useState(null);
const [reviewData, setReviewData] = useState(null); // { pair, content }
const [wizardOpen, setWizardOpen] = useState(false);
async function handleTranslate(pair) {
setTranslatingId(pair.id);
@@ -1214,10 +1366,17 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
return (
<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">
Pairs — Objekt #{selectedObject._index + 1}
</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 className="flex-1 overflow-y-auto">
<div className="p-3 border-b border-slate-100">
@@ -1291,6 +1450,15 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
onRetranslate={() => handleRetranslate(reviewData.pair)}
/>
)}
{wizardOpen && (
<PairReviewWizard
pairs={objectPairs}
allObjects={allObjects}
onClose={() => { setWizardOpen(false); (onReloadAll || onPairsReload)(); }}
onPairsReload={onPairsReload}
/>
)}
</aside>
);
}
@@ -1438,6 +1606,7 @@ export default function ContentCreation() {
const [loadingPairs, setLoadingPairs] = useState(false);
const [markingDone, setMarkingDone] = useState(false);
const [deletingPicture, setDeletingPicture] = useState(false);
const currentPicture = pictures[pictureIndex] || null;
const selectedObjectId = typeof mode === 'string' && mode !== 'add' ? mode : null;
@@ -1446,15 +1615,21 @@ export default function ContentCreation() {
? { ...selectedObject, _index: objects.indexOf(selectedObject) }
: null;
// Load all pictures
// Load all pictures (published sind fertig und werden hier nicht mehr bearbeitet)
useEffect(() => {
setLoadingPictures(true);
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([]))
.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
useEffect(() => {
if (!currentPicture) return;
@@ -1552,10 +1727,27 @@ export default function ContentCreation() {
if (!currentPicture) return;
try {
await apiPatch('/pictures', currentPicture.id, { status: newStatus });
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); }
}
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) {
try {
await apiPatch('/objects', objectId, { status: newStatus });
@@ -1588,7 +1780,7 @@ export default function ContentCreation() {
function reloadPictures() {
apiFetch('/pictures?limit=500')
.then(data => setPictures(Array.isArray(data) ? data : []))
.then(data => setPictures(Array.isArray(data) ? data.filter(p => p.status !== 'published') : []))
.catch(() => {});
}
@@ -1632,6 +1824,12 @@ export default function ContentCreation() {
{currentPicture.objects_created && (
<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}
@@ -1653,6 +1851,7 @@ export default function ContentCreation() {
onSelectObject={handleSelectObject}
currentPicture={currentPicture}
onObjectStatusChange={handleObjectStatusChange}
onPicturesReload={reloadPictures}
/>
<ImageCanvas

View File

@@ -1,109 +1,423 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import Layout from '../components/Layout';
import { apiFetch } from '../lib/api';
import { STATUS_COLORS } from '../lib/tables';
import { apiFetch, apiPost } from '../lib/api';
import { buildRows } from '../lib/pairRows';
import PlaceholderText from '../components/PlaceholderText';
const LANGS = [
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'sv', label: 'Svenska', flag: '🇸🇪' },
{ code: 'de', flag: '🇩🇪' },
{ code: 'en', flag: '🇬🇧' },
{ code: 'sv', flag: '🇸🇪' },
];
export default function Publish() {
const [lang, setLang] = useState('sv');
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
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 [publishing, setPublishing] = useState(null);
const [msg, setMsg] = 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);
const load = useCallback(async () => {
setLoading(true); setError(null);
try {
const { pairs } = await apiFetch(`/pairs/publishability?lang=${lang}`);
setRows(pairs);
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}, [lang]);
useEffect(() => { load(); }, [load]);
async function publish(id) {
setPublishing(id); setError(null); setMsg(null);
try {
await apiFetch(`/pairs/${id}/publish?lang=${lang}`, { method: 'POST', body: '{}' });
setMsg('Veröffentlicht ✓');
await load();
} catch (e) { setError(e.message); }
finally { setPublishing(null); setTimeout(() => setMsg(null), 2500); }
if (!bundle) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-4 animate-pulse h-24" />
);
}
const readyCount = rows.filter(r => r.ready).length;
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-4xl mx-auto">
<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">
Pairs sortiert nach <b>am wenigsten fehlt"</b>. Was komplett ist (Text, Bild, Audio in der
gewählten Sprache), kannst du mit einem Klick veröffentlichen — Frage & Sätze werden mit freigegeben.
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">
<span className="text-sm font-medium text-slate-600">Sprache:</span>
<div className="flex gap-1">
{LANGS.map(l => (
<button key={l.code} onClick={() => setLang(l.code)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
lang === l.code ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
}`}>
{l.flag} {l.label}
</button>
))}
</div>
{!loading && <span className="text-sm text-slate-400 ml-auto">{readyCount} bereit · {rows.length} offen</span>}
<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>}
{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>}
{loading ? (
<div className="space-y-2 animate-pulse">{[1,2,3,4].map(i => <div key={i} className="h-16 bg-slate-100 rounded-xl" />)}</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">
Keine offenen Pairs (draft/reviewed) gefunden.
Nichts in der Pipeline. Gib unter <b>Inhalte</b> ein Bild frei (🚀), dann erscheinen die Ergebnisse hier.
</div>
) : (
<div className="space-y-2">
{rows.map(r => (
<div key={r.id} className={`bg-white rounded-xl border p-3 flex items-center gap-3 ${
r.ready ? 'border-emerald-300' : 'border-slate-200'
}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`${STATUS_COLORS[r.status] || ''} rounded-full px-2 py-0.5 text-xs font-medium`}>{r.status}</span>
<span className="text-xs text-slate-400">{r.answer_type}</span>
</div>
<p className="text-sm text-slate-700 truncate">{r.preview || <span className="text-slate-300">— kein Text —</span>}</p>
{r.missing.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{r.missing.map((m, i) => (
<span key={i} className="text-[11px] bg-amber-50 text-amber-700 border border-amber-200 rounded-full px-2 py-0.5">{m}</span>
<div className="space-y-3">
{[...failed, ...inFlight].map(pic => (
<ProgressCard key={pic.id} pic={pic} onRetry={retry} />
))}
</div>
)}
</div>
{r.ready ? (
<button onClick={() => publish(r.id)} disabled={publishing === r.id}
className={`shrink-0 text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
publishing === r.id ? 'bg-violet-300 text-white' : 'bg-violet-600 text-white hover:bg-violet-500'
}`}>
{publishing === r.id ? '…' : 'Veröffentlichen'}
</button>
) : (
<span className="shrink-0 text-xs text-slate-400 w-20 text-right">{r.missingCount} offen</span>
)}
</div>
{ready.map(pic => (
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()}
onRefresh={() => refreshBundle(pic.id)} />
))}
</div>
)}

View File

@@ -12,8 +12,61 @@ 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">46 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);
@@ -26,7 +79,10 @@ export default function Settings() {
setRows(map);
} catch (e) { setError(e.message); }
}
useEffect(() => { load(); }, []);
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 } }));
@@ -80,8 +136,25 @@ export default function Settings() {
<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>
@@ -109,6 +182,8 @@ export default function Settings() {
<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>
);