/* 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" ? (
) : (
)}
);
}
// ============================================
// 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();