/* 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.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
);
}
// ── 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 && (
)}
{/* 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 });