/* Tab: Gestión de Canales */ function NewChannelModal({ onClose, onCreated }) { const [name, setName] = React.useState(""); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); async function handleCreate(e) { e.preventDefault(); if (!name.trim()) { setError("El nombre es obligatorio"); return; } setLoading(true); setError(""); try { const r = await fetch("/api/channels", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim(), voice_id: "" }), }); const d = await r.json(); if (r.ok && d.success) { onCreated(d.channel.id); onClose(); } else setError(d.detail || "Error al crear canal"); } catch { setError("Error de conexión"); } finally { setLoading(false); } } return (
e.stopPropagation()}>

Nuevo canal

setName(e.target.value)} placeholder="Mi canal de historia" autoFocus/>
{error &&
{error}
}
); } function ChannelsTab({ channels, setChannels, refreshChannels, currentUser }) { const channelLimit = currentUser?.channel_limit ?? "∞"; const [selected, setSelected] = React.useState(channels[0]?.id || null); const [showNewModal, setShowNewModal] = React.useState(false); const [dirty, setDirty] = React.useState(false); const [saving, setSaving] = React.useState(false); const [saveError, setSaveError] = React.useState(""); const [editingName, setEditingName] = React.useState(false); const [showInactive, setShowInactive] = React.useState(false); const cleanRef = React.useRef(null); const [cloneVoices, setCloneVoices] = React.useState([]); const [loadingClones, setLoadingClones] = React.useState(true); const [clonesError, setClonesError] = React.useState(""); const [editVoiceMode, setEditVoiceMode] = React.useState("manual"); const [avatars, setAvatars] = React.useState([]); const [loadingAvatars, setLoadingAvatars] = React.useState(true); const [avatarsError, setAvatarsError] = React.useState(""); const [uploadingAvatar, setUploadingAvatar] = React.useState(false); const [ai33Provider, setAi33Provider] = React.useState("elevenlabs"); const [ai33Query, setAi33Query] = React.useState(""); const [ai33Results, setAi33Results] = React.useState([]); const [ai33Page, setAi33Page] = React.useState(1); const [ai33HasMore, setAi33HasMore] = React.useState(false); const [ai33Loading, setAi33Loading] = React.useState(false); const [ai33Error, setAi33Error] = React.useState(""); const [ai33PlayingId, setAi33PlayingId] = React.useState(null); const ai33AudioRef = React.useRef(null); // Keep selected valid when channels change React.useEffect(() => { if (!selected && channels.length > 0) setSelected(channels[0].id); }, [channels]); // Snapshot clean state whenever the selected channel changes React.useEffect(() => { const ch = channels.find((c) => c.id === selected); cleanRef.current = ch ? JSON.parse(JSON.stringify(ch)) : null; setDirty(false); setSaveError(""); }, [selected]); // intentionally omit channels to avoid re-snapshot on every keystroke // Expose modal opener to parent (topbar button) React.useEffect(() => { window._openNewChannelModal = () => setShowNewModal(true); return () => { delete window._openNewChannelModal; }; }, []); React.useEffect(() => { fetch("/api/voice/clones", { credentials: "same-origin" }) .then(async (r) => { if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `HTTP ${r.status}`); } return r.json(); }) .then(d => { const raw = d.voice_list || d.data || d.voices || d.clones || []; setCloneVoices(raw.map(c => ({ voice_id: c.voice_id || c.id, voice_name: c.voice_name || c.name || "Clon sin nombre", sample_url: c.demo_audio || c.sample_url || c.preview_url || null, }))); }) .catch(e => setClonesError(String(e.message || e))) .finally(() => setLoadingClones(false)); }, []); function loadAvatars(channelId) { if (!channelId) { setAvatars([]); setLoadingAvatars(false); return; } setLoadingAvatars(true); fetch(`/api/channels/${channelId}/avatars`, { credentials: "same-origin" }) .then(async (r) => { if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `HTTP ${r.status}`); } return r.json(); }) .then(d => { setAvatars(d.avatars || []); setAvatarsError(""); }) .catch(e => setAvatarsError(String(e.message || e))) .finally(() => setLoadingAvatars(false)); } React.useEffect(() => { loadAvatars(selected); }, [selected]); async function uploadAvatar(file) { if (!file || !selected) return; setUploadingAvatar(true); setAvatarsError(""); const fd = new FormData(); fd.append("avatar", file); try { const r = await fetch(`/api/channels/${selected}/avatars`, { method: "POST", credentials: "same-origin", body: fd, }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`); loadAvatars(selected); // La galería es por canal y vive solo aquí, pero channel.avatar_url (el que // usan el resto de pestañas: Video Avatar, Creación en lista…) se actualiza en // el servidor al subir/marcar por defecto — sin este refresh, el resto de la // app se quedaba con el avatar_url viejo (o vacío) hasta recargar la página. if (refreshChannels) refreshChannels(); } catch (e) { setAvatarsError(String(e.message || e)); } setUploadingAvatar(false); } async function setDefaultAvatar(avatarId) { try { const r = await fetch(`/api/channels/${selected}/avatars/${avatarId}/default`, { method: "POST", credentials: "same-origin", }); if (!r.ok) throw new Error(); loadAvatars(selected); if (refreshChannels) refreshChannels(); } catch { setAvatarsError("No se pudo marcar como avatar por defecto"); } } async function deleteAvatar(avatarId) { if (!confirm("¿Eliminar esta foto de avatar?")) return; try { const r = await fetch(`/api/channels/${selected}/avatars/${avatarId}`, { method: "DELETE", credentials: "same-origin", }); if (!r.ok) throw new Error(); loadAvatars(selected); if (refreshChannels) refreshChannels(); } catch { setAvatarsError("No se pudo eliminar la foto"); } } function fetchAi33Voices(page, append) { setAi33Loading(true); setAi33Error(""); const params = new URLSearchParams({ provider: ai33Provider, page: String(page), page_size: "20" }); if (ai33Query.trim()) params.set("search", ai33Query.trim()); fetch(`/api/voice/ai33-voices?${params.toString()}`, { credentials: "same-origin" }) .then(async (r) => { if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `HTTP ${r.status}`); } return r.json(); }) .then((d) => { setAi33Results((prev) => append ? [...prev, ...(d.data || [])] : (d.data || [])); setAi33HasMore(!!(d.pagination && d.pagination.has_more)); setAi33Page(page); }) .catch((e) => setAi33Error(String(e.message || e))) .finally(() => setAi33Loading(false)); } // Buscador de voces ai33: relanza al cambiar de proveedor o tras dejar de escribir (debounce) React.useEffect(() => { if (editVoiceMode !== "ai33") return; const t = setTimeout(() => fetchAi33Voices(1, false), 400); return () => clearTimeout(t); }, [editVoiceMode, ai33Provider, ai33Query]); function playAi33Preview(url, id) { if (ai33AudioRef.current) ai33AudioRef.current.pause(); if (ai33PlayingId === id) { setAi33PlayingId(null); return; } const a = new Audio(url); ai33AudioRef.current = a; a.play().catch(() => {}); setAi33PlayingId(id); a.onended = () => setAi33PlayingId(null); } React.useEffect(() => { const ch = channels.find(c => c.id === selected); if (!ch) { setEditVoiceMode("manual"); return; } const isOwnClone = cloneVoices.some(v => v.voice_id === ch.voiceId); setEditVoiceMode(isOwnClone && ch.voiceId ? "favorite" : "manual"); }, [selected, cloneVoices]); const sel = channels.find((c) => c.id === selected); function updateSel(patch) { setChannels(channels.map((c) => c.id === selected ? { ...c, ...patch } : c)); setDirty(true); setSaveError(""); } const inactiveChannels = channels.filter((c) => c.active === false); // Si el canal seleccionado está apagado, abre el desplegable para que se vea. React.useEffect(() => { if (inactiveChannels.some((c) => c.id === selected)) setShowInactive(true); }, [selected]); function renderChannelRow(c) { const isActive = c.active !== false; return ( ); } function handleSelectChannel(id) { setEditingName(false); if (dirty && id !== selected) { // Discard unsaved changes before switching if (cleanRef.current) { setChannels((prev) => prev.map((c) => c.id === selected ? cleanRef.current : c)); } setDirty(false); setSaveError(""); } setSelected(id); } async function saveChanges() { const ch = channels.find((c) => c.id === selected); if (!ch) return; setSaving(true); setSaveError(""); try { const body = window.denormalizeChannel ? window.denormalizeChannel(ch) : {}; const r = await fetch(`/api/channels/${selected}`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (r.ok) { cleanRef.current = JSON.parse(JSON.stringify(ch)); setDirty(false); } else { setSaveError("Error al guardar. Inténtalo de nuevo."); } } catch { setSaveError("Error de conexión."); } finally { setSaving(false); } } function cancelChanges() { if (cleanRef.current) { setChannels((prev) => prev.map((c) => c.id === selected ? cleanRef.current : c)); } setDirty(false); setSaveError(""); } async function deleteChannel(channelId) { if (!confirm("¿Eliminar este canal? Esta acción no se puede deshacer.")) return; try { await fetch(`/api/channels/${channelId}`, { method: "DELETE", credentials: "same-origin" }); await refreshChannels(); setSelected(channels.find((c) => c.id !== channelId)?.id || null); } catch { /* ignore */ } } async function toggleActive() { if (!sel) return; const newActive = !sel.active; const updatedCh = { ...sel, active: newActive }; setChannels(channels.map((c) => c.id === selected ? updatedCh : c)); cleanRef.current = updatedCh; setDirty(false); const body = window.denormalizeChannel ? window.denormalizeChannel(updatedCh) : {}; try { await fetch(`/api/channels/${selected}`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } catch { /* ignore */ } } async function onLogoUpload(e) { const file = e.target.files && e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append("watermark", file); try { const r = await fetch("/api/upload-watermark", { method: "POST", credentials: "same-origin", body: formData }); const d = await r.json(); if (d.success) { const updatedCh = { ...sel, logo: d.url, _raw: { ...(sel._raw || {}), watermark_path: d.path } }; setChannels(channels.map((c) => c.id === selected ? updatedCh : c)); const body = window.denormalizeChannel ? window.denormalizeChannel(updatedCh) : {}; body.watermark_path = d.path; await fetch(`/api/channels/${selected}`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } } catch { /* ignore */ } } if (!sel) return (
Sin canales
{showNewModal && setShowNewModal(false)} onCreated={async (id) => { await refreshChannels(); setSelected(id); }}/>}
); return (
{showNewModal && setShowNewModal(false)} onCreated={async (id) => { await refreshChannels(); setSelected(id); }}/>} {/* List */}
{channels.length} / {channelLimit} CANALES
{channels.filter((c) => c.active !== false).map(renderChannelRow)} {inactiveChannels.length > 0 && ( <> {showInactive && inactiveChannels.map(renderChannelRow)} )}
{/* Detail */}
{/* Header card */}
{sel.avatarUrl ? : sel.logo ? : sel.initials}
{editingName ? ( { const n = e.target.value; updateSel({ name: n, initials: window.getInitials ? window.getInitials(n) : n.slice(0, 2).toUpperCase() }); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === "Escape") setEditingName(false); }} onBlur={() => setEditingName(false)} placeholder="Nombre del canal" style={{ fontSize: 18, fontWeight: 600, maxWidth: 340, padding: "4px 10px" }} /> ) : ( <>

{sel.name}

)} {sel.active !== false ? Activo : Inactivo}
youtube.com/@{sel.handle}
{/* Guionista */}

Rol del guionista

Instrucciones que recibe Claude al generar el guion para este canal.

System prompt