/* Main app - VIRAL CODE dashboard */ // ============================================ // DATA NORMALIZERS // ============================================ const TINT_PALETTE = ["#4ade80","#60a5fa","#f87171","#fbbf24","#a78bfa","#fb923c","#34d399","#f472b6"]; function getDefaultTint(id) { let hash = 0; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffffffff; return TINT_PALETTE[Math.abs(hash) % TINT_PALETTE.length]; } function getInitials(name) { return name.split(" ").slice(0, 2).map((w) => w[0] || "").join("").toUpperCase() || "CH"; } function normalizeChannel(ch) { const rs = (typeof ch.render_settings === "string" ? JSON.parse(ch.render_settings || "{}") : ch.render_settings) || {}; const ig = (typeof ch.image_generation === "string" ? JSON.parse(ch.image_generation || "{}") : ch.image_generation) || {}; const as = (typeof ch.audio_settings === "string" ? JSON.parse(ch.audio_settings || "{}") : ch.audio_settings) || {}; const tint = rs.tint || getDefaultTint(ch.id); const initials = rs.initials || getInitials(ch.name); const watermarkFilename = ch.watermark_path ? ch.watermark_path.split("/").pop() : null; const avatarUrl = ch.avatar_url || null; const minimaxVoiceId = ch.minimax_voice_id || ""; const minimaxVoiceName = ch.minimax_voice_name || ""; return { id: ch.id, name: ch.name, handle: rs.handle || ch.name.toLowerCase().replace(/\s+/g, ""), initials, tint, videos: rs.videos || 0, subs: rs.subs || "—", active: rs.active !== undefined ? rs.active : true, logo: watermarkFilename ? `/watermarks-serve/${watermarkFilename}` : null, scriptRole: ch.system_prompt || "", imageInstructions: ch.prompt_instructions || "", imageStyle: ig.imageStyle || rs.imageStyle || "", voiceId: ch.voice_id || "", voiceLabel: as.voiceLabel || rs.voiceLabel || ch.voice_id || "", watermarkDefault: rs.watermarkDefault !== undefined ? rs.watermarkDefault : false, watermarkPos: rs.watermarkPos || "br", watermarkOpacity: rs.watermarkOpacity !== undefined ? rs.watermarkOpacity : 70, watermarkSize: rs.watermarkSize !== undefined ? rs.watermarkSize : 15, subtitlesEnabled: rs.subtitlesEnabled !== undefined ? rs.subtitlesEnabled : false, subFont: rs.subFont || "Inter", subWeight: rs.subWeight || "Bold", subSize: rs.subSize || 48, subPos: rs.subPos || "bottom", subColor: rs.subColor || "#ffffff", subStroke: rs.subStroke || "#000000", subStrokeW: rs.subStrokeW !== undefined ? rs.subStrokeW : 3, subUppercase: rs.subUppercase || false, subBgEnabled: rs.subBgEnabled || false, subBgColor: rs.subBgColor || "#000000", subBgPadding: rs.subBgPadding !== undefined ? rs.subBgPadding : 8, avatarUrl, minimaxVoiceId, minimaxVoiceName, _raw: ch, }; } function denormalizeChannel(ui) { const raw = ui._raw || {}; const existingRs = (typeof raw.render_settings === "string" ? JSON.parse(raw.render_settings || "{}") : raw.render_settings) || {}; const render_settings = { ...existingRs, tint: ui.tint, initials: ui.initials, handle: ui.handle, videos: ui.videos, subs: ui.subs, active: ui.active, voiceLabel: ui.voiceLabel, imageStyle: ui.imageStyle, watermarkDefault: ui.watermarkDefault, watermarkPos: ui.watermarkPos, watermarkOpacity: ui.watermarkOpacity, watermarkSize: ui.watermarkSize, subtitlesEnabled: ui.subtitlesEnabled, subFont: ui.subFont, subWeight: ui.subWeight, subSize: ui.subSize, subPos: ui.subPos, subColor: ui.subColor, subStroke: ui.subStroke, subStrokeW: ui.subStrokeW, subUppercase: ui.subUppercase, subBgEnabled: ui.subBgEnabled, subBgColor: ui.subBgColor, subBgPadding: ui.subBgPadding, }; return { name: ui.name, voice_id: ui.voiceId, system_prompt: ui.scriptRole, prompt_instructions: ui.imageInstructions, render_settings, image_generation: { imageStyle: ui.imageStyle }, audio_settings: { voiceLabel: ui.voiceLabel }, // avatar_url NO se manda aquí a propósito: lo gestiona en exclusiva la galería de // avatares (endpoints /api/channels/{id}/avatars), nunca el guardado general del // canal — si no, un guardado con datos locales desfasados lo pisaba a null. minimax_voice_id: ui.minimaxVoiceId, minimax_voice_name: ui.minimaxVoiceName, }; } const NOTIFY_STATUSES = new Set(["pending_approval", "pending_image_approval", "pending_final_approval", "completed", "failed"]); function playNotificationSound() { try { new Audio("/assets/notification.mp3").play().catch(() => {}); } catch (e) { /* ignore */ } } function normalizeJob(job, channelsList) { const statusMap = { completed: "ready", queued: "queued", queued_resume: "queued", queued_render: "queued", pending: "processing", processing: "processing", pending_approval: "processing", pending_image_approval: "processing", pending_final_approval: "processing", failed: "failed", cancelled: "failed", draft: "draft", }; const ch = channelsList.find((c) => c.id === job.channel_id); const tint = ch ? ch.tint : "#7c5cff"; const firstWord = (job.title || "").split(" ")[0] || "V"; const glyph = firstWord.slice(0, 3).toUpperCase(); const progress = job.progress ? Math.round((job.progress.current_step / Math.max(job.progress.total_steps, 1)) * 100) : 0; let ago = "—"; if (job.created_at) { const diff = Date.now() - new Date(job.created_at).getTime(); const mins = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (days > 0) ago = `hace ${days} día${days > 1 ? "s" : ""}`; else if (hours > 0) ago = `hace ${hours}h`; else if (mins > 0) ago = `hace ${mins}m`; else ago = "ahora"; } let expiresIn = null; const expiryBase = job.completed_at || job.created_at; if (expiryBase && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) { const hoursLeft = 48 - (Date.now() - new Date(expiryBase).getTime()) / 3600000; if (hoursLeft > 0) expiresIn = Math.ceil(hoursLeft); } return { id: job.job_id, title: job.title || "Sin título", channelId: job.channel_id, status: statusMap[job.status] || "draft", _rawStatus: job.status, queuePos: job.queue_pos != null ? job.queue_pos : 0, workKind: job.work_kind || "", queuedAction: job.queued_action || "", launchBroll: !!job.launch_broll, challengeRun: !!job.challenge_run, videoAvatarCreacion: !!job.video_avatar_creacion, transcription: job.transcription || "", duration: "—", durationSec: 0, views: "—", ago, expiresIn, creditCost: job.credit_cost || 0, thumbBg: `linear-gradient(135deg, ${tint}33 0%, #0b0b10 100%)`, glow: tint, glowX: 40, glowY: 35, glyph, thumbTitle: (job.title || "").slice(0, 25).toUpperCase(), progress: statusMap[job.status] === "processing" ? (progress || 5) : 0, thumbnailUrl: job.thumbnail_url || null, error: job.error || null, enableSubtitles: job.enable_subtitles !== false, nanobanana_version: job.nanobanana_version || "v1", }; } // ============================================ // AUTH SCREEN (LOGIN + REGISTER) // ============================================ function AuthScreen({ onLogin }) { const [mode, setMode] = React.useState("login"); // "login" | "register" // Login state const [username, setUsername] = React.useState(""); const [password, setPassword] = React.useState(""); // Register state const [regUsername, setRegUsername] = React.useState(""); const [regEmail, setRegEmail] = React.useState(""); const [regPassword, setRegPassword] = React.useState(""); const [regPassword2, setRegPassword2] = React.useState(""); const [error, setError] = React.useState(""); const [loading, setLoading] = React.useState(false); function switchMode(m) { setMode(m); setError(""); } async function handleLogin(e) { e.preventDefault(); if (!username || !password) return; setLoading(true); setError(""); try { const r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ username, password }), }); const d = await r.json(); if (r.ok && d.success) { onLogin(d.user || { username: d.username }); } else { setError(d.detail || "Credenciales inválidas"); } } catch { setError("Error de conexión con el servidor"); } finally { setLoading(false); } } async function handleRegister(e) { e.preventDefault(); if (!regUsername || !regEmail || !regPassword) return; if (regPassword !== regPassword2) { setError("Las contraseñas no coinciden"); return; } setLoading(true); setError(""); try { const r = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ username: regUsername, email: regEmail, password: regPassword }), }); const d = await r.json(); if (r.ok && d.success) { onLogin(d.user || { username: d.username }); } else { setError(d.detail || "Error al registrarse"); } } catch { setError("Error de conexión con el servidor"); } finally { setLoading(false); } } return (

VIRAL·CODE

{mode === "login" ? "Accede a tu dashboard" : "Crea tu cuenta"}

{/* Mode tabs */}
{[["login","Iniciar sesión"],["register","Crear cuenta"]].map(([m, label]) => ( ))}
{mode === "login" ? (
setUsername(e.target.value)} placeholder="usuario" autoFocus autoComplete="username"/>
setPassword(e.target.value)} placeholder="••••••••" autoComplete="current-password"/>
{error &&
{error}
}
) : (
setRegUsername(e.target.value)} placeholder="mi_usuario" autoFocus autoComplete="username"/>
setRegEmail(e.target.value)} placeholder="correo@ejemplo.com" autoComplete="email"/>
setRegPassword(e.target.value)} placeholder="Mínimo 6 caracteres" autoComplete="new-password"/>
setRegPassword2(e.target.value)} placeholder="••••••••" autoComplete="new-password"/>
{error &&
{error}
}
)}
); } // ============================================ // APP // ============================================ function App() { const [authed, setAuthed] = React.useState(null); const [authUser, setAuthUser] = React.useState(null); // full user object const [tab, setTab] = React.useState(() => { const saved = localStorage.getItem("vc_tab"); return saved && saved !== "create" ? saved : "create_home"; }); const [createMode, setCreateMode] = React.useState("imagenes_ia"); // modo preseleccionado al entrar en "Crear Video" const [channels, setChannels] = React.useState([]); const [jobs, setJobs] = React.useState([]); const prevJobStatusesRef = React.useRef(null); // job_id -> raw status del último fetch const [tweaksOpen, setTweaksOpen] = React.useState(false); const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); const [queueOpen, setQueueOpen] = React.useState(false); const [sbCompact, setSbCompact] = React.useState(() => localStorage.getItem("vc_sb_compact") === "1"); const [accent, setAccent] = React.useState("#7c5cff"); const [density, setDensity] = React.useState("comfy"); const [radius, setRadius] = React.useState(10); async function refreshUser() { try { const r = await fetch("/api/auth/me", { credentials: "same-origin" }); if (r.ok) { const u = await r.json(); setAuthUser(u); } } catch {} } // Check auth on mount React.useEffect(() => { fetch("/api/auth/check", { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => { setAuthed(d.authenticated); setAuthUser(d.user || null); }) .catch(() => setAuthed(false)); }, []); // Load data when authenticated React.useEffect(() => { if (authed) { fetchChannels(); fetchJobs(); } }, [authed]); // Poll active jobs — solo "pending" y "processing" cambian solos; approval espera al usuario React.useEffect(() => { if (!authed) return; const hasRunning = jobs.some((j) => ["queued", "queued_resume", "queued_render", "pending", "processing"].includes(j._rawStatus)); if (!hasRunning) return; const id = setInterval(fetchJobs, 10000); return () => clearInterval(id); }, [jobs, authed]); async function fetchChannels() { try { const r = await fetch("/api/channels", { credentials: "same-origin" }); if (!r.ok) return; const d = await r.json(); setChannels((d.channels || []).map(normalizeChannel)); } catch (e) { /* ignore */ } } async function fetchJobs() { try { const r = await fetch("/api/videos/jobs?limit=50", { credentials: "same-origin" }); if (!r.ok) return; const d = await r.json(); const rawJobs = d.jobs || []; // Sonido de aviso cuando un job pasa a necesitar al usuario (aprobar guion/imágenes, // o terminó/falló). Solo en la transición, nunca en la carga inicial ni en polls repetidos. if (prevJobStatusesRef.current) { const prevStatuses = prevJobStatusesRef.current; const justNeedsAttention = rawJobs.some((j) => { const prevStatus = prevStatuses[j.job_id]; return prevStatus && prevStatus !== j.status && NOTIFY_STATUSES.has(j.status); }); if (justNeedsAttention) playNotificationSound(); } prevJobStatusesRef.current = Object.fromEntries(rawJobs.map((j) => [j.job_id, j.status])); // Use functional update to get latest channels setChannels((prev) => { setJobs(rawJobs.map((j) => normalizeJob(j, prev))); return prev; }); } catch (e) { /* ignore */ } } async function handleLogout() { await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); setAuthed(false); setAuthUser(null); setChannels([]); setJobs([]); } React.useEffect(() => { localStorage.setItem("vc_sb_compact", sbCompact ? "1" : "0"); }, [sbCompact]); React.useEffect(() => { localStorage.setItem("vc_tab", tab); }, [tab]); React.useEffect(() => { const root = document.documentElement.style; root.setProperty("--accent", accent); const r = parseInt(accent.slice(1, 3), 16); const g = parseInt(accent.slice(3, 5), 16); const b = parseInt(accent.slice(5, 7), 16); root.setProperty("--accent-soft", `rgba(${r}, ${g}, ${b}, 0.14)`); root.setProperty("--accent-softer", `rgba(${r}, ${g}, ${b}, 0.08)`); root.setProperty("--radius", radius + "px"); root.setProperty("--radius-lg", (radius + 4) + "px"); }, [accent, radius]); React.useEffect(() => { function onMsg(e) { if (!e.data) return; if (e.data.type === "__activate_edit_mode") setTweaksOpen(true); if (e.data.type === "__deactivate_edit_mode") setTweaksOpen(false); } window.addEventListener("message", onMsg); window.parent.postMessage({ type: "__edit_mode_available" }, "*"); return () => window.removeEventListener("message", onMsg); }, []); // Loading spinner while checking auth if (authed === null) { return (
); } if (!authed) { return { setAuthed(true); setAuthUser(user); }}/>; } const isAdmin = authUser?.is_admin; const titles = { create_home: { t: "Crear vídeo", s: "Elige cómo crear tu vídeo" }, launch_list: { t: "Creación en lista", s: "Lanzamiento Automatizado · en desarrollo" }, create: { t: "Crear Video", s: "Generar video con Claude + Nanobanana + FFmpeg" }, imagenes_ia: { t: "Imágenes IA", s: "Guion con IA o propio, ilustrado con imágenes generadas" }, personaje: { t: "Imágenes Personaje", s: "Sube tu audio y el personaje del canal aparece en las imágenes" }, stock_clips: { t: "Clips de Stock", s: "Montado con clips de Pexels y Pixabay · BETA" }, video_avatar: { t: "Video Avatar", s: "Sube tu vídeo de avatar y monta con B-Roll e imágenes" }, channels: { t: "Mis canales", s: "Perfiles de estilo para tus canales de YouTube" }, history: { t: "Producción", s: jobs.length + " videos generados" }, gen_images: { t: "Generar Imágenes", s: "Crea imágenes con Nanobanana · eliminación automática 48h" }, gen_clips: { t: "Generar Clips", s: "Crea vídeos con kie.ai Gemini Omni · eliminación automática 48h" }, music_library: { t: "Música de fondo", s: "Tu biblioteca de música para vídeos" }, planning: { t: "Planning", s: "Tareas de Álvaro y José" }, challenge30: { t: "30-day challenge", s: "Una columna por canal · el más nuevo a la izquierda" }, shared_access: { t: "Accesos compartidos", s: "Gestor de credenciales cifradas para tu equipo" }, viral_voice: { t: "Viral Voice", s: "Generar voz, mis voces, clonar voz e historial" }, gen_voice: { t: "Viral Voice", s: "Síntesis de voz con Minimax TTS · ai33.pro" }, mis_voces: { t: "Viral Voice", s: "Biblioteca de voces preset y tus clones" }, clonar_voz: { t: "Viral Voice", s: "Crea tu propia voz personalizada" }, voice_history: { t: "Viral Voice", s: "Tus generaciones de voz anteriores" }, settings: { t: "Configuración", s: "Cuenta, apariencia y notificaciones" }, billing: { t: "Mi Plan", s: "Gestiona tu suscripción y uso mensual" }, admin: { t: "Administración", s: "Panel de control de usuarios y facturación" }, }; function handleTabChange(id) { setTab(id); setMobileMenuOpen(false); } const PENDING_REVIEW_STATUSES = ["pending_approval", "pending_image_approval", "pending_final_approval"]; const historyPending = jobs.filter((j) => (!j.launchBroll || j.videoAvatarCreacion) && PENDING_REVIEW_STATUSES.includes(j._rawStatus)).length; const launchListPending = jobs.filter((j) => j.launchBroll && !j.challengeRun && !j.videoAvatarCreacion && PENDING_REVIEW_STATUSES.includes(j._rawStatus)).length; const challengePending = jobs.filter((j) => j.challengeRun && PENDING_REVIEW_STATUSES.includes(j._rawStatus)).length; return (
{mobileMenuOpen && (
setMobileMenuOpen(false)}/> )} setSbCompact(!sbCompact)} mobileOpen={mobileMenuOpen} currentUser={authUser} onLogout={handleLogout} historyPending={historyPending} launchListPending={launchListPending} challengePending={challengePending}/>
setMobileMenuOpen(true)} currentUser={authUser}> {tab === "history" && jobs.some((j) => ["queued", "queued_resume", "queued_render"].includes(j._rawStatus)) && ( )} {tab === "history" && ( )} {tab === "channels" && ( )}
{tab === "create_home" && { if (m.mode) setCreateMode(m.mode); handleTabChange(m.target); }}/>} {tab === "launch_list" && } {tab === "create" && { fetchJobs(); refreshUser(); setTab("history"); }}/>} {tab === "imagenes_ia" && { fetchJobs(); refreshUser(); setTab("history"); }}/>} {tab === "personaje" && { fetchJobs(); refreshUser(); setTab("history"); }}/>} {tab === "stock_clips" && { fetchJobs(); refreshUser(); setTab("history"); }}/>} {tab === "video_avatar" && { fetchJobs(); refreshUser(); setTab("history"); }}/>} {tab === "channels" && } {tab === "history" && } {tab === "gen_images" && } {tab === "gen_clips" && } {tab === "music_library" && } {tab === "shared_access" && } {tab === "planning" && } {tab === "challenge30" && isAdmin && } {(tab === "viral_voice" || (window.VIRAL_VOICE_IDS || []).includes(tab)) && ( )} {tab === "settings" && } {tab === "billing" && } {tab === "admin" && isAdmin && }
{queueOpen && ( setQueueOpen(false)} onChanged={fetchJobs}/> )} {/* Tweaks */}
Tweaks
{[ { c: "#7c5cff", name: "violeta" }, { c: "#4ade80", name: "verde" }, { c: "#60a5fa", name: "azul" }, { c: "#f87171", name: "rojo" }, { c: "#fbbf24", name: "ámbar" }, ].map((s) => (
setAccent(s.c)}/> ))}
setRadius(+e.target.value)} style={{ width: 120, accentColor: accent }}/>
); } Object.assign(window, { normalizeChannel, denormalizeChannel, normalizeJob, getInitials }); const root = ReactDOM.createRoot(document.getElementById("root")); root.render();