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>
This commit is contained in:
@@ -75,8 +75,11 @@ function ProgressCard({ pic, onRetry }) {
|
||||
|
||||
// ─── Pair-Zeile (flach, 3 Sprachspalten, Flag-Toggle) ────────────────────────
|
||||
|
||||
function PairRow({ pair, flagged, onToggleFlag }) {
|
||||
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'}`}>
|
||||
@@ -133,18 +136,37 @@ function PairRow({ pair, flagged, onToggleFlag }) {
|
||||
🚩
|
||||
</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 }) {
|
||||
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);
|
||||
|
||||
if (!bundle) {
|
||||
return (
|
||||
@@ -170,6 +192,33 @@ function ReadyCard({ bundle, onPublished }) {
|
||||
});
|
||||
}
|
||||
|
||||
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 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 {
|
||||
@@ -212,8 +261,14 @@ function ReadyCard({ bundle, onPublished }) {
|
||||
{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 className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-amber-600">
|
||||
⚠ {incompletePairs.length} Pair{incompletePairs.length !== 1 ? 's' : ''} unvollständig (Text/Audio)
|
||||
</span>
|
||||
<button onClick={fillAudio} disabled={audioFilling}
|
||||
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>
|
||||
@@ -230,7 +285,8 @@ function ReadyCard({ bundle, onPublished }) {
|
||||
</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)} />
|
||||
<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>
|
||||
@@ -300,6 +356,11 @@ export default function Publish() {
|
||||
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');
|
||||
@@ -334,7 +395,8 @@ export default function Publish() {
|
||||
<ProgressCard key={pic.id} pic={pic} onRetry={retry} />
|
||||
))}
|
||||
{ready.map(pic => (
|
||||
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()} />
|
||||
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()}
|
||||
onRefresh={() => refreshBundle(pic.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user