feat: Integrar LiveKit y actualizar URLs en el panel de transmisión y API
This commit is contained in:
parent
f57ce90c11
commit
396a803b1c
1
package-lock.json
generated
1
package-lock.json
generated
@ -11875,6 +11875,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"livekit-server-sdk": "^2.14.0",
|
||||
"socket.io": "^4.6.2",
|
||||
"stripe": "^14.9.0",
|
||||
"winston": "^3.11.0"
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"livekit-server-sdk": "^2.14.0",
|
||||
"socket.io": "^4.6.2",
|
||||
"stripe": "^14.9.0",
|
||||
"winston": "^3.11.0"
|
||||
|
||||
@ -15,6 +15,9 @@ const allowedOrigins = process.env.FRONTEND_URLS?.split(',') || ['http://localho
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
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: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({
|
||||
@ -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)
|
||||
app.get('/api/v1/livekit/rooms', (req, res) => {
|
||||
const roomName = typeof req.query.roomName === 'string' ? req.query.roomName : undefined;
|
||||
|
||||
@ -15,7 +15,7 @@ const Studio: React.FC = () => {
|
||||
localStorage.setItem('avanzacast_room', roomName)
|
||||
|
||||
// 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
|
||||
}, [])
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
|
||||
|
||||
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 tokenRes = await fetch(tokenUrl)
|
||||
if (!tokenRes.ok) throw new Error('No se pudo obtener token')
|
||||
|
||||
@ -37,6 +37,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5175,
|
||||
host: true,
|
||||
allowedHosts: [
|
||||
'localhost',
|
||||
'.easypanel.host',
|
||||
'avanzacast-broadcastpanel.bfzqqk.easypanel.host'
|
||||
],
|
||||
fs: {
|
||||
// Allow serving files from the shared folder when mounted in Docker
|
||||
allow: [
|
||||
|
||||
@ -7,108 +7,307 @@ import {
|
||||
MdQrCode,
|
||||
MdTimer,
|
||||
MdSettings,
|
||||
MdClose,
|
||||
MdPeople,
|
||||
MdChat,
|
||||
MdComment,
|
||||
} from 'react-icons/md'
|
||||
import { COLOR_THEMES, DEMO_OVERLAYS, DEMO_BACKGROUNDS, DEMO_SOUNDS } from '../config/demo'
|
||||
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 [activeTab, setActiveTab] = useState<TabType>('brand')
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'brand' as TabType, icon: MdBrush, label: 'Marca' },
|
||||
{ id: 'multimedia' as TabType, icon: MdImage, label: 'Multimedia' },
|
||||
{ id: 'participants' as TabType, icon: MdPeople, label: 'Personas' },
|
||||
{ id: 'sounds' as TabType, icon: MdMusicNote, label: 'Sonidos' },
|
||||
{ id: 'video' as TabType, icon: MdVideoLibrary, label: 'Videos' },
|
||||
{ id: 'qr' as TabType, icon: MdQrCode, label: 'QR' },
|
||||
{ id: 'countdown' as TabType, icon: MdTimer, label: 'Cuenta regresiva' },
|
||||
{ id: 'settings' as TabType, icon: MdSettings, label: 'Ajustes' },
|
||||
{ id: 'comments' as TabType, icon: MdComment, label: 'Comentarios', badge: 0 },
|
||||
{ id: 'banners' as TabType, icon: MdImage, label: 'Banners', badge: 0 },
|
||||
{ id: 'brand' as TabType, icon: MdBrush, label: 'Activos multimedia', badge: 0 },
|
||||
{ id: 'style' as TabType, icon: MdBrush, label: 'Estilo', badge: 0 },
|
||||
{ id: 'notes' as TabType, icon: MdTimer, label: 'Notas', badge: 0 },
|
||||
{ id: 'participants' as TabType, icon: MdPeople, label: 'Personas', badge: 0 },
|
||||
{ id: 'chat' as TabType, icon: MdChat, label: 'Chat privado', badge: 0 },
|
||||
]
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="w-12 bg-gray-800 border-l border-gray-700 flex flex-col items-center py-4 gap-2">
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
|
||||
|
||||
{/* 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) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id)
|
||||
setIsCollapsed(false)
|
||||
}}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
||||
title={tab.label}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`right-panel-content-${tab.id}`}
|
||||
id={`right-panel-tab-${tab.id}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// Tab de Marca
|
||||
const BrandTab = () => {
|
||||
// Tab de Comentarios (Streamyard style)
|
||||
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])
|
||||
|
||||
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 [logoPosition, setLogoPosition] = useState('top-right')
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@ -121,324 +320,87 @@ const BrandTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
setLogoUrl(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Tema de Color</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{COLOR_THEMES.map((theme) => (
|
||||
<button
|
||||
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 className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold text-sm">Marca 1</h3>
|
||||
<p className="text-gray-400 text-xs mt-0.5">Activos multimedia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors">
|
||||
<MdSettings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700 rounded-lg p-4 aspect-square flex items-center justify-center">
|
||||
<p className="text-gray-400 text-sm text-center">El código QR aparecerá aquí</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Tab de Cuenta Regresiva
|
||||
const CountdownTab = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Temporizadores Rápidos</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['30s', '1m', '5m', '10m', '15m', '30m'].map((time) => (
|
||||
<button
|
||||
key={time}
|
||||
className="bg-gray-700 hover:bg-pink-500 text-white py-2 px-3 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{time}
|
||||
</button>
|
||||
))}
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Logo section */}
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Logo</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{logoUrl && (
|
||||
<div className="aspect-square bg-gray-700 rounded-lg p-2 flex items-center justify-center border-2 border-pink-500">
|
||||
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
|
||||
</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">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<MdImage size={24} className="text-gray-500" />
|
||||
<span className="text-gray-500 text-xs">Subir logo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Personalizado</h4>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Minutos"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Fondos section */}
|
||||
<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={20} className="text-gray-500" />
|
||||
<span className="text-gray-500 text-xs">Subir</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Tab de Ajustes
|
||||
const SettingsTab = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Calidad de Video</h4>
|
||||
<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">
|
||||
<option>1080p (Full HD)</option>
|
||||
<option>720p (HD)</option>
|
||||
<option>480p (SD)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Bitrate</h4>
|
||||
<input
|
||||
type="range"
|
||||
min="1000"
|
||||
max="6000"
|
||||
step="500"
|
||||
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>
|
||||
{/* Overlays section */}
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Overlays</h4>
|
||||
<div className="space-y-2">
|
||||
{DEMO_OVERLAYS.slice(0, 3).map((overlay) => (
|
||||
<button
|
||||
key={overlay.id}
|
||||
className="w-full p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-left"
|
||||
>
|
||||
<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>
|
||||
<MdImage className="text-pink-500" size={20} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { LoginCredentials, RegisterData, AuthResponse, ApiResponse } from '@avanzacast/shared-types';
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user