/* MisVocesTab — v3 — Biblioteca con sidebar + tarjetas grandes */ // ── module-level helpers ────────────────────────────────────────────────────── const _MV_PALETTES = [ ["#7c5cff","#3a1fd1"],["#f87171","#b91c1c"],["#60a5fa","#1d4ed8"], ["#4ade80","#15803d"],["#fbbf24","#b45309"],["#a78bfa","#6d28d9"], ["#fb923c","#c2410c"],["#34d399","#065f46"],["#f472b6","#9d174d"], ]; function mvGradient(name) { let h = 0; for (let i = 0; i < (name || "").length; i++) h = (h * 31 + (name || "").charCodeAt(i)) & 0x7fffffff; const [c1, c2] = _MV_PALETTES[h % _MV_PALETTES.length]; return `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`; } const _MV_LANG = { spanish:"ES", español:"ES", english:"EN", inglés:"EN", portuguese:"PT", português:"PT", french:"FR", français:"FR", german:"DE", deutsch:"DE", japanese:"JP", japonés:"JP", chinese:"ZH", chino:"ZH", korean:"KR", coreano:"KR", italian:"IT", italiano:"IT", }; function mvLangBadges(tags) { return [...new Set((tags || []).map(t => _MV_LANG[(t || "").toLowerCase().trim()]).filter(Boolean))]; } const MV_FOLDERS = [ { key: "all", label: "Todas las voces" }, { key: "clones", label: "Mis clones" }, { key: "narr", label: "Narradores", match: ["narration","narrator","narrador"] }, { key: "cine", label: "Cine & Tráiler", match: ["cinema","cine","trailer","dramatic","film"] }, { key: "podcast", label: "Podcast & Meditación", match: ["podcast","meditation","meditative","calm","relax"] }, { key: "favs", label: "Favoritos" }, ]; function mvInFolder(voice, key) { if (key === "all") return true; if (key === "clones") return !!voice.is_clone; if (key === "favs") return !!voice.is_favorite; const f = MV_FOLDERS.find(x => x.key === key); return f && f.match ? (voice.tags || []).some(t => f.match.includes((t || "").toLowerCase().trim())) : true; } function MvHeart({ filled, size = 14 }) { return ( ); } // ── VoiceCard ───────────────────────────────────────────────────────────────── function VoiceCard({ voice, isAdmin, onFavorite, onDelete, onPlay, onUse, playingId }) { const vid = voice.voice_id; const active = playingId === vid; const isFav = !!voice.is_favorite; const langs = mvLangBadges(voice.tags); const bg = mvGradient(voice.voice_name); const canDel = isAdmin || !!voice.is_clone; const showFav = true; return (
{/* ── Cover ── */}
{voice.cover_url ? {voice.voice_name} :
} {voice.sample_url && ( )} {voice.is_clone && ( TÚ )}
{/* ── Body ── */}
{/* Name + lang badges */}
{voice.voice_name} {langs.map(l => ( {l} ))}
{/* Description */} {voice.description && (
{voice.description}
)} {/* Actions */}
{showFav && ( )} {canDel && ( )}
); } // ── AddVoiceModal (admin only) ──────────────────────────────────────────────── function AddVoiceModal({ onClose, onAdded }) { const [voiceId, setVoiceId] = React.useState(""); const [voiceName, setVoiceName] = React.useState(""); const [coverUrl, setCoverUrl] = React.useState(""); const [sampleUrl, setSampleUrl] = React.useState(""); const [description, setDescription] = React.useState(""); const [tagsInput, setTagsInput] = React.useState(""); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); async function handleAdd(e) { e.preventDefault(); if (!voiceId.trim() || !voiceName.trim()) { setError("Voice ID y nombre son obligatorios"); return; } setLoading(true); setError(""); const tags = tagsInput.split(",").map(t => t.trim()).filter(Boolean); try { const r = await fetch("/api/public-voices", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ voice_id: voiceId.trim(), voice_name: voiceName.trim(), cover_url: coverUrl.trim(), sample_url: sampleUrl.trim(), description: description.trim(), tags }), }); const d = await r.json(); if (r.ok && d.success) { onAdded(); onClose(); } else setError(d.detail || "Error al añadir voz"); } catch { setError("Error de conexión"); } finally { setLoading(false); } } return (
e.stopPropagation()}>

Añadir voz pública

setVoiceId(e.target.value)} placeholder="ai33_voice_xxx" style={{ fontFamily: "var(--font-mono)", fontSize: 12 }} autoFocus/>
setVoiceName(e.target.value)} placeholder="Narrador masculino"/>
setCoverUrl(e.target.value)} placeholder="https://…"/>
setSampleUrl(e.target.value)} placeholder="https://…/sample.mp3"/>
setDescription(e.target.value)} placeholder="Voz masculina grave, tono cinematográfico"/>
setTagsInput(e.target.value)} placeholder="Spanish, Male, Narration"/>
{error && (
{error}
)}
); } // ── MisVocesTab ─────────────────────────────────────────────────────────────── function MisVocesTab({ currentUser, onNavigate }) { const [presetVoices, setPresetVoices] = React.useState([]); const [cloneVoices, setCloneVoices] = React.useState([]); const [loading, setLoading] = React.useState(true); const [folder, setFolder] = React.useState("all"); const [search, setSearch] = React.useState(""); const [playingId, setPlayingId] = React.useState(null); const [copied, setCopied] = React.useState(false); const [showAdd, setShowAdd] = React.useState(false); const audioRef = React.useRef(null); const isAdmin = !!currentUser?.is_admin; React.useEffect(() => { loadAll(); }, []); async function loadAll() { setLoading(true); try { const [pr, cr, fr] = await Promise.all([ fetch("/api/public-voices", { credentials: "same-origin" }).then(r => r.json()), fetch("/api/voice/clones", { credentials: "same-origin" }).then(r => r.json()).catch(() => ({})), fetch("/api/user/voice-favorites", { credentials: "same-origin" }).then(r => r.json()).catch(() => ({})), ]); const favIds = new Set((fr.voices || []).map(v => v.voice_id)); setPresetVoices((pr.voices || []).map(v => ({ ...v, is_clone: false, is_favorite: v.is_favorite || favIds.has(v.voice_id) }))); const raw = cr.voice_list || cr.data || cr.voices || cr.clones || []; setCloneVoices(raw.map(c => ({ id: c.voice_id || c.id, voice_id: c.voice_id || c.id, voice_name: c.voice_name || c.name || "Clon sin nombre", description: [c.gender, c.language].filter(Boolean).join(" · ") || "Voz clonada", cover_url: null, sample_url: c.demo_audio || c.sample_url || c.preview_url || null, tags: [c.language, c.gender].filter(Boolean), is_favorite: favIds.has(c.voice_id || c.id), is_clone: true, }))); } catch (e) { console.error("Error cargando voces", e); } finally { setLoading(false); } } const allVoices = React.useMemo(() => [...presetVoices, ...cloneVoices], [presetVoices, cloneVoices]); const folderCounts = React.useMemo(() => { const out = {}; for (const f of MV_FOLDERS) out[f.key] = allVoices.filter(v => mvInFolder(v, f.key)).length; return out; }, [allVoices]); const filtered = React.useMemo(() => { let list = allVoices.filter(v => mvInFolder(v, folder)); if (search.trim()) { const q = search.toLowerCase(); list = list.filter(v => (v.voice_name || "").toLowerCase().includes(q) || (v.description || "").toLowerCase().includes(q) || (v.tags || []).some(t => (t || "").toLowerCase().includes(q)) ); } return list; }, [allVoices, folder, search]); async function handleFavorite(voice) { const voiceId = voice.voice_id; try { const r = await fetch(`/api/public-voices/${voiceId}/favorite`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ voice_name: voice.voice_name, is_clone: !!voice.is_clone }), }); const d = await r.json(); if (voice.is_clone) { setCloneVoices(prev => prev.map(v => v.voice_id === voiceId ? { ...v, is_favorite: d.favorited } : v)); } else { setPresetVoices(prev => prev.map(v => v.voice_id === voiceId ? { ...v, is_favorite: d.favorited } : v)); } } catch (e) { console.error(e); } } async function handleDelete(voice) { if (!confirm(`¿Eliminar la voz "${voice.voice_name}"?`)) return; try { if (voice.is_clone) { await fetch(`/api/voice/clones/${voice.voice_id}`, { method: "DELETE", credentials: "same-origin" }); setCloneVoices(prev => prev.filter(v => v.voice_id !== voice.voice_id)); } else { await fetch(`/api/public-voices/${voice.id}`, { method: "DELETE", credentials: "same-origin" }); setPresetVoices(prev => prev.filter(v => v.id !== voice.id)); } } catch (e) { console.error(e); } } function handleUse(voice) { if (navigator.clipboard) { navigator.clipboard.writeText(voice.voice_id).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } } function playPreview(url, id) { if (audioRef.current) audioRef.current.pause(); if (playingId === id) { setPlayingId(null); return; } const a = new Audio(url); audioRef.current = a; a.play().catch(() => {}); setPlayingId(id); a.onended = () => setPlayingId(null); } return (
{/* ── Sidebar ── */}
CARPETAS
{MV_FOLDERS.map(f => { const n = folderCounts[f.key] || 0; const on = folder === f.key; return ( ); })}
{/* ── Main ── */}
{/* Top bar */}
setSearch(e.target.value)}/>
{loading ? "…" : `${filtered.length} ${filtered.length === 1 ? "voz" : "voces"}`} {isAdmin && ( )}
{/* Loading */} {loading && (
Cargando voces…
)} {/* Empty library */} {!loading && allVoices.length === 0 && (
{isAdmin ? "No hay voces en la biblioteca" : "Biblioteca vacía"}
{isAdmin ? "Añade voces para que los usuarios puedan utilizarlas" : "El administrador aún no ha añadido voces"}
{isAdmin && ( )}
)} {/* No results */} {!loading && allVoices.length > 0 && filtered.length === 0 && (
No se encontraron voces
)} {/* Grid */} {!loading && filtered.length > 0 && (
{filtered.map(v => ( ))}
)}
{/* Modals */} {showAdd && setShowAdd(false)} onAdded={loadAll}/>} {/* Copy toast */} {copied && (
Voice ID copiado al portapapeles
)}
); } Object.assign(window, { MisVocesTab });