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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { apiPost, apiPatch } from '../lib/api';
|
import { apiPost, apiPatch } from '../lib/api';
|
||||||
|
import { buildRows } from '../lib/pairRows';
|
||||||
|
|
||||||
const LANGS = [
|
const LANGS = [
|
||||||
{ code: 'de', flag: '🇩🇪' },
|
{ code: 'de', flag: '🇩🇪' },
|
||||||
@@ -7,59 +8,6 @@ const LANGS = [
|
|||||||
{ code: 'sv', flag: '🇸🇪' },
|
{ code: 'sv', flag: '🇸🇪' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Platzhalter {{label.type:uuid}} → nur das Label anzeigen.
|
|
||||||
function strip(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
return text.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
|
|
||||||
const m = inner.match(/^(.+?)\.[a-z]+:[0-9a-f-]{36}$/i);
|
|
||||||
return m ? m[1] : inner;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Baut die Anzeigezeilen 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) });
|
|
||||||
// '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
|
|
||||||
// ('fehlt' statt stilles Ausblenden, damit eine fehlende Negativ-Antwort auffällt).
|
|
||||||
// 'text' hat per Definition kein Negativ.
|
|
||||||
if (type === 'question')
|
|
||||||
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 }) {
|
export default function PairReviewModal({ pair, content, onClose, onDone, onRetranslate }) {
|
||||||
const [busy, setBusy] = useState(null); // 'review' | 'block' | 'retranslate'
|
const [busy, setBusy] = useState(null); // 'review' | 'block' | 'retranslate'
|
||||||
const [missing, setMissing] = useState(null);
|
const [missing, setMissing] = useState(null);
|
||||||
|
|||||||
53
src/lib/pairRows.js
Normal file
53
src/lib/pairRows.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(', ');
|
||||||
|
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) });
|
||||||
|
// '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;
|
||||||
|
}
|
||||||
@@ -873,7 +873,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted, onSavedA
|
|||||||
|
|
||||||
// ─── 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">
|
||||||
@@ -887,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">
|
||||||
@@ -1175,11 +1178,82 @@ 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 → 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) ──────────────────────────────────────────
|
// ─── Geführter Review-Flow (Wizard) ──────────────────────────────────────────
|
||||||
// Läuft die Pairs des ausgewählten Objekts der Reihe nach durch. Wiederverwendung:
|
// Läuft die Pairs des ausgewählten Objekts der Reihe nach durch. Wiederverwendung:
|
||||||
// EditPairForm (Editor + alle Aktionen) und PairReviewModal (Übersetzungs-Prüf-Grid).
|
// EditPairForm (Editor + alle Aktionen) und PairReviewModal (Übersetzungs-Prüf-Grid).
|
||||||
@@ -1747,6 +1821,7 @@ export default function ContentCreation() {
|
|||||||
onSelectObject={handleSelectObject}
|
onSelectObject={handleSelectObject}
|
||||||
currentPicture={currentPicture}
|
currentPicture={currentPicture}
|
||||||
onObjectStatusChange={handleObjectStatusChange}
|
onObjectStatusChange={handleObjectStatusChange}
|
||||||
|
onPicturesReload={reloadPictures}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ImageCanvas
|
<ImageCanvas
|
||||||
|
|||||||
@@ -1,109 +1,340 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch, apiPost } from '../lib/api';
|
||||||
import { STATUS_COLORS } from '../lib/tables';
|
import { buildRows } from '../lib/pairRows';
|
||||||
|
|
||||||
const LANGS = [
|
const LANGS = [
|
||||||
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
{ code: 'de', flag: '🇩🇪' },
|
||||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
{ code: 'en', flag: '🇬🇧' },
|
||||||
{ code: 'sv', label: 'Svenska', flag: '🇸🇪' },
|
{ code: 'sv', flag: '🇸🇪' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Publish() {
|
const STEP_LABELS = {
|
||||||
const [lang, setLang] = useState('sv');
|
pairs: 'Pairs werden generiert',
|
||||||
const [rows, setRows] = useState([]);
|
translate: 'Übersetzen',
|
||||||
const [loading, setLoading] = useState(true);
|
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) * 35;
|
||||||
|
} 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) ────────────────────────
|
||||||
|
|
||||||
|
function PairRow({ pair, flagged, onToggleFlag }) {
|
||||||
|
const rows = buildRows(pair.content);
|
||||||
|
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 || <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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bereit-Karte: ganzes Bild-Bundle ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReadyCard({ bundle, onPublished }) {
|
||||||
|
const [excluded, setExcluded] = useState(() => new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [publishing, setPublishing] = useState(null);
|
const [notReady, setNotReady] = useState(null);
|
||||||
const [msg, setMsg] = useState(null);
|
const [done, setDone] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
if (!bundle) {
|
||||||
setLoading(true); setError(null);
|
return (
|
||||||
try {
|
<div className="bg-white rounded-xl border border-slate-200 p-4 animate-pulse h-24" />
|
||||||
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); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) — flaggen oder erst vervollständigen
|
||||||
|
</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)} />
|
||||||
|
))}
|
||||||
|
{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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Layout back="/">
|
<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>
|
<h1 className="text-2xl font-bold text-slate-800 mb-1">Veröffentlichen</h1>
|
||||||
<p className="text-slate-500 mb-4">
|
<p className="text-slate-500 mb-4">
|
||||||
Pairs sortiert nach <b>„am wenigsten fehlt"</b>. Was komplett ist (Text, Bild, Audio in der
|
Automatisch erstellte Inhalte prüfen und pro Bild mit einem Klick freigeben.
|
||||||
gewählten Sprache), kannst du mit einem Klick veröffentlichen — Frage & Sätze werden mit freigegeben.
|
Einzelne Pairs per 🚩 ausschließen — sie werden beim Veröffentlichen geblockt.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4 text-sm text-slate-500">
|
||||||
<span className="text-sm font-medium text-slate-600">Sprache:</span>
|
{inFlight.length > 0 && <span className="text-sky-600 font-medium">{inFlight.length} in Arbeit</span>}
|
||||||
<div className="flex gap-1">
|
{failed.length > 0 && <span className="text-red-600 font-medium">{failed.length} fehlgeschlagen</span>}
|
||||||
{LANGS.map(l => (
|
<span className="text-emerald-600 font-medium">{ready.length} bereit</span>
|
||||||
<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>
|
</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>}
|
{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 ? (
|
{overview === null ? (
|
||||||
<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>
|
<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 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-8 text-center text-slate-400">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{rows.map(r => (
|
{[...failed, ...inFlight].map(pic => (
|
||||||
<div key={r.id} className={`bg-white rounded-xl border p-3 flex items-center gap-3 ${
|
<ProgressCard key={pic.id} pic={pic} onRetry={retry} />
|
||||||
r.ready ? 'border-emerald-300' : 'border-slate-200'
|
))}
|
||||||
}`}>
|
{ready.map(pic => (
|
||||||
<div className="flex-1 min-w-0">
|
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,58 @@ function put(language, body) {
|
|||||||
return apiFetch(`/tts-settings/${language}`, { method: 'PUT', body: JSON.stringify(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() {
|
export default function Settings() {
|
||||||
const [rows, setRows] = useState({}); // language → settings
|
const [rows, setRows] = useState({}); // language → settings
|
||||||
const [saving, setSaving] = useState(null);
|
const [saving, setSaving] = useState(null);
|
||||||
@@ -109,6 +161,8 @@ export default function Settings() {
|
|||||||
<p className="text-xs text-slate-400 mt-3">
|
<p className="text-xs text-slate-400 mt-3">
|
||||||
Diese Werte werden bei jeder Audio-Generierung verwendet. Änderungen wirken auf neu erzeugte Audios.
|
Diese Werte werden bei jeder Audio-Generierung verwendet. Änderungen wirken auf neu erzeugte Audios.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<PipelineSettings />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user