/* 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)}/>
{row.useOwnNarration && TAL CUAL} {row.maintainObjectContext && CONTEXTO} {channelAvatars.length >= 1 && ( )}
{row.jobId ? ( job && job._rawStatus === "failed" && job.error ? ( openPopover("error", e)}> {label} ) : ( onOpenReview(row.jobId) : undefined}> {label} ) ) : ( Listo )}
{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) => ( ))}
)} {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 && ( )}
)}
); } 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 (
Día {dayNumber} {hasPending && ( )}
{ch ? (
{ch.name}
) : (
Canal no encontrado
)}
{rows.map((r) => ( ))} {!rows.some((r) => r.title.trim() === "") && ( )}
{narrationRow && ( setNarrationRowId(null)} onSave={(text, useOwn, maintainContext) => { updateRow(narrationRowId, "narration", text); updateRow(narrationRowId, "useOwnNarration", useOwn); updateRow(narrationRowId, "maintainObjectContext", maintainContext); }}/> )}
); } function ChallengeAddColumn({ availableChannels, onAdd }) { const [popover, setPopover] = React.useState(null); // { top, left } | null 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]); function toggle(e) { e.stopPropagation(); if (popover) { setPopover(null); return; } const r = e.currentTarget.getBoundingClientRect(); setPopover({ top: r.bottom + 8, left: r.left }); } return (
{popover && (
e.stopPropagation()} style={{ position: "fixed", top: popover.top, left: popover.left, minWidth: 230, maxHeight: 320, overflowY: "auto", padding: 4, zIndex: 200, }}> {availableChannels.length === 0 ? (
Todos los canales activos ya están en el challenge.
) : availableChannels.map((c) => ( ))}
)}
); } function Challenge30Tab({ channels, jobs, fetchJobs }) { const [assigned, setAssigned] = React.useState([]); const [loading, setLoading] = React.useState(true); const [reviewJobId, setReviewJobId] = React.useState(null); const [galleryJobId, setGalleryJobId] = React.useState(null); const [reassembleRowJobId, setReassembleRowJobId] = React.useState(null); function loadAssigned() { fetch("/api/challenge/channels", { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setAssigned(d.channels || [])) .catch(() => {}) .finally(() => setLoading(false)); } React.useEffect(() => { loadAssigned(); }, []); async function addChannel(channelId) { try { const r = await fetch("/api/challenge/channels", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: channelId }), }); const d = await r.json(); if (r.ok && d.success) setAssigned((prev) => [d.channel, ...prev]); } catch {} } async function removeChannel(channelId) { if (!confirm("¿Quitar este canal del challenge? Los vídeos ya generados no se borran, solo desaparece la columna.")) return; setAssigned((prev) => prev.filter((a) => a.channel_id !== channelId)); try { await fetch(`/api/challenge/channels/${channelId}`, { method: "DELETE", credentials: "same-origin" }); } catch {} } const activeChannels = (channels || []).filter((c) => c.active !== false); const availableChannels = activeChannels.filter((c) => !assigned.some((a) => a.channel_id === c.id)); return (
{loading ? (
Cargando…
) : (
{assigned.map((entry, i) => ( // 'assigned' viene del servidor ordenado por assigned_at DESC (más nuevo primero, // a la izquierda) — Día 1 es el canal asignado más antiguo, no días transcurridos. ))}
)} {reviewJobId && (() => { const job = (jobs || []).find((j) => j.id === reviewJobId); if (!job) return null; const reviewType = job._rawStatus === "pending_approval" ? "script" : "images"; return ( setReviewJobId(null)} onApproved={() => { setReviewJobId(null); if (fetchJobs) fetchJobs(); }}/> ); })()} {galleryJobId && ( setGalleryJobId(null)}/> )} {reassembleRowJobId && (() => { const job = (jobs || []).find((j) => j.id === reassembleRowJobId); return ( setReassembleRowJobId(null)} onDone={() => { setReassembleRowJobId(null); if (fetchJobs) fetchJobs(); }}/> ); })()}
); } Object.assign(window, { Challenge30Tab });