feat: Integrar LiveKit y actualizar URLs en el panel de transmisión y API

This commit is contained in:
Cesar Mendivil 2025-11-07 15:42:53 -07:00
parent f57ce90c11
commit 396a803b1c
8 changed files with 409 additions and 387 deletions

1
package-lock.json generated
View File

@ -11875,6 +11875,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.14.0",
"socket.io": "^4.6.2", "socket.io": "^4.6.2",
"stripe": "^14.9.0", "stripe": "^14.9.0",
"winston": "^3.11.0" "winston": "^3.11.0"

View File

@ -25,6 +25,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.14.0",
"socket.io": "^4.6.2", "socket.io": "^4.6.2",
"stripe": "^14.9.0", "stripe": "^14.9.0",
"winston": "^3.11.0" "winston": "^3.11.0"

View File

@ -15,6 +15,9 @@ const allowedOrigins = process.env.FRONTEND_URLS?.split(',') || ['http://localho
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
if (!allowedOrigins.includes('http://localhost:3020')) allowedOrigins.push('http://localhost:3020') if (!allowedOrigins.includes('http://localhost:3020')) allowedOrigins.push('http://localhost:3020')
if (!allowedOrigins.includes('http://localhost:3021')) allowedOrigins.push('http://localhost:3021') if (!allowedOrigins.includes('http://localhost:3021')) allowedOrigins.push('http://localhost:3021')
if (!allowedOrigins.includes('http://localhost:5175')) allowedOrigins.push('http://localhost:5175')
if (!allowedOrigins.includes('https://avanzacast-studio.bfzqqk.easypanel.host')) allowedOrigins.push('https://avanzacast-studio.bfzqqk.easypanel.host')
if (!allowedOrigins.includes('https://avanzacast-broadcastpanel.bfzqqk.easypanel.host')) allowedOrigins.push('https://avanzacast-broadcastpanel.bfzqqk.easypanel.host')
} }
app.use(cors({ app.use(cors({
@ -44,6 +47,56 @@ app.get('/api/v1', (req, res) => {
}); });
}); });
// LiveKit token generation endpoint
app.get('/api/token', async (req, res) => {
const { room, username } = req.query;
if (!room || typeof room !== 'string') {
return res.status(400).json({ error: 'Room name is required' });
}
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'Username is required' });
}
// TODO: Implement actual LiveKit token generation
// For now, return a placeholder response
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY;
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) {
console.error('⚠️ LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set in environment variables');
return res.status(500).json({ error: 'LiveKit credentials not configured' });
}
try {
// Import AccessToken from livekit-server-sdk
const { AccessToken } = await import('livekit-server-sdk');
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: username,
name: username,
});
at.addGrant({
room,
roomJoin: true,
canPublish: true,
canSubscribe: true,
});
const token = await at.toJwt();
return res.json({
token,
url: process.env.LIVEKIT_URL || 'ws://localhost:7880',
});
} catch (error) {
console.error('Error generating LiveKit token:', error);
return res.status(500).json({ error: 'Failed to generate token' });
}
});
// Minimal LiveKit-related endpoints (placeholder implementation) // Minimal LiveKit-related endpoints (placeholder implementation)
app.get('/api/v1/livekit/rooms', (req, res) => { app.get('/api/v1/livekit/rooms', (req, res) => {
const roomName = typeof req.query.roomName === 'string' ? req.query.roomName : undefined; const roomName = typeof req.query.roomName === 'string' ? req.query.roomName : undefined;

View File

@ -15,7 +15,7 @@ const Studio: React.FC = () => {
localStorage.setItem('avanzacast_room', roomName) localStorage.setItem('avanzacast_room', roomName)
// Redirigir al studio-panel (puerto 3001) // Redirigir al studio-panel (puerto 3001)
const studioUrl = `http://localhost:3001?user=${encodeURIComponent(userName)}&room=${encodeURIComponent(roomName)}` const studioUrl = `https://avanzacast-studio.bfzqqk.easypanel.host?user=${encodeURIComponent(userName)}&room=${encodeURIComponent(roomName)}`
window.location.href = studioUrl window.location.href = studioUrl
}, []) }, [])

View File

@ -61,7 +61,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
console.log('[BroadcastPanel] Solicitando token:', { room: decodeURIComponent(room), user: decodeURIComponent(user) }) console.log('[BroadcastPanel] Solicitando token:', { room: decodeURIComponent(room), user: decodeURIComponent(user) })
const TOKEN_SERVER = import.meta.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host' const TOKEN_SERVER = import.meta.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'
const tokenUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/token?room=${room}&username=${user}` const tokenUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/token?room=${room}&username=${user}`
const tokenRes = await fetch(tokenUrl) const tokenRes = await fetch(tokenUrl)
if (!tokenRes.ok) throw new Error('No se pudo obtener token') if (!tokenRes.ok) throw new Error('No se pudo obtener token')

View File

@ -37,6 +37,11 @@ export default defineConfig({
server: { server: {
port: 5175, port: 5175,
host: true, host: true,
allowedHosts: [
'localhost',
'.easypanel.host',
'avanzacast-broadcastpanel.bfzqqk.easypanel.host'
],
fs: { fs: {
// Allow serving files from the shared folder when mounted in Docker // Allow serving files from the shared folder when mounted in Docker
allow: [ allow: [

View File

@ -7,108 +7,307 @@ import {
MdQrCode, MdQrCode,
MdTimer, MdTimer,
MdSettings, MdSettings,
MdClose,
MdPeople, MdPeople,
MdChat,
MdComment,
} from 'react-icons/md' } from 'react-icons/md'
import { COLOR_THEMES, DEMO_OVERLAYS, DEMO_BACKGROUNDS, DEMO_SOUNDS } from '../config/demo' import { COLOR_THEMES, DEMO_OVERLAYS, DEMO_BACKGROUNDS, DEMO_SOUNDS } from '../config/demo'
import ParticipantsPanel from './ParticipantsPanel' import ParticipantsPanel from './ParticipantsPanel'
type TabType = 'brand' | 'multimedia' | 'participants' | 'sounds' | 'video' | 'qr' | 'countdown' | 'settings' type TabType = 'comments' | 'banners' | 'brand' | 'style' | 'notes' | 'participants' | 'chat'
const StudioRightPanel = ({ roomName }: { roomName?: string }) => { const StudioRightPanel = ({ roomName }: { roomName?: string }) => {
const [activeTab, setActiveTab] = useState<TabType>('brand') const [activeTab, setActiveTab] = useState<TabType>('brand')
const [isCollapsed, setIsCollapsed] = useState(false)
const tabs = [ const tabs = [
{ id: 'brand' as TabType, icon: MdBrush, label: 'Marca' }, { id: 'comments' as TabType, icon: MdComment, label: 'Comentarios', badge: 0 },
{ id: 'multimedia' as TabType, icon: MdImage, label: 'Multimedia' }, { id: 'banners' as TabType, icon: MdImage, label: 'Banners', badge: 0 },
{ id: 'participants' as TabType, icon: MdPeople, label: 'Personas' }, { id: 'brand' as TabType, icon: MdBrush, label: 'Activos multimedia', badge: 0 },
{ id: 'sounds' as TabType, icon: MdMusicNote, label: 'Sonidos' }, { id: 'style' as TabType, icon: MdBrush, label: 'Estilo', badge: 0 },
{ id: 'video' as TabType, icon: MdVideoLibrary, label: 'Videos' }, { id: 'notes' as TabType, icon: MdTimer, label: 'Notas', badge: 0 },
{ id: 'qr' as TabType, icon: MdQrCode, label: 'QR' }, { id: 'participants' as TabType, icon: MdPeople, label: 'Personas', badge: 0 },
{ id: 'countdown' as TabType, icon: MdTimer, label: 'Cuenta regresiva' }, { id: 'chat' as TabType, icon: MdChat, label: 'Chat privado', badge: 0 },
{ id: 'settings' as TabType, icon: MdSettings, label: 'Ajustes' },
] ]
if (isCollapsed) { return (
return ( <div className="flex h-full">
<div className="w-12 bg-gray-800 border-l border-gray-700 flex flex-col items-center py-4 gap-2">
{/* Content Panel - Aside como en Streamyard */}
<aside className="flex-1 bg-gray-800 flex flex-col" style={{ width: '320px' }}>
<div
id="right-panel-content"
role="tabpanel"
aria-labelledby={`right-panel-tab-${activeTab}`}
className="flex-1 flex flex-col h-full"
>
{activeTab === 'comments' && <CommentsTab />}
{activeTab === 'banners' && <BannersTab />}
{activeTab === 'brand' && <BrandTab />}
{activeTab === 'style' && <StyleTab />}
{activeTab === 'notes' && <NotesTab />}
{activeTab === 'participants' && <ParticipantsPanel roomName={roomName} />}
{activeTab === 'chat' && <ChatTab />}
</div>
</aside>
{/* Tabs Column - Vertical tabs como en Streamyard */}
<div role="tablist" className="w-20 bg-gray-800 border-l border-gray-700 flex flex-col">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon const Icon = tab.icon
const isActive = activeTab === tab.id
return ( return (
<button <button
key={tab.id} key={tab.id}
onClick={() => { role="tab"
setActiveTab(tab.id) aria-selected={isActive}
setIsCollapsed(false) aria-controls={`right-panel-content-${tab.id}`}
}} id={`right-panel-tab-${tab.id}`}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-700 transition-colors" onClick={() => setActiveTab(tab.id)}
title={tab.label} className={`relative flex flex-col items-center gap-1 py-4 px-2 border-r-2 transition-all ${
isActive
? 'bg-gray-700 border-pink-500 text-pink-500'
: 'border-transparent text-gray-400 hover:text-white hover:bg-gray-750'
}`}
> >
<Icon size={20} /> <div className="relative">
<Icon size={24} />
{tab.badge > 0 && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-pink-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold">{tab.badge}</span>
</div>
)}
</div>
<span className="text-xs font-medium text-center leading-tight">
{tab.label}
</span>
</button> </button>
) )
})} })}
</div> </div>
)
}
return (
<div className="w-80 h-full bg-gray-800 border-l border-gray-700 flex flex-col">
{/* Header con tabs */}
<div className="border-b border-gray-700">
<div className="flex items-center justify-between px-3 py-2">
<h3 className="text-white font-semibold text-sm">Configuración</h3>
<button
onClick={() => setIsCollapsed(true)}
className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
>
<MdClose size={18} />
</button>
</div>
{/* Tabs */}
<div className="flex overflow-x-auto scrollbar-thin scrollbar-thumb-gray-700">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'text-pink-500 border-pink-500'
: 'text-gray-400 border-transparent hover:text-white'
}`}
>
<Icon size={16} />
<span className="hidden xl:inline">{tab.label}</span>
</button>
)
})}
</div>
</div>
{/* Contenido de tabs */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'brand' && <BrandTab />}
{activeTab === 'multimedia' && <MultimediaTab />}
{activeTab === 'participants' && <ParticipantsPanel roomName={roomName} />}
{activeTab === 'sounds' && <SoundsTab />}
{activeTab === 'video' && <VideoTab />}
{activeTab === 'qr' && <QRTab />}
{activeTab === 'countdown' && <CountdownTab />}
{activeTab === 'settings' && <SettingsTab />}
</div>
</div> </div>
) )
} }
// Tab de Marca // Tab de Comentarios (Streamyard style)
const BrandTab = () => { const CommentsTab = () => {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm mb-2">Comentarios</h3>
<p className="text-gray-400 text-xs">
Puedes destacar comentarios importantes para que recuerdes comentarlos durante el programa.
</p>
</div>
{/* Empty state */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<MdComment size={48} className="mx-auto text-gray-600 mb-3" />
<p className="text-gray-400 text-sm">No hay comentarios destacados</p>
<button className="mt-3 text-pink-500 text-xs hover:underline">
Agrega un destino para publicar comentarios
</button>
</div>
</div>
{/* Input area */}
<div className="p-4 border-t border-gray-700">
<div className="bg-gray-700 rounded-lg p-3">
<textarea
placeholder="Escribe un comentario..."
disabled
className="w-full bg-transparent text-gray-500 text-sm resize-none outline-none"
rows={2}
/>
<div className="flex justify-end mt-2">
<button
disabled
className="px-3 py-1.5 bg-gray-600 text-gray-400 rounded text-xs font-medium cursor-not-allowed"
>
Enviar
</button>
</div>
</div>
</div>
</div>
)
}
// Tab de Banners (Streamyard style)
const BannersTab = () => {
const [banners] = useState([
{ id: '1', text: 'Este es un ejemplo de banner. Haz clic en un banner para mostrarlo en la pantalla.' },
{ id: '2', text: 'Utiliza banners para resumir los temas de los que estás hablando y mostrar llamadas a la acción' },
{ id: '3', text: 'Banner' },
])
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<h3 className="text-white font-semibold text-sm">Banners de ejemplo</h3>
<button className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors">
<MdSettings size={18} />
</button>
</div>
{/* Banners list */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{banners.map((banner) => (
<div
key={banner.id}
className="bg-gray-700 rounded-lg p-3 hover:bg-gray-600 transition-colors cursor-pointer group relative"
>
<div className="flex items-start gap-3">
<button className="flex-shrink-0 px-3 py-1 bg-pink-500 hover:bg-pink-600 text-white text-xs rounded font-medium transition-colors">
Mostrar
</button>
<p className="flex-1 text-gray-300 text-sm">{banner.text}</p>
</div>
{/* Hover actions */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 bg-gray-800 rounded hover:bg-gray-700 text-gray-400 hover:text-white">
<MdBrush size={14} />
</button>
<button className="p-1.5 bg-gray-800 rounded hover:bg-gray-700 text-red-400 hover:text-red-300">
<MdTimer size={14} />
</button>
</div>
</div>
))}
</div>
{/* Add banner button */}
<div className="p-3 border-t border-gray-700">
<button className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
<MdImage size={18} />
Crear un banner
</button>
</div>
</div>
)
}
// Tab de Notas
const NotesTab = () => {
const [notes, setNotes] = useState('')
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm">Notas</h3>
</div>
{/* Notes editor */}
<div className="flex-1 p-4">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Escribe tus notas aquí..."
className="w-full h-full bg-gray-700 text-white p-3 rounded-lg text-sm resize-none outline-none border border-gray-600 focus:border-pink-500"
/>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700">
<div className="flex items-center justify-between text-xs text-gray-400">
<span>{notes.length} caracteres</span>
<button className="text-pink-500 hover:underline">Limpiar</button>
</div>
</div>
</div>
)
}
// Tab de Chat Privado
const ChatTab = () => {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm">Chat Privado</h3>
<p className="text-gray-400 text-xs mt-1">
Chatea con los participantes sin que se vea en el stream
</p>
</div>
{/* Empty state */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<MdChat size={48} className="mx-auto text-gray-600 mb-3" />
<p className="text-gray-400 text-sm">No hay mensajes</p>
<p className="text-gray-500 text-xs mt-2">
Los mensajes aparecerán aquí cuando haya participantes
</p>
</div>
</div>
{/* Input area */}
<div className="p-4 border-t border-gray-700">
<div className="bg-gray-700 rounded-lg p-3">
<input
type="text"
placeholder="Escribe un mensaje..."
disabled
className="w-full bg-transparent text-gray-500 text-sm outline-none"
/>
</div>
</div>
</div>
)
}
// Tab de Estilo (nuevo - reemplaza parte de Brand)
const StyleTab = () => {
const [selectedTheme, setSelectedTheme] = useState(COLOR_THEMES[0]) const [selectedTheme, setSelectedTheme] = useState(COLOR_THEMES[0])
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm">Estilo</h3>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Tema de Color</h4>
<div className="grid grid-cols-2 gap-2">
{COLOR_THEMES.map((theme) => (
<button
key={theme.id}
onClick={() => setSelectedTheme(theme)}
className={`p-3 rounded-lg border-2 transition-all ${
selectedTheme.id === theme.id
? 'border-pink-500 bg-gray-700'
: 'border-gray-700 hover:border-gray-500'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-full"
style={{ backgroundColor: theme.primary }}
/>
<span className="text-white text-xs font-medium">{theme.name}</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
)
}
// Tab de Marca/Multimedia (Brand) - Activos multimedia
const BrandTab = () => {
const [logoUrl, setLogoUrl] = useState<string | null>(null) const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [logoPosition, setLogoPosition] = useState('top-right')
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@ -121,324 +320,87 @@ const BrandTab = () => {
} }
} }
const handleRemoveLogo = () => {
setLogoUrl(null)
}
return ( return (
<div className="space-y-4"> <div className="flex flex-col h-full">
<div> {/* Header */}
<h4 className="text-white text-sm font-semibold mb-3">Tema de Color</h4> <div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div className="grid grid-cols-3 gap-2"> <div>
{COLOR_THEMES.map((theme) => ( <h3 className="text-white font-semibold text-sm">Marca 1</h3>
<button <p className="text-gray-400 text-xs mt-0.5">Activos multimedia</p>
key={theme.id}
onClick={() => setSelectedTheme(theme)}
className={`px-3 py-2 rounded-lg border-2 transition-all ${
selectedTheme.id === theme.id
? 'border-white bg-gray-700'
: 'border-gray-700 hover:border-gray-500'
}`}
style={{
backgroundColor: theme.primary + '20',
borderColor: selectedTheme.id === theme.id ? theme.primary : undefined
}}
>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: theme.primary }}
/>
<span className="text-white text-xs font-medium">{theme.name}</span>
</div>
</button>
))}
</div> </div>
</div> <button className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors">
<MdSettings size={18} />
<div>
<h4 className="text-white text-sm font-semibold mb-3">Logo</h4>
{logoUrl ? (
<div className="relative border-2 border-gray-700 rounded-lg p-4 bg-gray-700/50">
<img
src={logoUrl}
alt="Logo"
className="max-h-24 mx-auto object-contain"
/>
<button
onClick={handleRemoveLogo}
className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white p-1.5 rounded-full transition-colors"
title="Eliminar logo"
>
<MdClose size={16} />
</button>
<p className="text-gray-400 text-xs text-center mt-2">Logo cargado</p>
</div>
) : (
<label className="block border-2 border-dashed border-gray-700 rounded-lg p-8 text-center hover:border-pink-500 transition-colors cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<MdImage size={32} className="mx-auto text-gray-500 mb-2" />
<p className="text-gray-400 text-xs">Haz clic para subir logo</p>
<p className="text-gray-500 text-xs mt-1">PNG, JPG, SVG (máx. 2MB)</p>
</label>
)}
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Posición del Logo</h4>
<div className="grid grid-cols-3 gap-2">
{[
{ pos: 'top-left', label: 'Superior Izq.' },
{ pos: 'top-center', label: 'Superior Centro' },
{ pos: 'top-right', label: 'Superior Der.' },
{ pos: 'center-left', label: 'Centro Izq.' },
{ pos: 'center', label: 'Centro' },
{ pos: 'center-right', label: 'Centro Der.' },
{ pos: 'bottom-left', label: 'Inferior Izq.' },
{ pos: 'bottom-center', label: 'Inferior Centro' },
{ pos: 'bottom-right', label: 'Inferior Der.' }
].map((item) => (
<button
key={item.pos}
onClick={() => setLogoPosition(item.pos)}
className={`aspect-square rounded-lg transition-all flex items-center justify-center ${
logoPosition === item.pos
? 'bg-pink-500/30 border-2 border-pink-500'
: 'bg-gray-700 border-2 border-transparent hover:bg-gray-600'
}`}
title={item.label}
>
<div className={`w-2 h-2 rounded-full ${
logoPosition === item.pos ? 'bg-pink-500' : 'bg-gray-400'
}`} />
</button>
))}
</div>
{logoUrl && (
<p className="text-gray-400 text-xs mt-2 text-center">
Posición: {logoPosition.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</p>
)}
</div>
</div>
)
}
// Tab de Multimedia
const MultimediaTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Fondos</h4>
<div className="grid grid-cols-2 gap-2">
{DEMO_BACKGROUNDS.map((bg) => (
<button
key={bg.id}
className="aspect-video rounded-lg hover:ring-2 hover:ring-pink-500 transition cursor-pointer overflow-hidden group relative"
style={{ background: bg.gradient }}
>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<span className="text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
{bg.name}
</span>
</div>
</button>
))}
<button className="aspect-video border-2 border-dashed border-gray-700 rounded-lg flex flex-col items-center justify-center gap-1 hover:border-pink-500 transition">
<MdImage size={24} className="text-gray-500" />
<span className="text-gray-500 text-xs">Subir fondo</span>
</button>
</div>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Overlays</h4>
<div className="space-y-2">
{DEMO_OVERLAYS.map((overlay) => (
<button
key={overlay.id}
className="w-full p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-left group"
>
<div className="flex items-center justify-between">
<div>
<p className="text-white text-sm font-medium">{overlay.name}</p>
<p className="text-gray-400 text-xs">{overlay.type}</p>
</div>
<div className="w-8 h-8 bg-pink-500/20 rounded flex items-center justify-center">
<MdImage className="text-pink-500" size={16} />
</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}
// Tab de Sonidos
const SoundsTab = () => {
const [volumes, setVolumes] = useState<Record<string, number>>(
DEMO_SOUNDS.reduce((acc, sound) => ({ ...acc, [sound.id]: 50 }), {})
)
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Efectos de Sonido</h4>
<div className="space-y-2">
{DEMO_SOUNDS.map((sound) => (
<div key={sound.id} className="bg-gray-700 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<button className="flex items-center gap-2 text-white hover:text-pink-500 transition-colors">
<div className="w-8 h-8 bg-pink-500/20 rounded-full flex items-center justify-center">
<MdMusicNote className="text-pink-500" size={16} />
</div>
<span className="text-sm font-medium">{sound.name}</span>
</button>
<span className="text-gray-400 text-xs">{volumes[sound.id]}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={volumes[sound.id]}
onChange={(e) => setVolumes({ ...volumes, [sound.id]: parseInt(e.target.value) })}
className="w-full h-1.5 bg-gray-600 rounded-lg appearance-none cursor-pointer"
/>
</div>
))}
</div>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Música de Fondo</h4>
<div className="border-2 border-dashed border-gray-700 rounded-lg p-6 text-center hover:border-pink-500 transition-colors cursor-pointer">
<MdMusicNote size={28} className="mx-auto text-gray-500 mb-2" />
<p className="text-gray-400 text-xs">Haz clic para agregar música</p>
</div>
</div>
</div>
)
}
// Tab de Videos
const VideoTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Clips de Video</h4>
<div className="grid grid-cols-2 gap-2">
{['Intro', 'Outro', 'Transición'].map((clip) => (
<div key={clip} className="aspect-video bg-gray-700 rounded-lg hover:ring-2 hover:ring-pink-500 transition cursor-pointer flex items-center justify-center">
<div className="text-center">
<MdVideoLibrary size={24} className="mx-auto text-gray-500 mb-1" />
<p className="text-gray-400 text-xs">{clip}</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// Tab de QR
const QRTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Generar Código QR</h4>
<input
type="text"
placeholder="Ingresa URL"
className="w-full bg-gray-700 text-white py-2 px-3 rounded-lg text-sm border border-gray-600 focus:border-pink-500 focus:outline-none"
/>
<button className="w-full mt-2 bg-pink-500 hover:bg-pink-600 text-white py-2 px-3 rounded-lg text-sm font-medium transition-colors">
Generar QR
</button> </button>
</div> </div>
<div className="bg-gray-700 rounded-lg p-4 aspect-square flex items-center justify-center"> {/* Content */}
<p className="text-gray-400 text-sm text-center">El código QR aparecerá aquí</p> <div className="flex-1 overflow-y-auto p-4 space-y-6">
</div> {/* Logo section */}
</div> <div>
) <h4 className="text-white text-sm font-semibold mb-3">Logo</h4>
} <div className="grid grid-cols-2 gap-2">
{logoUrl && (
// Tab de Cuenta Regresiva <div className="aspect-square bg-gray-700 rounded-lg p-2 flex items-center justify-center border-2 border-pink-500">
const CountdownTab = () => { <img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
return ( </div>
<div className="space-y-4"> )}
<div> <label className="aspect-square border-2 border-dashed border-gray-700 rounded-lg flex flex-col items-center justify-center gap-1 hover:border-pink-500 transition cursor-pointer">
<h4 className="text-white text-sm font-semibold mb-3">Temporizadores Rápidos</h4> <input
<div className="grid grid-cols-3 gap-2"> type="file"
{['30s', '1m', '5m', '10m', '15m', '30m'].map((time) => ( accept="image/*"
<button onChange={handleLogoUpload}
key={time} className="hidden"
className="bg-gray-700 hover:bg-pink-500 text-white py-2 px-3 rounded-lg text-sm font-medium transition-colors" />
> <MdImage size={24} className="text-gray-500" />
{time} <span className="text-gray-500 text-xs">Subir logo</span>
</button> </label>
))} </div>
</div> </div>
</div>
<div> {/* Fondos section */}
<h4 className="text-white text-sm font-semibold mb-3">Personalizado</h4> <div>
<input <h4 className="text-white text-sm font-semibold mb-3">Fondos</h4>
type="number" <div className="grid grid-cols-2 gap-2">
placeholder="Minutos" {DEMO_BACKGROUNDS.map((bg) => (
className="w-full bg-gray-700 text-white py-2 px-3 rounded-lg text-sm border border-gray-600 focus:border-pink-500 focus:outline-none" <button
/> key={bg.id}
</div> className="aspect-video rounded-lg hover:ring-2 hover:ring-pink-500 transition cursor-pointer overflow-hidden group relative"
</div> style={{ background: bg.gradient }}
) >
} <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<span className="text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
{bg.name}
</span>
</div>
</button>
))}
<button className="aspect-video border-2 border-dashed border-gray-700 rounded-lg flex flex-col items-center justify-center gap-1 hover:border-pink-500 transition">
<MdImage size={20} className="text-gray-500" />
<span className="text-gray-500 text-xs">Subir</span>
</button>
</div>
</div>
// Tab de Ajustes {/* Overlays section */}
const SettingsTab = () => { <div>
return ( <h4 className="text-white text-sm font-semibold mb-3">Overlays</h4>
<div className="space-y-4"> <div className="space-y-2">
<div> {DEMO_OVERLAYS.slice(0, 3).map((overlay) => (
<h4 className="text-white text-sm font-semibold mb-3">Calidad de Video</h4> <button
<select className="w-full bg-gray-700 text-white py-2 px-3 rounded-lg text-sm border border-gray-600 focus:border-pink-500 focus:outline-none"> key={overlay.id}
<option>1080p (Full HD)</option> className="w-full p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-left"
<option>720p (HD)</option> >
<option>480p (SD)</option> <div className="flex items-center justify-between">
</select> <div>
</div> <p className="text-white text-sm font-medium">{overlay.name}</p>
<p className="text-gray-400 text-xs">{overlay.type}</p>
<div> </div>
<h4 className="text-white text-sm font-semibold mb-3">Bitrate</h4> <MdImage className="text-pink-500" size={20} />
<input </div>
type="range" </button>
min="1000" ))}
max="6000" </div>
step="500" </div>
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
/>
<p className="text-gray-400 text-xs mt-2">3500 kbps</p>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between">
<span className="text-gray-300 text-sm">Grabar automáticamente</span>
<input type="checkbox" className="form-checkbox h-4 w-4 text-pink-500 rounded" />
</label>
<label className="flex items-center justify-between">
<span className="text-gray-300 text-sm">Mostrar chat en vivo</span>
<input type="checkbox" className="form-checkbox h-4 w-4 text-pink-500 rounded" defaultChecked />
</label>
<label className="flex items-center justify-between">
<span className="text-gray-300 text-sm">Habilitar aplausos</span>
<input type="checkbox" className="form-checkbox h-4 w-4 text-pink-500 rounded" defaultChecked />
</label>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,7 @@
import type { LoginCredentials, RegisterData, AuthResponse, ApiResponse } from '@avanzacast/shared-types'; import type { LoginCredentials, RegisterData, AuthResponse, ApiResponse } from '@avanzacast/shared-types';
import { getAuthHeader } from './auth'; import { getAuthHeader } from './auth';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api/v1'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host/api/v1';
/** /**
* Cliente HTTP para hacer peticiones a la API * Cliente HTTP para hacer peticiones a la API