/* Tab: 30-day challenge (solo admin) — una columna por canal asignado, cada columna es
una tabla infinita de vídeos (mismo pipeline que 'Creación en lista', vista compactada).
Reutiliza componentes globales ya definidos en otros archivos (funciones top-level =
globales en scripts no-módulo): LaunchChannelIcon, NarrationModal, ErrorPopover
(tab_launch_list.jsx), ReviewModal, ImageGalleryModal, ReassembleModal, _triggerDownload
(tab_history.jsx), y las consts LAUNCH_STATUS_LABELS / LAUNCH_REVIEW_STATUSES
(tab_launch_list.jsx — por eso este script se carga después en index.html). */
function ChallengeVideoCard({ row, job, channelAvatars, onUpdateRow, onRemoveRow, onLaunch, onRetry, onDelete, onCancel, onOpenNarration, onOpenReview, onOpenGallery, onOpenReassemble, launching }) {
// Las tarjetas viven dentro de una columna con scroll vertical propio: un popover
// position:absolute se recorta en el borde de ese scroll aunque tenga z-index alto
// (overflow:auto recorta a TODOS los descendientes). position:fixed, anclado a las
// coordenadas reales del botón, escapa de ese recorte.
const [popover, setPopover] = React.useState(null); // { type, top, left } | null
const locked = !!row.jobId;
const orphaned = !!row.jobId && !job;
const finished = !!row.jobId && (orphaned || ["completed", "failed", "cancelled"].includes(job._rawStatus));
const midGeneration = !!row.jobId && !finished;
const isReady = row.title.trim() !== "" && row.narration.trim() !== "";
const canPlay = (!row.jobId && isReady) || finished;
const busy = !!launching;
const downloadReady = !!(row.jobId && job && job._rawStatus === "completed");
const reassembleReady = !!(row.jobId && job && ["completed", "failed"].includes(job._rawStatus));
const label = row.jobId ? (job ? (LAUNCH_STATUS_LABELS[job._rawStatus] || "Procesando…") : "No encontrado (borrado)") : null;
const clickableStatus = !!job && LAUNCH_REVIEW_STATUSES.has(job._rawStatus);
function openPopover(type, e) {
e.stopPropagation();
if (popover && popover.type === type) { setPopover(null); return; }
const r = e.currentTarget.getBoundingClientRect();
setPopover({ type, top: r.bottom + 6, left: r.left, right: window.innerWidth - r.right });
}
React.useEffect(() => {
if (!popover) return;
function onDoc() { setPopover(null); }
document.addEventListener("click", onDoc);
window.addEventListener("scroll", onDoc, true);
return () => { document.removeEventListener("click", onDoc); window.removeEventListener("scroll", onDoc, true); };
}, [popover]);
return (
onUpdateRow(row.id, "title", e.target.value)}/>
onOpenNarration(row.id)}>
{row.narration.trim() ? "Narración ✓" : "Narración"}
{row.useOwnNarration && TAL CUAL }
{row.maintainObjectContext && CONTEXTO }
{channelAvatars.length >= 1 && (
openPopover("avatar", e)}>
)}
{row.jobId ? (
job && job._rawStatus === "failed" && job.error ? (
openPopover("error", e)}>
{label}
) : (
onOpenReview(row.jobId) : undefined}>
{label}
)
) : (
Listo
)}
{
if (job && job._rawStatus === "failed") { onRetry(row); return; }
if (finished && !confirm("¿Generar de nuevo este vídeo? Se creará un vídeo nuevo con el mismo título, narración y canal.")) return;
onLaunch(row);
}}>
downloadReady && _triggerDownload(`/api/videos/jobs/${row.jobId}/download`)}>
onDelete(row)}>
openPopover("more", e)}>
{popover && popover.type === "avatar" && (
e.stopPropagation()} style={{
position: "fixed", top: popover.top, left: popover.left, padding: 8, minWidth: 190, zIndex: 200,
display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 6,
}}>
{channelAvatars.map((av) => (
{ onUpdateRow(row.id, "avatarId", av.is_default ? null : av.id); setPopover(null); }}
style={{
padding: 0, border: "2px solid " + ((row.avatarId ? row.avatarId === av.id : av.is_default) ? "var(--accent)" : "transparent"),
borderRadius: 6, overflow: "hidden", cursor: "pointer", background: "none",
}}>
))}
)}
{popover && popover.type === "error" && job && job.error && (
e.stopPropagation()} style={{
position: "fixed", top: popover.top, left: popover.left, zIndex: 200, maxWidth: 320,
padding: 10, fontSize: 12, lineHeight: 1.5, color: "var(--text-2)",
}}>
{job.error}
)}
{popover && popover.type === "more" && (
e.stopPropagation()} style={{
position: "fixed", top: popover.top, right: popover.right, minWidth: 200, padding: 4, zIndex: 200,
}}>
{midGeneration && (
{ onCancel(row); setPopover(null); }} style={{
width: "100%", background: "transparent", border: "none", color: "var(--red)",
fontSize: 12.5, padding: "8px 10px", borderRadius: 7, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8, textAlign: "left",
}}> Cancelar generación
)}
{ onOpenGallery(row.jobId); setPopover(null); }} style={{
width: "100%", background: "transparent", border: "none", color: "var(--text)",
fontSize: 12.5, padding: "8px 10px", borderRadius: 7, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8, textAlign: "left",
}}> Ver imágenes
{ _triggerDownload(`/api/videos/jobs/${row.jobId}/transcript`); setPopover(null); }} style={{
width: "100%", background: "transparent", border: "none", color: "var(--text)",
fontSize: 12.5, padding: "8px 10px", borderRadius: 7, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8, textAlign: "left",
}}> Transcripción
{ _triggerDownload(`/api/videos/jobs/${row.jobId}/audio`); setPopover(null); }} style={{
width: "100%", background: "transparent", border: "none", color: "var(--text)",
fontSize: 12.5, padding: "8px 10px", borderRadius: 7, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8, textAlign: "left",
}}> Audio
{ if (!reassembleReady) return; onOpenReassemble(row.jobId); setPopover(null); }}
style={{
width: "100%", background: "transparent", border: "none",
color: reassembleReady ? "var(--text)" : "var(--text-4)",
fontSize: 12.5, padding: "8px 10px", borderRadius: 7, cursor: reassembleReady ? "pointer" : "default",
display: "flex", alignItems: "center", gap: 8, textAlign: "left",
}}> Remontar
)}
);
}
function ChallengeColumn({ entry, dayNumber, channels, jobs, fetchJobs, onRemove, onOpenReview, onOpenGallery, onOpenReassemble }) {
const ch = channels.find((c) => c.id === entry.channel_id);
const ROWS_KEY = `vc_challenge_rows_${entry.channel_id}`;
function makeEmptyRow() {
return {
id: `crow_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
title: "", narration: "", useOwnNarration: false, maintainObjectContext: false,
avatarId: null, jobId: null, error: "",
};
}
const [rows, setRows] = React.useState(() => {
try {
const saved = JSON.parse(localStorage.getItem(ROWS_KEY) || "null");
if (Array.isArray(saved) && saved.length > 0) return saved;
} catch {}
return [makeEmptyRow()];
});
const [channelAvatars, setChannelAvatars] = React.useState([]);
const [launching, setLaunching] = React.useState({});
const [narrationRowId, setNarrationRowId] = React.useState(null);
React.useEffect(() => {
try { localStorage.setItem(ROWS_KEY, JSON.stringify(rows)); } catch {}
}, [rows]);
React.useEffect(() => {
fetch(`/api/channels/${entry.channel_id}/avatars`, { credentials: "same-origin" })
.then((r) => r.json())
.then((d) => setChannelAvatars(d.avatars || []))
.catch(() => setChannelAvatars([]));
}, [entry.channel_id]);
// Recupera filas huérfanas: vídeos de este canal lanzados desde el challenge que ya
// existen en el servidor pero no están vinculados a ninguna fila local.
React.useEffect(() => {
if (!jobs || jobs.length === 0) return;
const orphans = jobs.filter((j) => j.launchBroll && j.challengeRun && j.channelId === entry.channel_id && !rows.some((r) => r.jobId === j.id));
if (orphans.length === 0) return;
setRows((prev) => {
const nonDraft = prev.filter((r) => r.title.trim() !== "");
const recovered = orphans.map((j) => ({
id: `crow_${j.id}`, title: j.title, narration: j.transcription || "",
useOwnNarration: false, maintainObjectContext: false, avatarId: null,
jobId: j.id, error: "",
}));
return [...nonDraft, ...recovered, makeEmptyRow()];
});
}, [jobs]);
const missingSinceRef = React.useRef({});
React.useEffect(() => {
if (!jobs || jobs.length === 0) return;
const now = Date.now();
const goneIds = [];
rows.forEach((r) => {
if (!r.jobId) return;
if (jobs.some((j) => j.id === r.jobId)) { delete missingSinceRef.current[r.jobId]; return; }
if (!missingSinceRef.current[r.jobId]) missingSinceRef.current[r.jobId] = now;
else if (now - missingSinceRef.current[r.jobId] > 15000) goneIds.push(r.jobId);
});
if (goneIds.length > 0) {
goneIds.forEach((id) => delete missingSinceRef.current[id]);
setRows((prev) => prev.filter((r) => !r.jobId || !goneIds.includes(r.jobId)));
}
}, [jobs]);
function updateRow(id, field, value) {
setRows((prev) => {
let next = prev.map((r) => (r.id === id ? { ...r, [field]: value } : r));
const last = next[next.length - 1];
if (last.id === id && field === "title" && last.title.trim() !== "") next = [...next, makeEmptyRow()];
return next;
});
}
function removeRow(id) {
setRows((prev) => {
let next = prev.filter((r) => r.id !== id);
if (next.length === 0) return [makeEmptyRow()];
const last = next[next.length - 1];
if (last.title.trim() !== "") next = [...next, makeEmptyRow()];
return next;
});
}
function jobForRow(r) {
return r.jobId ? (jobs || []).find((j) => j.id === r.jobId) : null;
}
async function launchRow(r) {
setLaunching((prev) => ({ ...prev, [r.id]: true }));
updateRow(r.id, "error", "");
try {
const resp = await fetch("/api/videos/generate", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: r.title, channel_id: entry.channel_id,
mode: r.useOwnNarration ? "narracion" : "referencia",
transcription: r.narration,
launch_broll: true, challenge_run: true,
voice_speed: 0.9,
maintain_object_context: !!r.maintainObjectContext,
avatar_id: r.avatarId || null,
}),
});
const d = await resp.json();
if (resp.ok && d.success) {
updateRow(r.id, "jobId", d.job_id);
if (fetchJobs) fetchJobs();
} else {
updateRow(r.id, "error", d.detail || "Error al iniciar la generación");
}
} catch (e) {
updateRow(r.id, "error", "Error de red al crear el vídeo");
} finally {
setLaunching((prev) => ({ ...prev, [r.id]: false }));
}
}
async function retryOrRegenerateRow(r) {
setLaunching((prev) => ({ ...prev, [r.id]: true }));
try {
const resp = await fetch(`/api/videos/jobs/${r.jobId}/retry`, { method: "POST", credentials: "same-origin" });
if (resp.ok) { if (fetchJobs) fetchJobs(); return; }
} catch (e) {}
finally { setLaunching((prev) => ({ ...prev, [r.id]: false })); }
if (!confirm("No se pudo reanudar el montaje — ¿generar de nuevo este vídeo desde cero?")) return;
launchRow(r);
}
async function deleteRow(r) {
if (r.jobId) {
if (!confirm("¿Eliminar este vídeo del servidor? Esta acción no se puede deshacer.")) return;
try { await fetch(`/api/videos/jobs/${r.jobId}`, { method: "DELETE", credentials: "same-origin" }); } catch {}
if (fetchJobs) fetchJobs();
}
removeRow(r.id);
}
// Cancela un vídeo que se está generando ahora mismo (los créditos ya gastados no
// se recuperan al cancelar).
async function cancelRow(r) {
if (!confirm("¿Cancelar la generación de este vídeo? Se perderá el progreso y no se recuperan los créditos gastados.")) return;
try { await fetch(`/api/videos/jobs/${r.jobId}`, { method: "DELETE", credentials: "same-origin" }); } catch {}
if (fetchJobs) fetchJobs();
removeRow(r.id);
}
const narrationRow = narrationRowId ? rows.find((r) => r.id === narrationRowId) : null;
const hasPending = (jobs || []).some((j) => j.challengeRun && j.channelId === entry.channel_id && LAUNCH_REVIEW_STATUSES.has(j._rawStatus));
return (