feat: Mejorar el panel de participantes y agregar funcionalidad de pestañas en el panel derecho
This commit is contained in:
parent
396a803b1c
commit
d3122d64f8
@ -57,62 +57,79 @@ const ParticipantsPanel: React.FC<ParticipantsPanelProps> = ({ roomName }) => {
|
|||||||
const isConnected = (identity: string) => liveParticipants.some((p: any) => p.identity === identity)
|
const isConnected = (identity: string) => liveParticipants.some((p: any) => p.identity === identity)
|
||||||
|
|
||||||
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-2">Conectados</h4>
|
<div className="p-4 border-b border-gray-700">
|
||||||
{liveParticipants.length === 0 ? (
|
<h3 className="text-white font-semibold text-sm">Participantes</h3>
|
||||||
<p className="text-gray-400 text-xs">No hay participantes conectados.</p>
|
<p className="text-gray-400 text-xs mt-1">
|
||||||
) : (
|
Gestiona quién está en el stream
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
{liveParticipants.map((p: any) => (
|
|
||||||
<div key={p.sid} className="flex items-center justify-between bg-gray-700 p-2 rounded-lg">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-pink-500 to-red-500 flex items-center justify-center text-white">
|
|
||||||
{p.identity?.charAt(0)?.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-white truncate">{p.identity}</p>
|
|
||||||
<p className="text-xs text-gray-400">{p.isLocal ? 'Tú (presentador)' : 'Invitado'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button className="text-xs px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded text-white">Silenciar</button>
|
|
||||||
<button className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-white">Remover</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Content */}
|
||||||
<h4 className="text-white text-sm font-semibold mb-2">Invitados</h4>
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{loading ? (
|
<div>
|
||||||
<p className="text-gray-400 text-xs">Cargando...</p>
|
<h4 className="text-white text-sm font-semibold mb-3">Conectados</h4>
|
||||||
) : error ? (
|
{liveParticipants.length === 0 ? (
|
||||||
<p className="text-red-400 text-xs">Error: {error}</p>
|
<p className="text-gray-400 text-xs">No hay participantes conectados.</p>
|
||||||
) : invited.length === 0 ? (
|
) : (
|
||||||
<p className="text-gray-400 text-xs">No hay invitados registrados.</p>
|
<div className="space-y-2">
|
||||||
) : (
|
{liveParticipants.map((p: any) => (
|
||||||
<div className="space-y-2">
|
<div key={p.sid} className="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
|
||||||
{invited.map((inv) => (
|
<div className="flex items-center gap-3">
|
||||||
<div key={inv.identity} className={`flex items-center justify-between bg-gray-700 p-2 rounded-lg ${isConnected(inv.identity) ? 'border-2 border-green-500' : ''}`}>
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-pink-500 to-red-500 flex items-center justify-center text-white font-medium text-sm">
|
||||||
<div className="flex items-center gap-3">
|
{p.identity?.charAt(0)?.toUpperCase()}
|
||||||
<div className="w-9 h-9 rounded-full bg-gray-600 flex items-center justify-center text-white">
|
</div>
|
||||||
{inv.identity?.charAt(0)?.toUpperCase()}
|
<div>
|
||||||
|
<p className="text-sm text-white font-medium truncate">{p.identity}</p>
|
||||||
|
<p className="text-xs text-gray-400">{p.isLocal ? 'Tú (presentador)' : 'Invitado'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-white truncate">{inv.identity}</p>
|
<button className="text-xs px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded text-white transition-colors">
|
||||||
<p className="text-xs text-gray-400">{isConnected(inv.identity) ? 'Conectado' : 'Esperando'}</p>
|
Silenciar
|
||||||
|
</button>
|
||||||
|
<button className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-white transition-colors">
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
))}
|
||||||
<button className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-white">Invitar</button>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white text-sm font-semibold mb-3">Invitados</h4>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-400 text-xs">Cargando...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-400 text-xs">Error: {error}</p>
|
||||||
|
) : invited.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-xs">No hay invitados registrados.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{invited.map((inv) => (
|
||||||
|
<div key={inv.identity} className={`flex items-center justify-between bg-gray-700 p-3 rounded-lg ${isConnected(inv.identity) ? 'ring-2 ring-green-500' : ''}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-gray-600 flex items-center justify-center text-white font-medium text-sm">
|
||||||
|
{inv.identity?.charAt(0)?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white font-medium truncate">{inv.identity}</p>
|
||||||
|
<p className="text-xs text-gray-400">{isConnected(inv.identity) ? 'Conectado' : 'Esperando'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-white transition-colors">
|
||||||
|
Invitar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import '@livekit/components-styles'
|
|||||||
import StudioHeader from './StudioHeader'
|
import StudioHeader from './StudioHeader'
|
||||||
import StudioLeftSidebar from './StudioLeftSidebar'
|
import StudioLeftSidebar from './StudioLeftSidebar'
|
||||||
import StudioVideoArea from './StudioVideoArea'
|
import StudioVideoArea from './StudioVideoArea'
|
||||||
import StudioRightPanel from './StudioRightPanel'
|
import StudioRightPanel, { TabsColumn, TabType } from './StudioRightPanel'
|
||||||
import StudioControls from './StudioControls'
|
import StudioControls from './StudioControls'
|
||||||
import PresentationPanel from './PresentationPanel'
|
import PresentationPanel from './PresentationPanel'
|
||||||
import { DEMO_TOKEN } from '../config/demo'
|
import { DEMO_TOKEN } from '../config/demo'
|
||||||
@ -25,6 +25,7 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
|
|||||||
const [mode, setMode] = useState<'video' | 'audio'>('video')
|
const [mode, setMode] = useState<'video' | 'audio'>('video')
|
||||||
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
||||||
const [showRightPanel, setShowRightPanel] = useState(true)
|
const [showRightPanel, setShowRightPanel] = useState(true)
|
||||||
|
const [activeRightTab, setActiveRightTab] = useState<TabType>('brand')
|
||||||
|
|
||||||
// Utility: heurística ligera para validar token en cliente
|
// Utility: heurística ligera para validar token en cliente
|
||||||
const isTokenLikelyValid = (t?: string) => {
|
const isTokenLikelyValid = (t?: string) => {
|
||||||
@ -332,18 +333,34 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
|
|||||||
|
|
||||||
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
|
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
|
||||||
<div
|
<div
|
||||||
className={`absolute right-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
||||||
showRightPanel ? 'translate-x-0' : 'translate-x-full'
|
showRightPanel ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
|
style={{ right: '80px' }}
|
||||||
>
|
>
|
||||||
<StudioRightPanel roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string} />
|
<StudioRightPanel
|
||||||
|
roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string}
|
||||||
|
activeTab={activeRightTab}
|
||||||
|
onChangeTab={(t) => setActiveRightTab(t)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Tabs Column externo (modo demo): permanece visible incluso cuando el panel se oculta */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 z-30 pointer-events-auto"
|
||||||
|
style={{ right: '0px' }}
|
||||||
|
>
|
||||||
|
<div className="h-full flex items-start">
|
||||||
|
<TabsColumn activeTab={activeRightTab} onChangeTab={(t) => setActiveRightTab(t)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botón toggle derecho (en el borde izquierdo del panel derecho) */}
|
{/* Botón toggle derecho (se mueve con el panel) */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRightPanel(!showRightPanel)}
|
onClick={() => setShowRightPanel(!showRightPanel)}
|
||||||
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
||||||
style={{ right: showRightPanel ? '320px' : '0px' }}
|
style={{ right: showRightPanel ? '400px' : '80px' }}
|
||||||
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
|
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
|
||||||
>
|
>
|
||||||
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(-1)' }}>
|
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(-1)' }}>
|
||||||
@ -465,20 +482,38 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
|
|||||||
<StudioVideoArea isDemoMode={false} />
|
<StudioVideoArea isDemoMode={false} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
|
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
|
||||||
<div
|
<div
|
||||||
className={`absolute right-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
||||||
showRightPanel ? 'translate-x-0' : 'translate-x-full'
|
showRightPanel ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
|
style={{ right: '80px' }}
|
||||||
>
|
>
|
||||||
<StudioRightPanel roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string} />
|
<StudioRightPanel
|
||||||
|
roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string}
|
||||||
|
activeTab={activeRightTab}
|
||||||
|
onChangeTab={(t) => setActiveRightTab(t)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Column externo: permanece visible incluso cuando el panel se oculta */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 z-30 pointer-events-auto"
|
||||||
|
style={{ right: '0px' }}
|
||||||
|
>
|
||||||
|
<div className="h-full flex items-start">
|
||||||
|
<TabsColumn activeTab={activeRightTab} onChangeTab={(t) => setActiveRightTab(t)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botón toggle derecho (en el borde izquierdo del panel derecho) */}
|
{/* Botón toggle derecho (se mueve con el panel) */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRightPanel(!showRightPanel)}
|
onClick={() => setShowRightPanel(!showRightPanel)}
|
||||||
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
||||||
style={{ right: showRightPanel ? '320px' : '0px' }}
|
style={{ right: showRightPanel ? '400px' : '80px' }}
|
||||||
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
|
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
|
||||||
>
|
>
|
||||||
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(1)' }}>
|
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(1)' }}>
|
||||||
|
|||||||
@ -14,20 +14,60 @@ import {
|
|||||||
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 = 'comments' | 'banners' | 'brand' | 'style' | 'notes' | 'participants' | 'chat'
|
export type TabType = 'comments' | 'banners' | 'brand' | 'style' | 'notes' | 'participants' | 'chat'
|
||||||
|
|
||||||
const StudioRightPanel = ({ roomName }: { roomName?: string }) => {
|
// tabs shared entre el panel y el elemento externo (TabsColumn)
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('brand')
|
const tabs: { id: TabType; icon: any; label: string; badge: number }[] = [
|
||||||
|
{ id: 'comments', icon: MdComment, label: 'Comentarios', badge: 0 },
|
||||||
|
{ id: 'banners', icon: MdImage, label: 'Banners', badge: 0 },
|
||||||
|
{ id: 'brand', icon: MdBrush, label: 'Activos multimedia', badge: 0 },
|
||||||
|
{ id: 'style', icon: MdBrush, label: 'Estilo', badge: 0 },
|
||||||
|
{ id: 'notes', icon: MdTimer, label: 'Notas', badge: 0 },
|
||||||
|
{ id: 'participants', icon: MdPeople, label: 'Personas', badge: 0 },
|
||||||
|
{ id: 'chat', icon: MdChat, label: 'Chat privado', badge: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
const tabs = [
|
// Componente reutilizable para renderizar la columna de tabs (puede usarse fuera del panel)
|
||||||
{ id: 'comments' as TabType, icon: MdComment, label: 'Comentarios', badge: 0 },
|
export const TabsColumn: React.FC<{ activeTab: TabType; onChangeTab: (t: TabType) => void }> = ({ activeTab, onChangeTab }) => {
|
||||||
{ id: 'banners' as TabType, icon: MdImage, label: 'Banners', badge: 0 },
|
return (
|
||||||
{ id: 'brand' as TabType, icon: MdBrush, label: 'Activos multimedia', badge: 0 },
|
<div role="tablist" className="w-20 bg-gray-800 border-l border-gray-700 flex flex-col">
|
||||||
{ id: 'style' as TabType, icon: MdBrush, label: 'Estilo', badge: 0 },
|
{tabs.map((tab) => {
|
||||||
{ id: 'notes' as TabType, icon: MdTimer, label: 'Notas', badge: 0 },
|
const Icon = tab.icon
|
||||||
{ id: 'participants' as TabType, icon: MdPeople, label: 'Personas', badge: 0 },
|
const isActive = activeTab === tab.id
|
||||||
{ id: 'chat' as TabType, icon: MdChat, label: 'Chat privado', badge: 0 },
|
return (
|
||||||
]
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={`right-panel-content-${tab.id}`}
|
||||||
|
id={`right-panel-tab-${tab.id}`}
|
||||||
|
onClick={() => onChangeTab(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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudioRightPanel = ({ roomName, activeTab: activeTabProp, onChangeTab }: { roomName?: string; activeTab?: TabType; onChangeTab?: (t: TabType) => void }) => {
|
||||||
|
const [internalActiveTab, setInternalActiveTab] = useState<TabType>('brand')
|
||||||
|
const activeTab = activeTabProp ?? internalActiveTab
|
||||||
|
const setActiveTab = onChangeTab ?? setInternalActiveTab
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@ -52,41 +92,7 @@ const StudioRightPanel = ({ roomName }: { roomName?: string }) => {
|
|||||||
|
|
||||||
|
|
||||||
</aside>
|
</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}
|
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -218,7 +224,12 @@ const NotesTab = () => {
|
|||||||
<div className="p-4 border-t border-gray-700">
|
<div className="p-4 border-t border-gray-700">
|
||||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
<span>{notes.length} caracteres</span>
|
<span>{notes.length} caracteres</span>
|
||||||
<button className="text-pink-500 hover:underline">Limpiar</button>
|
<button
|
||||||
|
onClick={() => setNotes('')}
|
||||||
|
className="text-pink-500 hover:underline"
|
||||||
|
>
|
||||||
|
Limpiar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user