feat: implement Studio Panel layout and components with Tailwind CSS
@ -0,0 +1,21 @@
|
|||||||
|
REMOVED: `Hero` component
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Fecha: 2025-11-10
|
||||||
|
|
||||||
|
Acción tomada: se ha eliminado la funcionalidad del componente `Hero` en el paquete `studio-panel` porque este repositorio contiene el panel de usuario (studio) y no una landing page.
|
||||||
|
|
||||||
|
Detalles:
|
||||||
|
- Archivo afectado: `packages/studio-panel/src/components/Hero.tsx`.
|
||||||
|
- Estado actual: el archivo se ha convertido en un stub que retorna `null` para evitar romper referencias existentes durante la transición.
|
||||||
|
- Import en `packages/studio-panel/src/App.tsx` fue removido y reemplazado por un comentario que indica que el `Hero` no corresponde al panel.
|
||||||
|
|
||||||
|
Motivo:
|
||||||
|
- Evitar contenido de landing en el panel de usuario.
|
||||||
|
|
||||||
|
Siguientes pasos recomendados:
|
||||||
|
1. Si deseas eliminar completamente el archivo del repositorio, puedo borrarlo (actualmente lo mantengo como stub para facilidad de reversión).
|
||||||
|
2. Si el contenido del `Hero` debe conservarse para la landing, puedo moverlo al paquete `landing-page` y limpiar referencias.
|
||||||
|
3. Limpiar assets no usados en `packages/studio-panel/public/figma-assets/` si quieres reducir el tamaño del repo.
|
||||||
|
|
||||||
|
Si quieres que proceda a eliminar el archivo físicamente o a moverlo a otro paquete, dime la opción ("delete" / "move to landing-page" / "keep stub").
|
||||||
162
docs/TAILWIND_STYLES_GUIDE.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# Guía de Estilos Tailwind CSS - Studio Panel
|
||||||
|
|
||||||
|
## Actualización: 2025-11-10
|
||||||
|
|
||||||
|
Se ha aplicado Tailwind CSS de forma consistente en todo el `studio-panel`, siguiendo el patrón establecido en `broadcast-panel`.
|
||||||
|
|
||||||
|
## Principios de Diseño
|
||||||
|
|
||||||
|
### Colores Base
|
||||||
|
- **Fondo principal**: `bg-[#1f1f1f]` (gris oscuro)
|
||||||
|
- **Fondo secundario**: `bg-gray-900`
|
||||||
|
- **Gradientes**: `bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900`
|
||||||
|
- **Bordes sutiles**: `border-white/[0.04]` a `border-white/[0.12]`
|
||||||
|
- **Superficies elevadas**: `bg-white/[0.02]` a `bg-white/[0.06]`
|
||||||
|
|
||||||
|
### Texto
|
||||||
|
- **Primario**: `text-white`
|
||||||
|
- **Secundario**: `text-white/80`
|
||||||
|
- **Terciario**: `text-white/60`
|
||||||
|
- **Muted**: `text-gray-400`
|
||||||
|
|
||||||
|
### Espaciado Consistente
|
||||||
|
- **Padding pequeño**: `px-3 py-2` o `p-3`
|
||||||
|
- **Padding medio**: `px-4 py-2.5` o `p-4`
|
||||||
|
- **Padding grande**: `px-6 py-3` o `p-6`
|
||||||
|
- **Gaps**: `gap-2`, `gap-3`, `gap-4`
|
||||||
|
|
||||||
|
### Bordes y Radios
|
||||||
|
- **Border radius estándar**: `rounded-lg` (0.5rem)
|
||||||
|
- **Border radius pequeño**: `rounded-md` (0.375rem)
|
||||||
|
- **Border radius extra**: `rounded-xl` (0.75rem)
|
||||||
|
- **Border radius completo**: `rounded-full`
|
||||||
|
|
||||||
|
### Sombras
|
||||||
|
- **Sombra ligera**: `shadow-sm`
|
||||||
|
- **Sombra media**: `shadow-md`
|
||||||
|
- **Sombra grande**: `shadow-lg`
|
||||||
|
- **Sombra extra**: `shadow-xl` y `shadow-2xl`
|
||||||
|
- **Sombras de color**: `shadow-blue-500/20`, `shadow-red-500/20`
|
||||||
|
|
||||||
|
## Componentes Actualizados
|
||||||
|
|
||||||
|
### Header
|
||||||
|
```tsx
|
||||||
|
className="bg-[#1f1f1f] shadow-md border-b border-white/[0.04]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button
|
||||||
|
- **Default**: Fondo semitransparente con hover
|
||||||
|
- **Primary**: Gradiente azul con sombra
|
||||||
|
- **Ghost**: Transparente con hover sutil
|
||||||
|
- **Danger**: Gradiente rojo con sombra
|
||||||
|
|
||||||
|
### IconButton
|
||||||
|
```tsx
|
||||||
|
className="px-2.5 py-2.5 rounded-lg hover:bg-white/10 active:bg-white/5 transition-all duration-200"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
```tsx
|
||||||
|
className="bg-white/[0.02] border border-white/[0.06] rounded-lg p-6 hover:bg-white/[0.04] hover:border-white/[0.12] transition-all duration-200"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transiciones Estándar
|
||||||
|
|
||||||
|
### Duración
|
||||||
|
- **Rápida**: `duration-150`
|
||||||
|
- **Normal**: `duration-200`
|
||||||
|
- **Lenta**: `duration-300`
|
||||||
|
|
||||||
|
### Propiedades
|
||||||
|
- **Colores**: `transition-colors`
|
||||||
|
- **Todo**: `transition-all`
|
||||||
|
- **Opacidad**: `transition-opacity`
|
||||||
|
|
||||||
|
### Hover States
|
||||||
|
```tsx
|
||||||
|
hover:bg-white/10
|
||||||
|
hover:shadow-xl
|
||||||
|
hover:scale-105
|
||||||
|
active:scale-95
|
||||||
|
```
|
||||||
|
|
||||||
|
## Focus States
|
||||||
|
```tsx
|
||||||
|
focus:outline-none
|
||||||
|
focus:ring-2
|
||||||
|
focus:ring-white/20
|
||||||
|
focus:ring-offset-2
|
||||||
|
focus:ring-offset-gray-900
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estados Disabled
|
||||||
|
```tsx
|
||||||
|
disabled:opacity-50
|
||||||
|
disabled:cursor-not-allowed
|
||||||
|
disabled:pointer-events-none
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mejores Prácticas
|
||||||
|
|
||||||
|
1. **Usar clases utilitarias** en lugar de CSS personalizado
|
||||||
|
2. **Mantener consistencia** con espaciado (múltiplos de 4px)
|
||||||
|
3. **Aplicar transiciones** a elementos interactivos
|
||||||
|
4. **Usar opacidad** para variaciones de color (`white/10`, `white/60`)
|
||||||
|
5. **Gradientes sutiles** para profundidad visual
|
||||||
|
6. **Sombras contextuales** con colores de acento
|
||||||
|
|
||||||
|
## Paleta de Colores Acento
|
||||||
|
|
||||||
|
### Blue (Primary)
|
||||||
|
- `from-blue-600 to-blue-500`
|
||||||
|
- `hover:from-blue-500 hover:to-blue-400`
|
||||||
|
- `shadow-blue-500/20`
|
||||||
|
|
||||||
|
### Red (Danger/Record)
|
||||||
|
- `from-red-600 to-red-500`
|
||||||
|
- `hover:from-red-500 hover:to-red-400`
|
||||||
|
- `shadow-red-500/20`
|
||||||
|
|
||||||
|
### Green (Success)
|
||||||
|
- `bg-green-400`
|
||||||
|
- `shadow-green-400/50`
|
||||||
|
|
||||||
|
### Yellow (Warning)
|
||||||
|
- `bg-yellow-400`
|
||||||
|
- `shadow-yellow-400/50`
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
- `sm:` - 640px
|
||||||
|
- `md:` - 768px
|
||||||
|
- `lg:` - 1024px
|
||||||
|
- `xl:` - 1280px
|
||||||
|
|
||||||
|
### Grid Patterns
|
||||||
|
```tsx
|
||||||
|
grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Archivos Modificados
|
||||||
|
|
||||||
|
- `src/components/Header.tsx`
|
||||||
|
- `src/components/header/Brand.tsx`
|
||||||
|
- `src/components/header/LiveBadge.tsx`
|
||||||
|
- `src/components/header/RecordButton.tsx`
|
||||||
|
- `src/components/ui/Button.tsx`
|
||||||
|
- `src/components/ui/IconButton.tsx`
|
||||||
|
- `src/components/figma/FigmaHeader.tsx`
|
||||||
|
- `src/components/figma/PersonCard.tsx`
|
||||||
|
- `src/components/figma/MediaGrid.tsx`
|
||||||
|
- `src/App.tsx`
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
✅ TypeCheck: OK
|
||||||
|
✅ Lint: OK
|
||||||
|
✅ Consistencia visual con broadcast-panel
|
||||||
|
✅ Transiciones suaves aplicadas
|
||||||
|
✅ Estados hover/focus/active implementados
|
||||||
|
|
||||||
285
docs/VRISTO_UI_COMPONENTS.md
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# Componentes UI Adaptados de Vristo - Studio Panel
|
||||||
|
|
||||||
|
## Fecha: 2025-11-10
|
||||||
|
|
||||||
|
Se han implementado componentes UI profesionales adaptados de **Vristo React** para el `studio-panel`, siguiendo el diseño de Figma y la estructura visual mostrada en las imágenes de contexto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Componentes Implementados
|
||||||
|
|
||||||
|
### 1. **Modal** (`Modal.tsx`)
|
||||||
|
Modal reutilizable con Headless UI y Tailwind CSS.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Backdrop blur
|
||||||
|
- ✅ Animaciones de entrada/salida
|
||||||
|
- ✅ Tamaños configurables (sm, md, lg, xl, full)
|
||||||
|
- ✅ Header, body y footer personalizables
|
||||||
|
- ✅ Botón de cerrar opcional
|
||||||
|
- ✅ Diseño oscuro consistente
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
title="Nueva Escena"
|
||||||
|
size="md"
|
||||||
|
footer={<ModalFooter />}
|
||||||
|
>
|
||||||
|
{/* Contenido */}
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **VerticalTabs** (`VerticalTabs.tsx`)
|
||||||
|
Tabs verticales para el panel derecho del studio.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Layout vertical con sidebar
|
||||||
|
- ✅ Iconos personalizables
|
||||||
|
- ✅ Estados activo/inactivo/disabled
|
||||||
|
- ✅ Animaciones suaves
|
||||||
|
- ✅ Border lateral en tab activo (azul)
|
||||||
|
- ✅ Scroll automático en contenido
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```tsx
|
||||||
|
<VerticalTabs
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'logo',
|
||||||
|
label: 'Logo',
|
||||||
|
icon: <PhotoIcon />,
|
||||||
|
content: <LogoSettings />
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **DropdownMenu** (`DropdownMenu.tsx`)
|
||||||
|
Menú desplegable contextual con Headless UI.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Posicionamiento configurable (4 opciones)
|
||||||
|
- ✅ Items con iconos
|
||||||
|
- ✅ Dividers entre secciones
|
||||||
|
- ✅ Items deshabilitados
|
||||||
|
- ✅ Items de peligro (rojo)
|
||||||
|
- ✅ Hover states suaves
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```tsx
|
||||||
|
<DropdownMenu
|
||||||
|
button={<EllipsisVerticalIcon />}
|
||||||
|
items={[
|
||||||
|
{ label: 'Editar', onClick: handleEdit },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: 'Eliminar', onClick: handleDelete, danger: true }
|
||||||
|
]}
|
||||||
|
placement="bottom-end"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Toggle** (`Toggle.tsx`)
|
||||||
|
Switch toggle con Headless UI.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Tres tamaños (sm, md, lg)
|
||||||
|
- ✅ Label y descripción opcionales
|
||||||
|
- ✅ Estados disabled
|
||||||
|
- ✅ Focus ring accesible
|
||||||
|
- ✅ Animaciones smooth
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```tsx
|
||||||
|
<Toggle
|
||||||
|
enabled={isEnabled}
|
||||||
|
onChange={setIsEnabled}
|
||||||
|
label="Mostrar logo"
|
||||||
|
description="Activa o desactiva el logo"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Select** (`Select.tsx`)
|
||||||
|
Select dropdown personalizado.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Opciones con iconos
|
||||||
|
- ✅ Placeholder configurable
|
||||||
|
- ✅ Estados error
|
||||||
|
- ✅ Label opcional
|
||||||
|
- ✅ Check mark en opción seleccionada
|
||||||
|
- ✅ Hover y focus states
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```tsx
|
||||||
|
<Select
|
||||||
|
value={position}
|
||||||
|
onChange={setPosition}
|
||||||
|
options={positionOptions}
|
||||||
|
label="Posición del logo"
|
||||||
|
placeholder="Seleccionar..."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Paneles Implementados
|
||||||
|
|
||||||
|
### **StudioLeftPanel** (`panels/StudioLeftPanel.tsx`)
|
||||||
|
Panel izquierdo con escenas (según Figma).
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Lista de escenas con thumbnails
|
||||||
|
- ✅ Header con estado BETA
|
||||||
|
- ✅ Botón expandir/colapsar
|
||||||
|
- ✅ Dropdown menu por escena
|
||||||
|
- ✅ Modal para nueva escena
|
||||||
|
- ✅ Hover states en cards
|
||||||
|
- ✅ Dividers entre escenas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **StudioRightPanel** (`panels/StudioRightPanel.tsx`)
|
||||||
|
Panel derecho con herramientas (según Figma).
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Tabs verticales con iconos
|
||||||
|
- ✅ 9 secciones configurables:
|
||||||
|
- Comentarios
|
||||||
|
- Logo (con Toggle y Select)
|
||||||
|
- Superposición
|
||||||
|
- Código QR
|
||||||
|
- Clips de video
|
||||||
|
- Fondo
|
||||||
|
- Sonidos
|
||||||
|
- Música de fondo
|
||||||
|
- Notas
|
||||||
|
- ✅ Configuraciones por sección
|
||||||
|
- ✅ Estados interactivos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 App.tsx Actualizado
|
||||||
|
|
||||||
|
Layout completo del studio implementado:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ HEADER (Brand, Live, Record) │
|
||||||
|
├───────┬─────────────────────────────┬──────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ LEFT │ MAIN STUDIO AREA │ RIGHT │
|
||||||
|
│ PANEL │ (Video + Controls Bar) │ PANEL │
|
||||||
|
│ │ │ │
|
||||||
|
│Escenas│ 720p Preview │Tabs Vert.│
|
||||||
|
│ │ │ │
|
||||||
|
└───────┴─────────────────────────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Áreas:**
|
||||||
|
1. **Header** - Branding, estado EN VIVO, botón Grabar
|
||||||
|
2. **Left Panel** - Gestión de escenas (280px)
|
||||||
|
3. **Main Area** - Preview de video + barra de controles
|
||||||
|
4. **Right Panel** - Herramientas en tabs verticales
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Paleta de Colores Aplicada
|
||||||
|
|
||||||
|
### Fondos
|
||||||
|
- **Principal**: `bg-[#1f1f1f]`
|
||||||
|
- **Secundario**: `bg-[#1a1a1a]`
|
||||||
|
- **Oscuro**: `bg-[#0f0f0f]`
|
||||||
|
- **Superficies**: `bg-white/[0.02]` a `bg-white/[0.06]`
|
||||||
|
|
||||||
|
### Bordes
|
||||||
|
- **Sutiles**: `border-white/10`
|
||||||
|
- **Hover**: `border-white/20`
|
||||||
|
- **Activos**: `border-white/30`
|
||||||
|
|
||||||
|
### Textos
|
||||||
|
- **Primario**: `text-white`
|
||||||
|
- **Secundario**: `text-white/80`
|
||||||
|
- **Terciario**: `text-white/60`
|
||||||
|
- **Muted**: `text-white/40`
|
||||||
|
|
||||||
|
### Acentos
|
||||||
|
- **Blue**: `bg-blue-600` / `text-blue-400`
|
||||||
|
- **Red**: `bg-red-600` / `text-red-400`
|
||||||
|
- **Purple**: `bg-purple-500/20` (BETA badge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ventajas de los Componentes
|
||||||
|
|
||||||
|
1. **Totalmente Tipados** - TypeScript strict mode
|
||||||
|
2. **Accesibles** - Focus states, ARIA labels, keyboard navigation
|
||||||
|
3. **Responsive** - Diseño adaptable
|
||||||
|
4. **Animaciones Suaves** - Transiciones de 150-300ms
|
||||||
|
5. **Dark Mode Native** - Diseñados para tema oscuro
|
||||||
|
6. **Consistencia Visual** - Sigue design tokens de broadcast-panel
|
||||||
|
7. **Reutilizables** - Props configurables y flexibles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Validaciones
|
||||||
|
|
||||||
|
✅ **TypeCheck**: OK (sin errores)
|
||||||
|
✅ **Lint**: OK (sin warnings)
|
||||||
|
✅ **Build**: Compatible con Vite
|
||||||
|
✅ **Consistencia**: Alineado con broadcast-panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Dependencias Usadas
|
||||||
|
|
||||||
|
- `@headlessui/react` - Componentes accesibles sin estilos
|
||||||
|
- `@heroicons/react` - Iconos consistentes
|
||||||
|
- `tailwindcss` - Estilos utilitarios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Próximos Pasos Recomendados
|
||||||
|
|
||||||
|
1. ✅ Integrar con estado global (Redux/Zustand)
|
||||||
|
2. ✅ Conectar con backend para guardar configuraciones
|
||||||
|
3. ✅ Añadir drag & drop para escenas
|
||||||
|
4. ✅ Implementar preview en tiempo real
|
||||||
|
5. ✅ Añadir más opciones de personalización
|
||||||
|
6. ✅ Tests unitarios para componentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Archivos Creados/Modificados
|
||||||
|
|
||||||
|
### Nuevos Componentes UI:
|
||||||
|
- `src/components/ui/Modal.tsx`
|
||||||
|
- `src/components/ui/VerticalTabs.tsx`
|
||||||
|
- `src/components/ui/DropdownMenu.tsx`
|
||||||
|
- `src/components/ui/Toggle.tsx`
|
||||||
|
- `src/components/ui/Select.tsx`
|
||||||
|
|
||||||
|
### Paneles:
|
||||||
|
- `src/components/panels/StudioLeftPanel.tsx`
|
||||||
|
- `src/components/panels/StudioRightPanel.tsx`
|
||||||
|
|
||||||
|
### Actualizados:
|
||||||
|
- `src/components/ui/index.ts` - Exports actualizados
|
||||||
|
- `src/App.tsx` - Layout completo del studio
|
||||||
|
- `docs/TAILWIND_STYLES_GUIDE.md` - Guía de estilos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementación completada y validada** ✅
|
||||||
|
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
Guía de Design Tokens — AvanzaCast
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Propósito
|
||||||
|
--------
|
||||||
|
Esta guía centraliza los tokens de diseño usados por AvanzaCast (colores, tipografías, tamaños, espaciados y sombras). Está pensada para desarrolladores y diseñadores: explica los tokens disponibles, su uso semántico y mapas a clases de Tailwind ya configuradas en `packages/studio-panel/tailwind.config.cjs`.
|
||||||
|
|
||||||
|
Resumen rápido
|
||||||
|
---------------
|
||||||
|
- Paleta principal: tokens `studio-*` (bg, muted, accent, accent-2)
|
||||||
|
- Tipografías: `inter` (principal) y `requiner` (logo)
|
||||||
|
- Tamaños tipográficos: `figma-transmision`, `figma-personas`, `figma-h1`, `figma-h2`, `figma-base`
|
||||||
|
- Pesos: `figma-light`, `figma-regular`, `figma-medium`, `figma-bold`
|
||||||
|
|
||||||
|
Colores (paleta)
|
||||||
|
-----------------
|
||||||
|
Uso semántico y clases Tailwind (extendidas en `tailwind.config.cjs`):
|
||||||
|
|
||||||
|
- studio-bg — #41444A
|
||||||
|
- Uso: fondo de header, barras principales.
|
||||||
|
- Clase: `bg-studio-bg`.
|
||||||
|
|
||||||
|
- studio-dark — #12151D
|
||||||
|
- Uso: fondos muy oscuros, overlays principales.
|
||||||
|
- Clase: `bg-studio-dark`.
|
||||||
|
|
||||||
|
- studio-muted — #7E8791
|
||||||
|
- Uso: texto secundario, subtítulos, metadatos.
|
||||||
|
- Clase: `text-studio-muted`.
|
||||||
|
|
||||||
|
- studio-muted-2 — #77818F
|
||||||
|
- Uso: texto alternativo, placeholders.
|
||||||
|
- Clase: `text-studio-muted-2`.
|
||||||
|
|
||||||
|
- studio-accent — #3B82F6
|
||||||
|
- Uso: acentos primarios (botones, enlaces activos).
|
||||||
|
- Clase: `bg-studio-accent` (o `text-studio-accent`).
|
||||||
|
|
||||||
|
- studio-accent-2 — #EF4444
|
||||||
|
- Uso: acento secundario / estado destructivo.
|
||||||
|
- Clase: `bg-studio-accent-2`.
|
||||||
|
|
||||||
|
Tokens clásicos (utility mapping)
|
||||||
|
- Fondo principal: `bg-studio-bg` (header)
|
||||||
|
- Texto secundario: `text-studio-muted`
|
||||||
|
- Botón primario: `bg-studio-accent` o gradiente `from-blue-600 to-blue-500`
|
||||||
|
- Indicador en vivo: punto rojo `bg-red-500`
|
||||||
|
|
||||||
|
Tipografías y tamaños
|
||||||
|
---------------------
|
||||||
|
Font families (extendidas en Tailwind):
|
||||||
|
- `font-inter` -> Inter (principal UI)
|
||||||
|
- `font-requiner` -> Requiner (logo)
|
||||||
|
|
||||||
|
Tamaños definidos (tailwind custom):
|
||||||
|
- `text-[figma-h1]` = 32px / lineHeight 40px (clase generada: `text-[figma-h1]`)
|
||||||
|
- `text-[figma-h2]` = 24px / lineHeight 32px
|
||||||
|
- `text-[figma-transmision]` = 12.8px / lineHeight 15.5px
|
||||||
|
- `text-[figma-personas]` = 11.3px / lineHeight 13.7px
|
||||||
|
- `text-[figma-base]` = 16px / lineHeight 24px
|
||||||
|
|
||||||
|
Pesos semánticos (clases personalizadas en `tailwind.config.cjs`):
|
||||||
|
- `font-figma-light` -> 300
|
||||||
|
- `font-figma-regular` -> 400
|
||||||
|
- `font-figma-medium` -> 500
|
||||||
|
- `font-figma-bold` -> 700
|
||||||
|
|
||||||
|
Recomendaciones de uso tipográfico
|
||||||
|
- Titulares: `text-[figma-h1]` / `text-[figma-h2]` según jerarquía, `font-figma-medium` o `font-figma-bold`.
|
||||||
|
- Labels pequeños y metadatos: `text-[figma-personas]` + `font-figma-regular`.
|
||||||
|
- UI body copy: `text-[figma-base]` + `font-figma-regular`.
|
||||||
|
|
||||||
|
Espaciado (scale)
|
||||||
|
------------------
|
||||||
|
Recomiendo usar las utilidades de espacio de Tailwind (p, px, py, gap) con esta convención semántica:
|
||||||
|
- xs: 4px -> `p-1`, `gap-1`
|
||||||
|
- sm: 8px -> `p-2`, `gap-2`
|
||||||
|
- md (base): 12px -> `p-3`, `gap-3`
|
||||||
|
- lg: 16px -> `p-4`, `gap-4`
|
||||||
|
- xl: 24px -> `p-6`, `gap-6`
|
||||||
|
|
||||||
|
En el header aplicamos: `h-14` (56px), `gap-3` (12px) y `px-4` para los laterales.
|
||||||
|
|
||||||
|
Border-radius (radio)
|
||||||
|
---------------------
|
||||||
|
- `rounded-sm` ~ 4px
|
||||||
|
- `rounded-md` ~ 8px
|
||||||
|
- `rounded-full` -> 9999px (botones circulares)
|
||||||
|
- En Brand usamos `rounded-xl` (~12px) para el contenedor del logo.
|
||||||
|
|
||||||
|
Sombras y elevación
|
||||||
|
--------------------
|
||||||
|
- Elevación leve (cards, buttons): `shadow-sm` -> `box-shadow: 0 1px 2px rgba(...)`.
|
||||||
|
- Elevación media (modales): `shadow-lg`.
|
||||||
|
- Borde sutil en header: `border-b border-white/4`.
|
||||||
|
|
||||||
|
Iconografía y tamaños
|
||||||
|
---------------------
|
||||||
|
- Iconos pequeños: `w-4 h-4` (16px)
|
||||||
|
- Iconos medios: `w-5 h-5` (20px)
|
||||||
|
- Avatares / logos: 40–48px (`w-10` / `w-12`)
|
||||||
|
|
||||||
|
Estados y microinteracciones
|
||||||
|
----------------------------
|
||||||
|
- Hover: usar `hover:bg-white/6` o `hover:brightness-95` según el elemento.
|
||||||
|
- Focus visible: ya definimos en CSS global `:focus-visible` con outline azul; preferir `focus:outline-none` y rely en `focus-visible` para accesibilidad.
|
||||||
|
- Disabled: aplicar `opacity-50 cursor-not-allowed pointer-events-none`.
|
||||||
|
|
||||||
|
Ejemplos de uso (Tailwind)
|
||||||
|
--------------------------
|
||||||
|
- Header brand:
|
||||||
|
- Contenedor: `flex items-center gap-3 min-w-0`
|
||||||
|
- Título: `text-[figma-h2] font-figma-medium text-white`
|
||||||
|
- Subtítulo: `text-[figma-transmision] font-figma-regular text-studio-muted`
|
||||||
|
|
||||||
|
- Live badge:
|
||||||
|
- `inline-flex items-center gap-2 px-3 py-0.5 rounded-full bg-black/40 ring-1 ring-white/6 font-inter`
|
||||||
|
- Punto: `w-2 h-2 rounded-full bg-red-500 animate-pulse`
|
||||||
|
|
||||||
|
- Botón primario (ej. Grabar):
|
||||||
|
- `rounded-full inline-flex items-center gap-2 px-3 py-1 bg-gradient-to-br from-red-600 to-red-500 text-white shadow-sm`
|
||||||
|
|
||||||
|
Cómo mantener los tokens (developer notes)
|
||||||
|
-----------------------------------------
|
||||||
|
1. Centraliza tokens en `tailwind.config.cjs` dentro de `theme.extend` (colores, fontFamily, fontSize, fontWeight).
|
||||||
|
2. Evita usar colores hex en componentes: siempre usar `bg-studio-bg`, `text-studio-muted`, etc.
|
||||||
|
3. Si se necesita un nuevo token, agrégalo a `tailwind.config.cjs` y documenta aquí bajo "Actualizaciones" con fecha y autor.
|
||||||
|
|
||||||
|
Ejemplo: añadir un color token en `tailwind.config.cjs`
|
||||||
|
```js
|
||||||
|
// tailwind.config.cjs (theme.extend)
|
||||||
|
colors: {
|
||||||
|
'studio-bg': '#41444A',
|
||||||
|
'studio-muted': '#7E8791',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Guía rápida de accesibilidad
|
||||||
|
----------------------------
|
||||||
|
- Siempre añadir `aria-label` en `IconButton` y `role=status` en badges dinámicos.
|
||||||
|
- Usar `sr-only` para mensajes redundantes de accesibilidad.
|
||||||
|
- Mantener contraste suficiente para texto y elementos interactivos (usar herramientas de contraste para verificar).
|
||||||
|
|
||||||
|
Mantenimiento y gobernanza
|
||||||
|
---------------------------
|
||||||
|
- Cada cambio de token debe registrar una entrada en este documento con la fecha y la razón.
|
||||||
|
- Mantener consistencia entre Figma y `tailwind.config.cjs`: al actualizar estilos en Figma, actualizar tokens y revisar componentes.
|
||||||
|
|
||||||
|
Historial y próximas acciones
|
||||||
|
-----------------------------
|
||||||
|
- 2025-11-10: Creación inicial de la guía y mapeo de tokens usados en `packages/studio-panel`.
|
||||||
|
- Próximo: extraer sombras y radios exactos desde Figma y sincronizar con `tailwind.config.cjs` si se requiere match pixel-perfect.
|
||||||
|
|
||||||
|
Contacto
|
||||||
|
--------
|
||||||
|
Si necesitas que incluya valores exactos extraídos de Figma (letter-spacing, exact color tokens, o exportar assets) indícalo y automatizo la extracción de tokens y la actualización de `tailwind.config.cjs`.
|
||||||
|
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
Figma — Tokens extraídos (Stream_clone)
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
Resumen
|
||||||
|
-------
|
||||||
|
Este documento recoge los tokens visuales (colores, tipografías, tamaños, radios) extraídos automáticamente desde el archivo Figma `Stream_clone` (Page 1 / Frame `Figma design - streamyard1.png`). Los valores son la mejor correspondencia extraída del archivo (hex, px) y se proponen como tokens para sincronizar con `tailwind.config.cjs`.
|
||||||
|
|
||||||
|
Colores (hex)
|
||||||
|
-------------
|
||||||
|
- studio-dark: #12151D (fill_GX03CB)
|
||||||
|
- studio-bg: #41444A (fill_DLCT4G)
|
||||||
|
- studio-muted: #7E8791 (ej. fill_13MDCZ / configurado previamente)
|
||||||
|
- studio-muted-2: #77818F (fill_XG994A)
|
||||||
|
- studio-border: #D0D0CB (fill_9R9Y7U)
|
||||||
|
- studio-btn-blue: #98BFFE (fill_49OOFB - botón)
|
||||||
|
- studio-grabar-bg: #424E6A (fill_BWASHJ - botón "Grabar" fondo en Figma)
|
||||||
|
- studio-accent-alt: #122B54 (fill_BOQTE5)
|
||||||
|
- token-gray-light: #C7C6BF (fill_N03D5A)
|
||||||
|
- token-gray-mid: #C9D1D0 (fill_XEE5XM)
|
||||||
|
- token-gray-2: #CBCEC9 (fill_XEE5XM)
|
||||||
|
|
||||||
|
Tipografías y tamaños
|
||||||
|
---------------------
|
||||||
|
Extracciones desde los style_* en Figma (valores en px):
|
||||||
|
- style_2I2NT6: 12.8px -> `figma-transmision` (ya en Tailwind)
|
||||||
|
- style_I6TCK2: 11.3px -> `figma-personas` (ya en Tailwind)
|
||||||
|
- style_0GZP76: 14.2px -> (UI small labels)
|
||||||
|
- style_CREH16: 15.5px -> (heading small)
|
||||||
|
- style_4TUWSC: 13.2px -> (botón / label)
|
||||||
|
- style_QUEM4J: 16.2px -> (subtitle / section headings)
|
||||||
|
- style_2T7708 / style_10RTOQ / style_UT5PZD: 16-17px (varios headings)
|
||||||
|
|
||||||
|
Sugerencia: mantener en `tailwind.config.cjs` los tokens ya añadidos: `figma-transmision`, `figma-personas`, `figma-h1`, `figma-h2`, `figma-base`. Añadir `figma-btn` = 13.2px si se desea.
|
||||||
|
|
||||||
|
Radii (border-radius)
|
||||||
|
---------------------
|
||||||
|
- small-radius: 3.25px / 3.75px (varios elementos con borderRadius 3.25–3.75px)
|
||||||
|
- rounded-xl: ~12px (usado en Brand logo container)
|
||||||
|
|
||||||
|
Sombras
|
||||||
|
-------
|
||||||
|
El archivo Figma devuelto en la extracción no contenía propiedades de sombra explícitas en los estilos expuestos por la API (o estaban en layers rasterizados). Recomendación: extraer sombras manualmente desde la inspección en Figma o fijar una shadow scale en `tailwind.config.cjs` (ej. `shadow-sm`, `shadow-md`, `shadow-lg`) y ajustar según revisión visual.
|
||||||
|
|
||||||
|
Tokens JSON (next steps)
|
||||||
|
------------------------
|
||||||
|
He exportado también un JSON con los tokens detectados (colores, tipografías y radios) en `packages/studio-panel/src/design/figma-tokens.json`. Úsalo como fuente canónica para sincronizar `tailwind.config.cjs`.
|
||||||
|
|
||||||
|
Notas
|
||||||
|
-----
|
||||||
|
- Algunos elementos en el Figma son imágenes o composiciones (Image fills con imageRef). Esos se descargaron como PNGs en `packages/studio-panel/public/figma-assets/`.
|
||||||
|
- Para extracción exacta de sombras, letter-spacing y otras propiedades de texto/efecto es mejor acceder directamente al archivo Figma (layers) y exportar tokens desde el plugin "Design Tokens" o solicitar al diseñador que exporte la tabla de tokens.
|
||||||
|
|
||||||
|
Siguiente paso sugerido
|
||||||
|
----------------------
|
||||||
|
- Sincronizar `tailwind.config.cjs` con los valores listados arriba (colores exactos, agregar `figma-btn` si se desea).
|
||||||
|
- Opcional: ejecutar un paso de QA visual comparando capturas del header actual con la imagen Figma (usar Playwright snapshots).
|
||||||
|
|
||||||
|
Archivo con tokens: `packages/studio-panel/src/design/figma-tokens.json`
|
||||||
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Figma import report
|
||||||
|
|
||||||
|
File: jTTsEco3IFs46dNG6dY9GM (Stream_clone)
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- The Figma file contains a single frame `Figma design - streamyard1.png` with nested frames and rectangles.
|
||||||
|
- Key nodes detected and mapped to React components:
|
||||||
|
- `1:173 Transmision` (TEXT) -> mapped to `FigmaHeader` title
|
||||||
|
- `1:49 Personas` (TEXT) -> mapped to `PersonCard` label
|
||||||
|
- Several `Image` RECTANGLE nodes with imageRef -> mapped to `MediaGrid` assets
|
||||||
|
- `Button` frames -> mapped to `ui/Button` usage in `FigmaHeader`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Images were downloaded to `packages/studio-panel/public/figma-assets/`.
|
||||||
|
- Some frames in Figma are complex groups; I created representative components (FigmaHeader, PersonCard, MediaGrid). These are starting points — further refinement may be needed to exactly match spacing/typography.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
- Replace placeholder images with exact exported assets where necessary.
|
||||||
|
- Extract exact colors and typography tokens from Figma into `tailwind.config.cjs`.
|
||||||
|
- Iterate creating more precise components for groups (e.g., SceneCard, Toolbar, Controls).
|
||||||
|
|
||||||
|
|
||||||
BIN
docs/screenshot_streamyard.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/text40.png
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 77 KiB |
5141
package-lock.json
generated
@ -1,6 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
|
<script type="module">
|
||||||
|
import RefreshRuntime from "/@react-refresh"
|
||||||
|
RefreshRuntime.injectIntoGlobalHook(window)
|
||||||
|
window.$RefreshReg$ = () => {}
|
||||||
|
window.$RefreshSig$ = () => (type) => type
|
||||||
|
window.__vite_plugin_react_preamble_installed__ = true
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="module" src="/@vite/client"></script>
|
||||||
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AvanzaCast - Plataforma Profesional de Streaming en Vivo</title>
|
<title>AvanzaCast - Plataforma Profesional de Streaming en Vivo</title>
|
||||||
@ -11,9 +21,9 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Iconos Unicons -->
|
<!-- Iconos Unicons (removed CDN link; importing locally from @iconscout/unicons in src/main.tsx) -->
|
||||||
<link rel="stylesheet" href="https://unicons.iconscout.com/release/v4.0.0/css/line.css">
|
<!-- <link rel="stylesheet" href="https://unicons.iconscout.com/release/v4.0.0/css/line.css"> -->
|
||||||
|
|
||||||
<!-- Material Design Icons -->
|
<!-- Material Design Icons -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
6449
packages/landing-page/package-lock.json
generated
@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@iconscout/unicons": "^4.0.8",
|
"@iconscout/unicons": "^4.2.0",
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"choices.js": "^10.2.0",
|
"choices.js": "^10.2.0",
|
||||||
"feather-icons": "^4.29.1",
|
"feather-icons": "^4.29.1",
|
||||||
@ -26,7 +26,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"shufflejs": "^6.1.2",
|
"shufflejs": "^6.1.2",
|
||||||
"swiper": "4.5.0"
|
"swiper": "^10.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.4",
|
"@tailwindcss/forms": "^0.5.4",
|
||||||
@ -36,8 +36,8 @@
|
|||||||
"@vitejs/plugin-react": "^4.0.3",
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"postcss": "^8.4.23",
|
"postcss": "^8.4.23",
|
||||||
"tailwindcss": "^4.1.0",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^4.3.9"
|
"vite": "^5.4.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'tailwindcss': {},
|
||||||
autoprefixer: {},
|
'autoprefixer': {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { ArrowUpIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
export default function BackToTop() {
|
export default function BackToTop() {
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
@ -16,7 +17,8 @@ export default function BackToTop() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
@ -26,26 +28,16 @@ export default function BackToTop() {
|
|||||||
if (!isVisible) return null
|
if (!isVisible) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<a
|
||||||
onClick={scrollToTop}
|
href="#"
|
||||||
id="back-to-top"
|
id="back-to-top"
|
||||||
className="fixed bottom-5 end-5 z-50 size-10 text-center bg-indigo-600 text-white rounded-full flex items-center justify-center shadow-md hover:bg-indigo-700 transition-all duration-300"
|
onClick={scrollToTop}
|
||||||
aria-label="Volver arriba"
|
className={`fixed bottom-5 end-5 z-50 h-10 w-10 flex items-center justify-center bg-indigo-600 text-white rounded-full transition-transform duration-300 hover:bg-indigo-700 ${
|
||||||
|
isVisible ? 'translate-y-0' : 'translate-y-20'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg
|
<ArrowUpIcon className="h-6 w-6" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</a>
|
||||||
className="size-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Logo } from '../../../../shared/components/Logo'
|
import { Logo } from '../../../../shared/components/Logo'
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
HeartIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
const ModernSaasFooter: React.FC = () => {
|
const ModernSaasFooter: React.FC = () => {
|
||||||
const [newsletter, setNewsletter] = useState('')
|
const [newsletter, setNewsletter] = useState('')
|
||||||
@ -52,7 +57,7 @@ const ModernSaasFooter: React.FC = () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="py-2 px-5 inline-block font-semibold tracking-wide align-middle duration-500 text-base text-center absolute top-[2px] end-[3px] h-[46px] bg-indigo-600 hover:bg-indigo-700 border border-indigo-600 hover:border-indigo-700 text-white rounded-full"
|
className="py-2 px-5 inline-block font-semibold tracking-wide align-middle duration-500 text-base text-center absolute top-[2px] end-[3px] h-[46px] bg-indigo-600 hover:bg-indigo-700 border border-indigo-600 hover:border-indigo-700 text-white rounded-full"
|
||||||
>
|
>
|
||||||
Suscribirse <i className="uil uil-arrow-right"></i>
|
Suscribirse <ArrowRightIcon className="inline-block h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -81,27 +86,27 @@ const ModernSaasFooter: React.FC = () => {
|
|||||||
<ul className="list-none flex items-center space-x-2">
|
<ul className="list-none flex items-center space-x-2">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
||||||
<i className="uil uil-facebook-f align-middle"></i>
|
<i className="mdi mdi-facebook text-2xl"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
||||||
<i className="uil uil-instagram align-middle"></i>
|
<i className="mdi mdi-instagram text-2xl"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
||||||
<i className="uil uil-twitter align-middle"></i>
|
<i className="mdi mdi-twitter text-2xl"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
||||||
<i className="uil uil-linkedin align-middle"></i>
|
<i className="mdi mdi-linkedin text-2xl"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
<a href="#" className="size-10 inline-flex items-center justify-center tracking-wide align-middle duration-500 text-base text-center border border-gray-700 rounded-md hover:border-indigo-600 dark:hover:border-indigo-600 hover:bg-indigo-600 dark:hover:bg-indigo-600">
|
||||||
<i className="uil uil-youtube align-middle"></i>
|
<i className="mdi mdi-youtube text-2xl"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -113,32 +118,32 @@ const ModernSaasFooter: React.FC = () => {
|
|||||||
<ul className="list-none footer-list space-y-3">
|
<ul className="list-none footer-list space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Acerca de
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Acerca de
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Servicios
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Servicios
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Equipo
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Equipo
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Precios
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Precios
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Proyecto
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Proyecto
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Carreras
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Carreras
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -150,27 +155,27 @@ const ModernSaasFooter: React.FC = () => {
|
|||||||
<ul className="list-none footer-list space-y-3">
|
<ul className="list-none footer-list space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Términos de Servicio
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Términos de Servicio
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Política de Privacidad
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Política de Privacidad
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Documentación
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Documentación
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Changelog
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Changelog
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Componentes
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Componentes
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -182,27 +187,27 @@ const ModernSaasFooter: React.FC = () => {
|
|||||||
<ul className="list-none footer-list space-y-3">
|
<ul className="list-none footer-list space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Blog
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Blog
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Tutoriales
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Tutoriales
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Centro de Ayuda
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Centro de Ayuda
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> Soporte
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> Soporte
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
<a href="#" className="text-slate-300 hover:text-slate-400 duration-500 ease-in-out inline-flex items-center">
|
||||||
<i className="uil uil-angle-right-b me-1"></i> API
|
<ChevronRightIcon className="inline-block h-4 w-4 me-1" /> API
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -217,7 +222,7 @@ const ModernSaasFooter: React.FC = () => {
|
|||||||
<div className="grid md:grid-cols-2 items-center gap-6">
|
<div className="grid md:grid-cols-2 items-center gap-6">
|
||||||
<div className="md:text-start text-center">
|
<div className="md:text-start text-center">
|
||||||
<p className="mb-0 text-slate-300">
|
<p className="mb-0 text-slate-300">
|
||||||
© {new Date().getFullYear()} AvanzaCast. Diseñado con <i className="mdi mdi-heart text-red-600"></i> por{' '}
|
© {new Date().getFullYear()} AvanzaCast. Diseñado con <HeartIcon className="inline-block h-4 w-4 text-red-600" /> por{' '}
|
||||||
<a href="#" target="_blank" rel="noopener noreferrer" className="text-reset">
|
<a href="#" target="_blank" rel="noopener noreferrer" className="text-reset">
|
||||||
Nextream
|
Nextream
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import feather from 'feather-icons';
|
import feather from 'feather-icons';
|
||||||
import Reveal from './Reveal'
|
import Reveal from './Reveal'
|
||||||
|
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
@ -130,7 +131,7 @@ export default function StreamingFeatures() {
|
|||||||
href="/features"
|
href="/features"
|
||||||
className="relative inline-block font-semibold tracking-wide align-middle text-base text-center border-none after:content-[''] after:absolute after:h-px after:w-0 hover:after:w-full after:end-0 hover:after:end-auto after:bottom-0 after:start-0 after:duration-500 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 after:bg-indigo-600 dark:after:bg-indigo-400 duration-500 ease-in-out transition-colors"
|
className="relative inline-block font-semibold tracking-wide align-middle text-base text-center border-none after:content-[''] after:absolute after:h-px after:w-0 hover:after:w-full after:end-0 hover:after:end-auto after:bottom-0 after:start-0 after:duration-500 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 after:bg-indigo-600 dark:after:bg-indigo-400 duration-500 ease-in-out transition-colors"
|
||||||
>
|
>
|
||||||
Ver Más <i className="uil uil-arrow-right"></i>
|
Ver Más <ArrowRightIcon className="inline-block h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,69 +17,156 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply font-sans text-base leading-relaxed bg-white dark:bg-slate-900 text-gray-900 dark:text-white;
|
/* replaced @apply font-sans text-base leading-relaxed bg-white dark:bg-slate-900 text-gray-900 dark:text-white */
|
||||||
|
font-family: 'Manrope', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.625;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #0f172a; /* approx gray-900 */
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@apply leading-relaxed;
|
line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||||
@apply leading-normal font-semibold;
|
line-height: 1.5;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@apply bg-indigo-600/90 text-white;
|
background-color: rgba(79, 70, 229, 0.9);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form inputs base styles */
|
/* Form inputs base styles */
|
||||||
.form-input {
|
.form-input {
|
||||||
@apply bg-white dark:bg-slate-900 border-gray-200 dark:border-gray-800 focus:border-indigo-600 focus:ring-0;
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e6e9ee; /* approx gray-200 */
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Techwind Software Components */
|
/* Techwind Software Components */
|
||||||
.container {
|
.container {
|
||||||
@apply mx-auto px-4;
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center font-semibold tracking-wide border align-middle duration-500 text-base text-center rounded-md;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
border-style: solid;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-indigo-600 hover:bg-indigo-700 border-indigo-600 hover:border-indigo-700 text-white;
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #4338ca; /* indigo-700 */
|
||||||
|
border-color: #4338ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-orange {
|
.btn-orange {
|
||||||
@apply bg-orange-500 hover:bg-orange-600 border-orange-500 hover:border-orange-600 text-white;
|
background-color: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #f97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feature Cards */
|
/* Feature Cards */
|
||||||
.feature-card {
|
.feature-card {
|
||||||
@apply group relative hover:bg-white dark:hover:bg-slate-900 hover:shadow dark:hover:shadow-gray-800 p-6 duration-500 rounded-xl overflow-hidden text-center;
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon-bg {
|
.feature-icon-bg {
|
||||||
@apply size-28 fill-indigo-600/5 dark:fill-white/5 mx-auto bg-indigo-600/5 rounded-full;
|
width: 7rem;
|
||||||
|
height: 7rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
display: block;
|
||||||
|
background-color: rgba(79, 70, 229, 0.05);
|
||||||
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pricing Cards */
|
/* Pricing Cards */
|
||||||
.pricing-card {
|
.pricing-card {
|
||||||
@apply p-6 bg-white dark:bg-slate-900 rounded-xl shadow dark:shadow-gray-800;
|
padding: 1.5rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 10px 15px rgba(2,6,23,0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-card-featured {
|
.pricing-card-featured {
|
||||||
@apply shadow-xl rounded-xl p-6 bg-gradient-to-t from-indigo-600 to-indigo-500 scale-105;
|
box-shadow: 0 25px 50px rgba(2,6,23,0.25);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-image: linear-gradient(to top, #4f46e5, #6366f1);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shape divider */
|
/* Shape divider */
|
||||||
.shape {
|
.shape {
|
||||||
@apply absolute sm:-bottom-px -bottom-[2px] start-0 end-0 overflow-hidden;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Broadcast create-card */
|
||||||
|
.create-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e6e9ee;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card .icon {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #0ea5e9;
|
||||||
|
border-bottom: 2px solid #0ea5e9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,10 +199,10 @@
|
|||||||
|
|
||||||
/* Broadcasts (Transmisiones) specific */
|
/* Broadcasts (Transmisiones) specific */
|
||||||
@layer components {
|
@layer components {
|
||||||
.create-card { @apply flex items-center gap-4 p-4 rounded-lg border border-slate-200 bg-white hover:shadow-sm text-left; }
|
.create-card { display:flex; align-items:center; gap:1rem; padding:1rem; border-radius:0.5rem; border:1px solid #e6e9ee; background-color:#ffffff; }
|
||||||
.create-card .icon { @apply w-12 h-12 rounded-md flex items-center justify-center text-xl; }
|
.create-card .icon { width:3rem; height:3rem; border-radius:0.375rem; display:flex; align-items:center; justify-content:center; font-size:1.25rem; }
|
||||||
.tab-btn { @apply px-3 py-1 rounded-md text-sm text-slate-600 hover:bg-slate-50; }
|
.tab-btn { padding:0.5rem 0.75rem; border-radius:0.375rem; font-size:0.875rem; color:#64748b; }
|
||||||
.tab-btn.active { @apply text-sky-600 border-b-2 border-sky-500; }
|
.tab-btn.active { color:#0ea5e9; border-bottom:2px solid #0ea5e9; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nextream-style Animations */
|
/* Nextream-style Animations */
|
||||||
@ -229,41 +316,51 @@
|
|||||||
/* Techwind Helper Styles */
|
/* Techwind Helper Styles */
|
||||||
/* Cookies */
|
/* Cookies */
|
||||||
.cookie-popup-not-accepted {
|
.cookie-popup-not-accepted {
|
||||||
@apply block;
|
display:block;
|
||||||
animation: cookie-popup-in .5s ease forwards;
|
animation: cookie-popup-in .5s ease forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cookie-popup-accepted {
|
.cookie-popup-accepted {
|
||||||
@apply hidden;
|
display:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cookie-popup-in {
|
@keyframes cookie-popup-in {
|
||||||
from {
|
from { bottom: -6.25rem; }
|
||||||
bottom: -6.25rem;
|
to { bottom: 1.25rem; }
|
||||||
}
|
|
||||||
to {
|
|
||||||
bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preloader */
|
/* Preloader */
|
||||||
#preloader {
|
#preloader {
|
||||||
background-image: linear-gradient(45deg, #ffffff, #ffffff);
|
background-image: linear-gradient(45deg, #ffffff, #ffffff);
|
||||||
z-index: 99999;
|
z-index: 99999;
|
||||||
@apply fixed inset-0;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preloader #status {
|
#preloader #status {
|
||||||
@apply absolute start-0 end-0 top-1/2 -translate-y-1/2;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#preloader #status .spinner {
|
#preloader #status .spinner {
|
||||||
@apply size-10 relative my-[100px] mx-auto;
|
width: 2.5rem; /* size-10 approx */
|
||||||
|
height: 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
margin: 100px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preloader #status .spinner .double-bounce1,
|
#preloader #status .spinner .double-bounce1,
|
||||||
#preloader #status .spinner .double-bounce2 {
|
#preloader #status .spinner .double-bounce2 {
|
||||||
@apply w-full h-full rounded-full bg-indigo-600/60 absolute top-0 start-0;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: rgba(79, 70, 229, 0.6);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
animation: sk-bounce 2.0s infinite ease-in-out;
|
animation: sk-bounce 2.0s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,11 +380,11 @@
|
|||||||
/* Switcher */
|
/* Switcher */
|
||||||
.label .ball {
|
.label .ball {
|
||||||
transition: transform 0.2s linear;
|
transition: transform 0.2s linear;
|
||||||
@apply translate-x-0;
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:checked + .label .ball {
|
.checkbox:checked + .label .ball {
|
||||||
@apply translate-x-6;
|
transform: translateX(1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mover animation */
|
/* Mover animation */
|
||||||
@ -306,7 +403,11 @@
|
|||||||
|
|
||||||
/* Background effect for hero sections */
|
/* Background effect for hero sections */
|
||||||
.background-effect .circles li {
|
.background-effect .circles li {
|
||||||
@apply absolute block -bottom-[150px] bg-indigo-600/30;
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
bottom: -150px;
|
||||||
|
/* indigo-600 = #4f46e5 -> 0.3 opacity */
|
||||||
|
background-color: rgba(79, 70, 229, 0.3);
|
||||||
animation: animate-circles 25s linear infinite;
|
animation: animate-circles 25s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,60 +434,72 @@
|
|||||||
.background-effect .circles li:nth-child(8),
|
.background-effect .circles li:nth-child(8),
|
||||||
.background-effect .circles li:nth-child(9),
|
.background-effect .circles li:nth-child(9),
|
||||||
.background-effect .circles li:nth-child(10) {
|
.background-effect .circles li:nth-child(10) {
|
||||||
@apply size-12;
|
/* Replaced @apply size-12 */
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(1) {
|
.background-effect .circles li:nth-child(1) {
|
||||||
@apply start-1/4;
|
/* Replaced @apply start-1/4 */
|
||||||
|
inset-inline-start: 25%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(2) {
|
.background-effect .circles li:nth-child(2) {
|
||||||
@apply start-[10%];
|
/* Replaced @apply start-[10%] */
|
||||||
|
inset-inline-start: 10%;
|
||||||
animation-delay: 2s;
|
animation-delay: 2s;
|
||||||
animation-duration: 12s;
|
animation-duration: 12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(3) {
|
.background-effect .circles li:nth-child(3) {
|
||||||
@apply start-[70%];
|
/* Replaced @apply start-[70%] */
|
||||||
|
inset-inline-start: 70%;
|
||||||
animation-delay: 4s;
|
animation-delay: 4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(4) {
|
.background-effect .circles li:nth-child(4) {
|
||||||
@apply start-[40%];
|
/* Replaced @apply start-[40%] */
|
||||||
|
inset-inline-start: 40%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
animation-duration: 18s;
|
animation-duration: 18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(5) {
|
.background-effect .circles li:nth-child(5) {
|
||||||
@apply start-[65%];
|
/* Replaced @apply start-[65%] */
|
||||||
|
inset-inline-start: 65%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(6) {
|
.background-effect .circles li:nth-child(6) {
|
||||||
@apply start-3/4;
|
/* Replaced @apply start-3/4 */
|
||||||
|
inset-inline-start: 75%;
|
||||||
animation-delay: 3s;
|
animation-delay: 3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(7) {
|
.background-effect .circles li:nth-child(7) {
|
||||||
@apply start-[35%];
|
/* Replaced @apply start-[35%] */
|
||||||
|
inset-inline-start: 35%;
|
||||||
animation-delay: 7s;
|
animation-delay: 7s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(8) {
|
.background-effect .circles li:nth-child(8) {
|
||||||
@apply start-1/2;
|
/* Replaced @apply start-1/2 */
|
||||||
|
inset-inline-start: 50%;
|
||||||
animation-delay: 15s;
|
animation-delay: 15s;
|
||||||
animation-duration: 45s;
|
animation-duration: 45s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(9) {
|
.background-effect .circles li:nth-child(9) {
|
||||||
@apply start-[20%];
|
/* Replaced @apply start-[20%] */
|
||||||
|
inset-inline-start: 20%;
|
||||||
animation-delay: 2s;
|
animation-delay: 2s;
|
||||||
animation-duration: 35s;
|
animation-duration: 35s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(10) {
|
.background-effect .circles li:nth-child(10) {
|
||||||
@apply start-[85%];
|
/* Replaced @apply start-[85%] */
|
||||||
|
inset-inline-start: 85%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
animation-duration: 11s;
|
animation-duration: 11s;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,17 @@
|
|||||||
|
|
||||||
/* Selection */
|
/* Selection */
|
||||||
::selection {
|
::selection {
|
||||||
@apply bg-indigo-600/90 text-white;
|
background-color: rgba(79, 70, 229, 0.9); /* indigo-600 */
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography defaults */
|
/* Typography defaults */
|
||||||
p {
|
p {
|
||||||
@apply leading-relaxed;
|
line-height: 1.625; /* approx tailwind 'leading-relaxed' */
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||||
@apply leading-normal;
|
line-height: 1.5; /* tailwind 'leading-normal' */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mover animation */
|
/* Mover animation */
|
||||||
@ -116,61 +117,84 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li {
|
.background-effect .circles li {
|
||||||
@apply absolute block -bottom-[150px] bg-indigo-600/30;
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
bottom: -150px;
|
||||||
|
background-color: rgba(79, 70, 229, 0.3); /* indigo-600 at 0.3 opacity */
|
||||||
animation: animate 25s linear infinite;
|
animation: animate 25s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(1) {
|
.background-effect .circles li:nth-child(1) {
|
||||||
@apply size-12 start-1/4;
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 25%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(2) {
|
.background-effect .circles li:nth-child(2) {
|
||||||
@apply size-12 start-[10%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 10%;
|
||||||
animation-delay: 2s;
|
animation-delay: 2s;
|
||||||
animation-duration: 12s;
|
animation-duration: 12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(3) {
|
.background-effect .circles li:nth-child(3) {
|
||||||
@apply size-12 start-[70%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 70%;
|
||||||
animation-delay: 4s;
|
animation-delay: 4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(4) {
|
.background-effect .circles li:nth-child(4) {
|
||||||
@apply size-12 start-[40%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 40%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
animation-duration: 18s;
|
animation-duration: 18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(5) {
|
.background-effect .circles li:nth-child(5) {
|
||||||
@apply size-12 start-[65%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 65%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(6) {
|
.background-effect .circles li:nth-child(6) {
|
||||||
@apply size-12 start-3/4;
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 75%;
|
||||||
animation-delay: 3s;
|
animation-delay: 3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(7) {
|
.background-effect .circles li:nth-child(7) {
|
||||||
@apply size-12 start-[35%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 35%;
|
||||||
animation-delay: 7s;
|
animation-delay: 7s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(8) {
|
.background-effect .circles li:nth-child(8) {
|
||||||
@apply size-12 start-1/2;
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 50%;
|
||||||
animation-delay: 15s;
|
animation-delay: 15s;
|
||||||
animation-duration: 45s;
|
animation-duration: 45s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(9) {
|
.background-effect .circles li:nth-child(9) {
|
||||||
@apply size-12 start-[20%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 20%;
|
||||||
animation-delay: 2s;
|
animation-delay: 2s;
|
||||||
animation-duration: 35s;
|
animation-duration: 35s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-effect .circles li:nth-child(10) {
|
.background-effect .circles li:nth-child(10) {
|
||||||
@apply size-12 start-[85%];
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
left: 85%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
animation-duration: 11s;
|
animation-duration: 11s;
|
||||||
}
|
}
|
||||||
@ -210,7 +234,13 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
|||||||
|
|
||||||
.spinner .double-bounce1,
|
.spinner .double-bounce1,
|
||||||
.spinner .double-bounce2 {
|
.spinner .double-bounce2 {
|
||||||
@apply w-full h-full rounded-full bg-indigo-600/60 absolute top-0 start-0;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: rgba(79, 70, 229, 0.6);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
animation: sk-bounce 2.0s infinite ease-in-out;
|
animation: sk-bounce 2.0s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,24 +251,31 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
|||||||
/* Dark mode toggle switch */
|
/* Dark mode toggle switch */
|
||||||
.label .ball {
|
.label .ball {
|
||||||
transition: transform 0.2s linear;
|
transition: transform 0.2s linear;
|
||||||
@apply translate-x-0;
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:checked + .label .ball {
|
.checkbox:checked + .label .ball {
|
||||||
@apply translate-x-6;
|
transform: translateX(1.5rem); /* approx translate-x-6 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Testimonial navigation dots (pagination) */
|
/* Testimonial navigation dots (pagination) */
|
||||||
.tns-nav {
|
.tns-nav {
|
||||||
@apply text-center mt-3;
|
text-align: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tns-nav button {
|
.tns-nav button {
|
||||||
@apply rounded-[3px] bg-indigo-600/30 duration-500 border-0 m-1 p-[5px];
|
border-radius: 3px;
|
||||||
|
background-color: rgba(79,70,229,0.3);
|
||||||
|
transition-duration: 0.5s;
|
||||||
|
border: 0;
|
||||||
|
margin: 0.25rem;
|
||||||
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tns-nav button.tns-nav-active {
|
.tns-nav button.tns-nav-active {
|
||||||
@apply bg-indigo-600 rotate-[45deg];
|
background-color: rgba(79, 70, 229, 1);
|
||||||
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth infinite scroll slider */
|
/* Smooth infinite scroll slider */
|
||||||
|
|||||||
@ -7,7 +7,8 @@ module.exports = {
|
|||||||
content: [
|
content: [
|
||||||
'./index.html',
|
'./index.html',
|
||||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'../shared-components/src/**/*.{js,ts,jsx,tsx}',
|
// Scan shared components and utilities in the monorepo (fix: was ../shared-components which is not present)
|
||||||
|
'../../shared/**/*.{js,ts,jsx,tsx,mdx}'
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
...sharedConfig.theme,
|
...sharedConfig.theme,
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const reactPlugin: any = react()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [reactPlugin],
|
||||||
server: {
|
server: {
|
||||||
port: 5173
|
// Use a fixed port that is available in the current environment
|
||||||
}
|
port: 5174,
|
||||||
|
// Allow Vite to access shared folder during development (monorepo)
|
||||||
|
fs: {
|
||||||
|
allow: [path.resolve(__dirname), path.resolve(__dirname, '../../shared')],
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
.DS_Store
|
|
||||||
coverage
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.md
|
|
||||||
src
|
|
||||||
public
|
|
||||||
index.html
|
|
||||||
vite.config.ts
|
|
||||||
tsconfig.json
|
|
||||||
postcss.config.cjs
|
|
||||||
tailwind.config.cjs
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
# LiveKit Configuration
|
|
||||||
VITE_LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host
|
|
||||||
VITE_LIVEKIT_API_KEY=devkey
|
|
||||||
VITE_LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
# Servidor de Tokens LiveKit - Docker
|
|
||||||
|
|
||||||
Este directorio contiene el servidor de tokens LiveKit dockerizado para AvanzaCast.
|
|
||||||
|
|
||||||
## 🚀 Inicio Rápido
|
|
||||||
|
|
||||||
### Opción 1: Usando Docker Compose (Recomendado)
|
|
||||||
|
|
||||||
Desde la raíz del proyecto:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Construir e iniciar el servidor
|
|
||||||
docker-compose up -d livekit-token-server
|
|
||||||
|
|
||||||
# Ver logs
|
|
||||||
docker-compose logs -f livekit-token-server
|
|
||||||
|
|
||||||
# Detener el servidor
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Opción 2: Usando Docker directamente
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd packages/studio-panel
|
|
||||||
|
|
||||||
# Construir la imagen
|
|
||||||
docker build -t avanzacast-token-server .
|
|
||||||
|
|
||||||
# Ejecutar el contenedor
|
|
||||||
docker run -d \
|
|
||||||
--name avanzacast-token-server \
|
|
||||||
-p 3010:3010 \
|
|
||||||
-e LIVEKIT_API_KEY=devkey \
|
|
||||||
-e LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret \
|
|
||||||
-e LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host \
|
|
||||||
avanzacast-token-server
|
|
||||||
|
|
||||||
# Ver logs
|
|
||||||
docker logs -f avanzacast-token-server
|
|
||||||
|
|
||||||
# Detener el contenedor
|
|
||||||
docker stop avanzacast-token-server
|
|
||||||
docker rm avanzacast-token-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Variables de Entorno
|
|
||||||
|
|
||||||
El servidor requiere las siguientes variables de entorno:
|
|
||||||
|
|
||||||
- `LIVEKIT_API_KEY`: API Key de LiveKit
|
|
||||||
- `LIVEKIT_API_SECRET`: Secret de LiveKit
|
|
||||||
- `LIVEKIT_URL`: URL del servidor LiveKit (wss://...)
|
|
||||||
- `PORT`: Puerto del servidor (default: 3010)
|
|
||||||
|
|
||||||
## 📡 Endpoints
|
|
||||||
|
|
||||||
Una vez iniciado, el servidor estará disponible en:
|
|
||||||
|
|
||||||
- **Health Check**: `http://localhost:3010/health`
|
|
||||||
- **Generación de Tokens**: `http://localhost:3010/api/token?room=ROOM_NAME&username=USERNAME`
|
|
||||||
|
|
||||||
### Ejemplo de uso:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar salud del servidor
|
|
||||||
curl http://localhost:3010/health
|
|
||||||
|
|
||||||
# Generar token
|
|
||||||
curl "http://localhost:3010/api/token?room=mi-sala&username=usuario1"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Monitoreo
|
|
||||||
|
|
||||||
### Ver estado del contenedor
|
|
||||||
```bash
|
|
||||||
docker ps | grep avanzacast-token-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver logs en tiempo real
|
|
||||||
```bash
|
|
||||||
docker logs -f avanzacast-token-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verificar health check
|
|
||||||
```bash
|
|
||||||
docker inspect --format='{{.State.Health.Status}}' avanzacast-token-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Actualización
|
|
||||||
|
|
||||||
Para actualizar el servidor después de cambios en el código:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Detener y eliminar el contenedor actual
|
|
||||||
docker-compose down livekit-token-server
|
|
||||||
|
|
||||||
# Reconstruir la imagen
|
|
||||||
docker-compose build livekit-token-server
|
|
||||||
|
|
||||||
# Iniciar nuevamente
|
|
||||||
docker-compose up -d livekit-token-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### El contenedor no inicia
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ver logs de error
|
|
||||||
docker logs avanzacast-token-server
|
|
||||||
|
|
||||||
# Verificar que las variables de entorno estén configuradas
|
|
||||||
docker exec avanzacast-token-server env | grep LIVEKIT
|
|
||||||
```
|
|
||||||
|
|
||||||
### Puerto 3002 ya en uso
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verificar qué está usando el puerto
|
|
||||||
lsof -i :3002
|
|
||||||
|
|
||||||
# Detener el proceso que usa el puerto
|
|
||||||
kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reiniciar el contenedor
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker restart avanzacast-token-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Comandos Útiles
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Entrar al contenedor
|
|
||||||
docker exec -it avanzacast-token-server sh
|
|
||||||
|
|
||||||
# Ver uso de recursos
|
|
||||||
docker stats avanzacast-token-server
|
|
||||||
|
|
||||||
# Eliminar completamente (contenedor e imagen)
|
|
||||||
docker-compose down --rmi all
|
|
||||||
docker rmi avanzacast-token-server
|
|
||||||
```
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# Dockerfile para el servidor de tokens LiveKit
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
# Instalar wget para healthcheck
|
|
||||||
RUN apk add --no-cache wget
|
|
||||||
|
|
||||||
# Establecer directorio de trabajo
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copiar package.json específico del servidor
|
|
||||||
COPY server-package.json package.json
|
|
||||||
|
|
||||||
# Instalar dependencias
|
|
||||||
RUN npm install --production
|
|
||||||
|
|
||||||
# Copiar el archivo del servidor
|
|
||||||
COPY server.js ./
|
|
||||||
|
|
||||||
# Exponer el puerto 3010
|
|
||||||
EXPOSE 3010
|
|
||||||
|
|
||||||
# Variables de entorno por defecto
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3010
|
|
||||||
|
|
||||||
# Healthcheck
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost:3010/health || exit 1
|
|
||||||
|
|
||||||
# Comando para iniciar el servidor
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
14
packages/studio-panel/README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Studio Panel
|
||||||
|
|
||||||
|
Panel de video conferencia para AvanzaCast. Esta carpeta contiene un pequeño proyecto React + Vite que usa Tailwind v4 y componentes adaptados desde `vristo`.
|
||||||
|
|
||||||
|
Para correr localmente:
|
||||||
|
|
||||||
|
1. cd packages/studio-panel
|
||||||
|
2. npm install
|
||||||
|
3. npm run dev
|
||||||
|
|
||||||
|
Notas:
|
||||||
|
- Tailwind v4 fue seleccionado por requerimiento; ajusta versiones en `package.json` según tu repositorio.
|
||||||
|
- Componentes incluidos: Button, Avatar, VideoTile, VideoGrid, ControlBar
|
||||||
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
# LiveKit Broadcast Studio UI - Documentación
|
|
||||||
|
|
||||||
## 📋 Descripción General
|
|
||||||
|
|
||||||
Sistema de interfaz de usuario para un estudio de producción de video en vivo estilo StreamYard, construido con React, TypeScript y LiveKit Components.
|
|
||||||
|
|
||||||
## 🏗️ Arquitectura
|
|
||||||
|
|
||||||
### Estructura de Componentes
|
|
||||||
|
|
||||||
```
|
|
||||||
BroadcastStudio (Contenedor Principal)
|
|
||||||
├── SceneProvider (Context)
|
|
||||||
│ ├── StreamView (Visualización - CONSUMIDOR)
|
|
||||||
│ │ ├── Layouts dinámicos basados en sceneConfig
|
|
||||||
│ │ ├── Renderizado de participantes (LiveKit)
|
|
||||||
│ │ └── Overlays (logos, lower thirds)
|
|
||||||
│ │
|
|
||||||
│ └── ControlPanel (Controles - MODIFICADOR)
|
|
||||||
│ ├── LocalControls (Izquierda - Vista local + Presentar)
|
|
||||||
│ ├── ScrollableLayoutsContainer (Centro - Botones de layouts)
|
|
||||||
│ └── ActionControls (Derecha - Config y recursos)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sistema de Estado (SceneContext)
|
|
||||||
|
|
||||||
**Ubicación:** `src/context/SceneContext.tsx`
|
|
||||||
|
|
||||||
El contexto centralizado gestiona toda la configuración de escenas:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SceneConfig {
|
|
||||||
participantLayout: ParticipantLayoutType // Tipo de layout activo
|
|
||||||
mediaSource: MediaSourceType | null // Contenido adicional (screen, file, etc)
|
|
||||||
overlays: OverlayConfig // Logos, lower thirds, etc
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Regla de oro:**
|
|
||||||
- **ControlPanel**: Único componente que MODIFICA `sceneConfig`
|
|
||||||
- **StreamView**: Único componente que CONSUME `sceneConfig` para renderizar
|
|
||||||
|
|
||||||
## 🎨 Componentes Principales
|
|
||||||
|
|
||||||
### 1. StreamView
|
|
||||||
**Archivo:** `src/components/broadcast/StreamView.tsx`
|
|
||||||
|
|
||||||
Renderiza la salida final de video en formato 16:9.
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
- Aspecto ratio fijo 16:9 (`aspect-ratio: 16 / 9`)
|
|
||||||
- 6 layouts predefinidos:
|
|
||||||
- `grid_4`: Grid 2×2
|
|
||||||
- `grid_6`: Grid 3×2
|
|
||||||
- `focus_side`: Foco principal + sidebar
|
|
||||||
- `side_by_side`: Dos participantes lado a lado
|
|
||||||
- `presentation`: Pantalla compartida + speaker pequeño
|
|
||||||
- `single_speaker`: Un solo participante
|
|
||||||
- Sistema de overlays configurable
|
|
||||||
- Integración con hooks de LiveKit (`useParticipants`, `useTracks`)
|
|
||||||
|
|
||||||
### 2. ControlPanel
|
|
||||||
**Archivo:** `src/components/broadcast/ControlPanel.tsx`
|
|
||||||
|
|
||||||
Panel de control interactivo dividido en 3 secciones.
|
|
||||||
|
|
||||||
**Estructura CSS:**
|
|
||||||
```css
|
|
||||||
.control-panel-wrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.1 LocalControls (Izquierda)
|
|
||||||
- Vista previa del usuario local
|
|
||||||
- Botón "Presentar"
|
|
||||||
- `flex-shrink: 0` (ancho fijo)
|
|
||||||
|
|
||||||
#### 2.2 ScrollableLayoutsContainer (Centro)
|
|
||||||
- Scroll horizontal de botones de layouts
|
|
||||||
- `flex-grow: 1; overflow-x: auto; overflow-y: hidden`
|
|
||||||
- Botones con `flex-shrink: 0` en fila única
|
|
||||||
|
|
||||||
#### 2.3 ActionControls (Derecha)
|
|
||||||
- Botones de acción (Editor, Config, Añadir)
|
|
||||||
- `flex-shrink: 0` (ancho fijo)
|
|
||||||
|
|
||||||
### 3. BroadcastStudio
|
|
||||||
**Archivo:** `src/components/broadcast/BroadcastStudio.tsx`
|
|
||||||
|
|
||||||
Contenedor principal que alinea todo.
|
|
||||||
|
|
||||||
**CSS clave:**
|
|
||||||
```css
|
|
||||||
.main-app-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream-view-container,
|
|
||||||
.control-panel-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px; /* Mismo ancho máximo para ambos */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. LiveKitBroadcastWrapper
|
|
||||||
**Archivo:** `src/components/broadcast/LiveKitBroadcastWrapper.tsx`
|
|
||||||
|
|
||||||
Wrapper que conecta el BroadcastStudio con LiveKit.
|
|
||||||
|
|
||||||
## 🔧 Uso
|
|
||||||
|
|
||||||
### Integración Básica
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { LiveKitBroadcastWrapper } from './components/broadcast'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<LiveKitBroadcastWrapper
|
|
||||||
token="your-livekit-token"
|
|
||||||
serverUrl="wss://your-server.com"
|
|
||||||
userName="Usuario"
|
|
||||||
roomName="sala-demo"
|
|
||||||
onDisconnect={() => console.log('Desconectado')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uso Standalone (sin LiveKit)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { BroadcastStudio } from './components/broadcast'
|
|
||||||
import { LiveKitRoom } from '@livekit/components-react'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<LiveKitRoom token={token} serverUrl={serverUrl}>
|
|
||||||
<BroadcastStudio />
|
|
||||||
</LiveKitRoom>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Acceso al Contexto de Escenas
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useScene } from './context/SceneContext'
|
|
||||||
|
|
||||||
function MiComponente() {
|
|
||||||
const { sceneConfig, applyPreset, updateOverlays } = useScene()
|
|
||||||
|
|
||||||
// Aplicar un preset
|
|
||||||
const handleChangeLayout = () => {
|
|
||||||
applyPreset('FOCUS_SIDE')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actualizar overlays
|
|
||||||
const handleToggleLogo = () => {
|
|
||||||
updateOverlays({ showLogo: !sceneConfig.overlays.showLogo })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 Presets de Layouts
|
|
||||||
|
|
||||||
Definidos en `SceneContext.tsx`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
PRESET_LAYOUTS = {
|
|
||||||
GRID_4: { participantLayout: 'grid_4', ... },
|
|
||||||
GRID_6: { participantLayout: 'grid_6', ... },
|
|
||||||
FOCUS_SIDE: { participantLayout: 'focus_side', ... },
|
|
||||||
SIDE_BY_SIDE: { participantLayout: 'side_by_side', ... },
|
|
||||||
PRESENTATION: { participantLayout: 'presentation', ... },
|
|
||||||
SINGLE_SPEAKER: { participantLayout: 'single_speaker', ... },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Próximos Pasos
|
|
||||||
|
|
||||||
### Features Pendientes
|
|
||||||
- [ ] Integración con LiveKit Egress para grabación/streaming
|
|
||||||
- [ ] Editor visual de escenas (modal con drag & drop)
|
|
||||||
- [ ] Gestión de overlays personalizado
|
|
||||||
- [ ] Soporte para múltiples cámaras por participante
|
|
||||||
- [ ] Transiciones animadas entre layouts
|
|
||||||
- [ ] Guardado/carga de escenas personalizadas
|
|
||||||
|
|
||||||
### Mejoras de UX
|
|
||||||
- [ ] Tooltips informativos en botones de layout
|
|
||||||
- [ ] Preview en miniatura de cada layout
|
|
||||||
- [ ] Keyboard shortcuts para cambio rápido
|
|
||||||
- [ ] Indicador visual del layout activo más prominente
|
|
||||||
- [ ] Confirmación antes de cambios críticos
|
|
||||||
|
|
||||||
## 🐛 Debugging
|
|
||||||
|
|
||||||
### Verificar estado de escenas
|
|
||||||
```tsx
|
|
||||||
// En DevTools Console:
|
|
||||||
window.__SCENE_DEBUG__ = true
|
|
||||||
|
|
||||||
// O agregar en tu componente:
|
|
||||||
console.log('[SceneDebug]', sceneConfig)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs de LiveKit
|
|
||||||
Los hooks de LiveKit (`useParticipants`, `useTracks`) ya incluyen logs internos.
|
|
||||||
Para más detalle, habilitar en LiveKitRoom:
|
|
||||||
```tsx
|
|
||||||
<LiveKitRoom logLevel="debug" ... />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Notas de Implementación
|
|
||||||
|
|
||||||
1. **Aspecto Ratio:** StreamView usa `aspect-ratio: 16/9` nativo de CSS (compatibilidad moderna)
|
|
||||||
2. **Scroll Horizontal:** El scroll en LayoutsContainer es solo horizontal para mejor UX
|
|
||||||
3. **Flexibilidad:** Todos los componentes son modulares y pueden usarse independientemente
|
|
||||||
4. **Performance:** Los layouts se renderizan condicionalmente para evitar re-renders innecesarios
|
|
||||||
5. **TypeScript:** Todo el código está fuertemente tipado para mejor DX
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Creado el:** 7 de noviembre de 2025
|
|
||||||
**Versión:** 1.0.0
|
|
||||||
**Stack:** React + TypeScript + LiveKit + Tailwind CSS
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
// Ejemplo de cómo integrar BroadcastStudio en Studio.tsx existente
|
|
||||||
|
|
||||||
import { LiveKitBroadcastWrapper } from './broadcast'
|
|
||||||
|
|
||||||
// Reemplazar la sección de render actual con:
|
|
||||||
|
|
||||||
// Opción 1: Reemplazar completamente StudioVideoArea con BroadcastStudio
|
|
||||||
{!isDemoMode && token && serverUrl && (
|
|
||||||
<LiveKitBroadcastWrapper
|
|
||||||
token={token}
|
|
||||||
serverUrl={serverUrl}
|
|
||||||
userName={userName}
|
|
||||||
roomName={roomName}
|
|
||||||
onDisconnect={() => {
|
|
||||||
console.log('Desconectado del estudio')
|
|
||||||
setIsDemoMode(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
// Opción 2: Usar solo dentro del LiveKitRoom existente
|
|
||||||
<LiveKitRoom token={token} serverUrl={serverUrl} ...>
|
|
||||||
<BroadcastStudio />
|
|
||||||
</LiveKitRoom>
|
|
||||||
|
|
||||||
// Opción 3: Modo híbrido - Toggle entre vista clásica y BroadcastStudio
|
|
||||||
const [useBroadcastUI, setUseBroadcastUI] = useState(false)
|
|
||||||
|
|
||||||
{useBroadcastUI ? (
|
|
||||||
<BroadcastStudio />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<StudioVideoArea ... />
|
|
||||||
<StudioControls ... />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
@ -1,10 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="es">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AvanzaCast Studio - Streaming en Vivo</title>
|
<title>Studio Panel</title>
|
||||||
|
<!-- Enlace directo al CSS en dev para asegurar estilos aunque la inyección via JS falle -->
|
||||||
|
<link rel="stylesheet" href="/src/styles/globals.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,49 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "@avanzacast/broadcast-studio",
|
"name": "@avanzacast/studio-panel",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "AvanzaCast - Broadcast Studio",
|
"description": "Panel de video conferencia (Studio Panel) para AvanzaCast - basado en componentes vristo adaptados a Tailwind v4",
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3020",
|
"dev": "vite",
|
||||||
"dev:vite": "vite --port 3020",
|
|
||||||
"dev:server": "node server.js",
|
|
||||||
"dev:full": "concurrently \"npm run dev:vite\" \"npm run dev:server\"",
|
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 3001",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"test": "vitest"
|
||||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
||||||
"docker:build": "docker build -t avanzacast-token-server .",
|
|
||||||
"docker:run": "docker run -d --name avanzacast-token-server -p 3002:3002 --env-file ../../.env avanzacast-token-server",
|
|
||||||
"docker:stop": "docker stop avanzacast-token-server && docker rm avanzacast-token-server",
|
|
||||||
"docker:logs": "docker logs -f avanzacast-token-server"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
|
||||||
"@livekit/components-react": "^2.9.15",
|
|
||||||
"@livekit/components-styles": "^1.1.6",
|
|
||||||
"concurrently": "^9.2.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"livekit-client": "^2.15.14",
|
|
||||||
"livekit-server-sdk": "^2.14.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0"
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-router-dom": "^6.30.1",
|
|
||||||
"socket.io-client": "^4.6.2",
|
|
||||||
"zustand": "^4.4.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.4",
|
"typescript": "^5.5.0",
|
||||||
"@types/react": "^18.0.28",
|
"vite": "^4.1.0",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
"tailwindcss": "^4.1.17",
|
||||||
"autoprefixer": "^10.4.14",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"postcss": "^8.4.23",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^4.1.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"typescript": "^5.2.2",
|
"vitest": "^1.1.8",
|
||||||
"vite": "^4.3.9"
|
"@testing-library/react": "^14.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
|
"@testing-library/user-event": "^14.4.3"
|
||||||
|
},
|
||||||
|
"vitest": {
|
||||||
|
"test": {
|
||||||
|
"environment": "jsdom",
|
||||||
|
"setupFiles": "./src/setupTests.ts",
|
||||||
|
"globals": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
// use the PostCSS plugin package for Tailwind v4
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 231 KiB |
@ -1,9 +0,0 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<rect width="14" height="14" fill="url(#pattern0_1_28)"/>
|
|
||||||
<defs>
|
|
||||||
<pattern id="pattern0_1_28" patternContentUnits="objectBoundingBox" width="1" height="1">
|
|
||||||
<use xlink:href="#image0_1_28" transform="scale(0.0178571)"/>
|
|
||||||
</pattern>
|
|
||||||
<image id="image0_1_28" width="56" height="56" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAACRUlEQVR4nO2Z0YkbMRCGv5ldXwGGgyMvKeDKSFMp4epwBWkgZaSAvISDgCGXt7M1fx6kXTuQe4q0joJ+WCx7F2m+ndGMLEFDSbpPSYfzWS8pSRH5Skkqvx0k3be0wVt2/i+oOWAEmIF7/rxuR7QefXiwfw3A3jUAe9cA7F0DsHcNwN41AHvXAOxdtwFUuTYYfW4/ROZYmCA3NuLbBnCVQL+RtldrwFfAEziBACuuFOBhuOdnmmluuG0n4N08s4/ELoRhLF40N3azswfeS/oGWAsjLCUdoPoOlwGTO3t3HlPiIYJ5DU0DN87TzHMEXyI4Askd1bLDywS381kvkLfxKsokHNgBFmJSYCrGm4M7MiMJJHFyIwDVskPlZVpKuemVU5oECoiSWCLydzPAMqRlT+YJWcBq2bFEwlzZc6vKdMsTq2ROLfXBMtUCtUDXfMcLV5tStITHG7euGJtXjHmN1ZqeFMguxmuZ8LIVbqHX1ctY79UwofQ7S/yEylk0MNklyQATnmugL6Ep5EaKHLkniZAhq2THCmjGJ2hQJlTKBDwKHtxKtJSB3UiT8WzkMiFIbg3KROtCT+IpwQecO5GzqgkcXifjM85HoFmhn83se4uOASSdzuLocHJjF5TVTCAFpxBHd76a2Y9WNrRei94BgROU/HGVb4Jc3O9aGrDp/8E1eXppjAPQv9fmgI0WTm/qJh7cMEJHiHavAdi7BmDvGoC9awD2rgHYuwZg7/rvAZsfgLpfzibsarNXqn8e8sfx2w9xW/0CBRgA1K7WEMAAAAAASUVORK5CYII="/>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
@ -1,9 +0,0 @@
|
|||||||
<svg width="23" height="19" viewBox="0 0 23 19" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<rect width="23" height="19" fill="url(#pattern0_1_106)"/>
|
|
||||||
<defs>
|
|
||||||
<pattern id="pattern0_1_106" patternContentUnits="objectBoundingBox" width="1" height="1">
|
|
||||||
<use xlink:href="#image0_1_106" transform="matrix(0.0111633 0 0 0.0135135 -0.00235018 0)"/>
|
|
||||||
</pattern>
|
|
||||||
<image id="image0_1_106" width="90" height="74" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
@ -1,9 +0,0 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<rect width="20" height="20" fill="url(#pattern0_1_117)"/>
|
|
||||||
<defs>
|
|
||||||
<pattern id="pattern0_1_117" patternContentUnits="objectBoundingBox" width="1" height="1">
|
|
||||||
<use xlink:href="#image0_1_117" transform="scale(0.0125)"/>
|
|
||||||
</pattern>
|
|
||||||
<image id="image0_1_117" width="80" height="80" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "livekit-token-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Servidor de generación de tokens JWT para LiveKit",
|
|
||||||
"type": "module",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"livekit-server-sdk": "^2.14.0",
|
|
||||||
"dotenv": "^17.2.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
import express from 'express'
|
|
||||||
import cors from 'cors'
|
|
||||||
import { AccessToken } from 'livekit-server-sdk'
|
|
||||||
import * as dotenv from 'dotenv'
|
|
||||||
|
|
||||||
// Cargar variables de entorno
|
|
||||||
dotenv.config({ path: '../../.env' })
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
const port = 3010
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(cors())
|
|
||||||
app.use(express.json())
|
|
||||||
|
|
||||||
// Configuración de LiveKit desde variables de entorno
|
|
||||||
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'devkey'
|
|
||||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret'
|
|
||||||
const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
|
|
||||||
|
|
||||||
console.log('🚀 Servidor de tokens iniciado')
|
|
||||||
console.log('📡 LiveKit URL:', LIVEKIT_URL)
|
|
||||||
console.log('🔑 API Key:', LIVEKIT_API_KEY)
|
|
||||||
|
|
||||||
// Endpoint para generar tokens
|
|
||||||
app.get('/api/token', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { room, username } = req.query
|
|
||||||
|
|
||||||
if (!room || !username) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Se requieren los parámetros room y username'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crear token de acceso
|
|
||||||
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
|
|
||||||
identity: username,
|
|
||||||
name: username,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Agregar permisos
|
|
||||||
at.addGrant({
|
|
||||||
room: room,
|
|
||||||
roomJoin: true,
|
|
||||||
canPublish: true,
|
|
||||||
canPublishData: true,
|
|
||||||
canSubscribe: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Generar JWT
|
|
||||||
const token = await at.toJwt()
|
|
||||||
|
|
||||||
console.log(`✅ Token generado para usuario: ${username} en sala: ${room}`)
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
token,
|
|
||||||
serverUrl: LIVEKIT_URL,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error generando token:', error)
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Error al generar token',
|
|
||||||
details: error.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Endpoint de salud
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
livekit: {
|
|
||||||
url: LIVEKIT_URL,
|
|
||||||
apiKey: LIVEKIT_API_KEY,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = app.listen(port, () => {
|
|
||||||
console.log(`🎙️ Servidor corriendo en http://localhost:${port}`)
|
|
||||||
console.log(`📋 Endpoint de tokens: http://localhost:${port}/api/token?room=ROOM_NAME&username=USERNAME`)
|
|
||||||
console.log(`💚 Health check: http://localhost:${port}/health`)
|
|
||||||
})
|
|
||||||
|
|
||||||
server.on('error', (error) => {
|
|
||||||
console.error('❌ Error del servidor:', error)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
console.log('🛑 SIGTERM recibido, cerrando servidor...')
|
|
||||||
server.close(() => {
|
|
||||||
console.log('✅ Servidor cerrado')
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('🛑 SIGINT recibido, cerrando servidor...')
|
|
||||||
server.close(() => {
|
|
||||||
console.log('✅ Servidor cerrado')
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,15 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import StudioLayout from './layouts/StudioLayout'
|
import StudioLayout from './components/StudioLayout'
|
||||||
|
|
||||||
const App: React.FC = () => {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<StudioLayout>
|
<StudioLayout />
|
||||||
<div className="p-4 text-white">
|
|
||||||
<h1 className="text-2xl font-semibold">AvanzaCast - Studio</h1>
|
|
||||||
<p className="text-sm text-gray-300 mt-2">Panel de pruebas del estudio.</p>
|
|
||||||
</div>
|
|
||||||
</StudioLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
38
packages/studio-panel/src/components/Avatar.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
size?: number
|
||||||
|
status?: 'online' | 'offline' | 'away'
|
||||||
|
bgColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Avatar({ src, alt = 'avatar', size = 40, status = 'offline', bgColor }: Props) {
|
||||||
|
const initials = alt?.split(' ').map(s => s[0]).join('').slice(0, 2).toUpperCase()
|
||||||
|
const statusColor = status === 'online' ? 'bg-green-400' : status === 'away' ? 'bg-yellow-400' : 'bg-gray-500'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative inline-flex items-center justify-center rounded-full overflow-hidden ring-1 ring-gray-700"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
title={alt}
|
||||||
|
role="img"
|
||||||
|
aria-label={alt}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
// lazy image with alt and object-fit
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={src} alt={alt} style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`w-full h-full flex items-center justify-center ${bgColor ?? 'bg-gray-700'} text-white font-semibold`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{initials || alt[0]?.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full ring-2 ring-black ${statusColor}`} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
packages/studio-panel/src/components/BottomControls.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import IconCameraOn from './icons/IconCameraOn'
|
||||||
|
import IconMicOff from './icons/IconMicOff'
|
||||||
|
|
||||||
|
export default function BottomControls(){
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 right-4 flex items-center justify-center gap-4">
|
||||||
|
<div className="bg-black/70 rounded-md px-4 py-3 flex items-center gap-3">
|
||||||
|
<button className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center"><IconMicOff /></button>
|
||||||
|
<button className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center"><IconCameraOn /></button>
|
||||||
|
<button className="w-12 h-12 rounded-full bg-red-600 text-white flex items-center justify-center">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
20
packages/studio-panel/src/components/Button.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: 'primary' | 'ghost' | 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({ variant = 'primary', className = '', children, ...rest }: Props) {
|
||||||
|
const base = 'inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium focus:outline-none'
|
||||||
|
const variants: Record<string, string> = {
|
||||||
|
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||||
|
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className={`${base} ${variants[variant]} ${className}`} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
64
packages/studio-panel/src/components/ChatPanel.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export function ChatPanel() {
|
||||||
|
const [messages, setMessages] = useState<{ id: number; text: string; time: number; self?: boolean }[]>([])
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const msg = { id: Date.now(), text: trimmed, time: Date.now(), self: true }
|
||||||
|
setMessages(prev => [...prev, msg])
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-scroll to bottom when messages change
|
||||||
|
const el = listRef.current
|
||||||
|
if (el) {
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-80 border-l border-gray-800 p-3 bg-gray-900 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold">Chat</h3>
|
||||||
|
<div className="text-xs text-gray-400">{messages.length} mensajes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="flex-1 overflow-auto mb-3 space-y-3" style={{ maxHeight: '60vh' }} aria-live="polite">
|
||||||
|
{messages.length === 0 && <div className="text-center text-gray-500 text-sm">No hay mensajes aún</div>}
|
||||||
|
{messages.map(m => (
|
||||||
|
<div key={m.id} className={`flex items-end ${m.self ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={
|
||||||
|
`max-w-[80%] p-2 rounded-lg text-sm leading-tight break-words ${m.self ? 'bg-blue-600 text-white rounded-br-none' : 'bg-gray-800 text-gray-200 rounded-bl-none'}`
|
||||||
|
}>
|
||||||
|
<div className="whitespace-pre-wrap">{m.text}</div>
|
||||||
|
<div className="text-[11px] text-gray-300 mt-1 text-right">{new Date(m.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-auto" onSubmit={(e) => { e.preventDefault(); send() }}>
|
||||||
|
<label htmlFor="chat-input" className="sr-only">Escribe un mensaje</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
id="chat-input"
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
||||||
|
placeholder="Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva línea)"
|
||||||
|
className="flex-1 px-3 py-2 rounded bg-gray-800 text-white text-sm resize-none h-12 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
aria-label="Escribe un mensaje"
|
||||||
|
/>
|
||||||
|
<button type="submit" onClick={send} className="px-3 py-2 rounded bg-blue-600 hover:bg-blue-700 transition text-sm">Enviar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatPanel
|
||||||
14
packages/studio-panel/src/components/ControlBar.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function ControlBar() {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-gray-800/70 backdrop-blur-md rounded-full px-4 py-3 flex items-center gap-3 z-50 shadow-lg transition-all">
|
||||||
|
<button aria-label="Toggle microphone" className="w-12 h-12 rounded-full bg-red-600 text-white flex items-center justify-center hover:scale-105 transition transform" title="Micrófono">🎤</button>
|
||||||
|
<button aria-label="Toggle camera" className="w-12 h-12 rounded-full bg-white/10 text-white flex items-center justify-center hover:scale-105 transition transform" title="Cámara">📷</button>
|
||||||
|
<button aria-label="Share screen" className="w-12 h-12 rounded-full bg-white/10 text-white flex items-center justify-center hover:scale-105 transition transform" title="Compartir pantalla">🖥️</button>
|
||||||
|
<button aria-label="Record" className="w-12 h-12 rounded-full bg-yellow-500 text-black flex items-center justify-center hover:scale-105 transition transform" title="Grabar">⏺</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlBar
|
||||||
23
packages/studio-panel/src/components/Header.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<header className="w-full bg-gradient-to-r from-slate-900 to-gray-900 py-3 px-6 border-b border-gray-800 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-yellow-400 rounded-full flex items-center justify-center font-bold text-black">AC</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-semibold">AvanzaCast - Studio</div>
|
||||||
|
<div className="text-xs text-gray-400">Transmisión en vivo • Panel de producción</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button className="px-3 py-1 bg-blue-600 rounded">Agregar destino</button>
|
||||||
|
<button className="px-3 py-1 bg-green-600 rounded">Grabar</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
109
packages/studio-panel/src/components/LivekitConnector.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export function LivekitConnector() {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle'|'connecting'|'connected'|'error'>('idle')
|
||||||
|
const roomRef = useRef<any>(null)
|
||||||
|
const [participants, setParticipants] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (roomRef.current?.disconnect) {
|
||||||
|
try { roomRef.current.disconnect() } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function connectToLivekit() {
|
||||||
|
setStatus('connecting')
|
||||||
|
setParticipants([])
|
||||||
|
try {
|
||||||
|
const mod = await import('livekit-client')
|
||||||
|
const lk: any = (mod as any).default ? (mod as any).default : mod
|
||||||
|
const connectFn = lk && (lk.connect || lk.createRoom || lk.Room || null)
|
||||||
|
if (!connectFn) {
|
||||||
|
setStatus('error')
|
||||||
|
console.warn('LiveKit client not available (no connect/createRoom)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let room: any = null
|
||||||
|
if (typeof lk.connect === 'function') {
|
||||||
|
room = await lk.connect(url, token)
|
||||||
|
} else if (typeof lk.createRoom === 'function') {
|
||||||
|
room = await lk.createRoom()
|
||||||
|
if (room && typeof room.connect === 'function') {
|
||||||
|
await room.connect(url, token)
|
||||||
|
}
|
||||||
|
} else if (lk.Room) {
|
||||||
|
room = new lk.Room()
|
||||||
|
if (room && typeof room.connect === 'function') {
|
||||||
|
await room.connect(url, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
setStatus('error')
|
||||||
|
console.warn('Could not create or connect to LiveKit Room')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roomRef.current = room
|
||||||
|
setStatus('connected')
|
||||||
|
|
||||||
|
const updateParticipants = () => {
|
||||||
|
try {
|
||||||
|
const parts: any[] = []
|
||||||
|
if (room.participants && typeof room.participants.values === 'function') {
|
||||||
|
for (const p of room.participants.values()) {
|
||||||
|
parts.push({ sid: p.sid, identity: p.identity, participant: p })
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(room.participants)) {
|
||||||
|
for (const p of room.participants) parts.push(p)
|
||||||
|
}
|
||||||
|
setParticipants(parts)
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on?.('participantConnected', updateParticipants)
|
||||||
|
room.on?.('participantDisconnected', updateParticipants)
|
||||||
|
room.on?.('trackPublished', updateParticipants)
|
||||||
|
updateParticipants()
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error')
|
||||||
|
console.error('LiveKit connect error', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-gray-900 text-sm">
|
||||||
|
<h4 className="font-semibold mb-2">LiveKit</h4>
|
||||||
|
<label className="block text-xs text-gray-400">Server URL</label>
|
||||||
|
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="wss://your-livekit-server" className="w-full px-2 py-1 rounded bg-gray-800 text-white mb-2" />
|
||||||
|
<label className="block text-xs text-gray-400">Token</label>
|
||||||
|
<input value={token} onChange={e => setToken(e.target.value)} placeholder="JWT token" className="w-full px-2 py-1 rounded bg-gray-800 text-white mb-2" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={connectToLivekit} className="px-3 py-1 bg-green-600 rounded">Conectar</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (roomRef.current?.disconnect) { roomRef.current.disconnect(); setStatus('idle'); setParticipants([]) }
|
||||||
|
}} className="px-3 py-1 bg-red-600 rounded">Desconectar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-xs text-gray-300">Estado: <span className="font-medium">{status}</span></div>
|
||||||
|
<div className="mt-2 text-xs text-gray-300">Participantes:</div>
|
||||||
|
<ul className="mt-1 text-xs">
|
||||||
|
{participants.length === 0 && <li className="text-gray-500">(ninguno)</li>}
|
||||||
|
{participants.map(p => (
|
||||||
|
<li key={p.sid || p.identity} className="text-gray-200">{p.identity || p.sid}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LivekitConnector
|
||||||
19
packages/studio-panel/src/components/LowerThird.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const LowerThird: React.FC<{ title?: string; subtitle?: string }> = ({ title = 'AvanzaCast', subtitle = 'En vivo' }) => {
|
||||||
|
return (
|
||||||
|
<div className="fixed left-6 bottom-20 z-40 pointer-events-none">
|
||||||
|
<div className="bg-black/60 backdrop-blur rounded-md px-4 py-2 flex items-center gap-3 shadow-lg pointer-events-auto">
|
||||||
|
<div className="w-10 h-10 bg-yellow-400 rounded flex items-center justify-center font-bold text-black">AC</div>
|
||||||
|
<div className="text-white">
|
||||||
|
<div className="font-semibold text-sm">{title}</div>
|
||||||
|
<div className="text-xs text-gray-300">{subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-xs text-gray-300">720p • Live</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LowerThird
|
||||||
|
|
||||||
17
packages/studio-panel/src/components/MediaAssetCard.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function MediaAssetCard({ title }: { title: string }){
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-md bg-white/5">
|
||||||
|
<div className="w-20 h-12 bg-gray-800 rounded-sm flex items-center justify-center text-xs">IMG</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{title}</div>
|
||||||
|
<div className="text-xs text-gray-400">Overlay / Logo</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button className="px-2 py-1 rounded-md bg-white/10">Usar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
17
packages/studio-panel/src/components/ParticipantCard.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParticipantCard: React.FC<Props> = ({ name }) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded shadow p-2 flex flex-col items-center">
|
||||||
|
<div className="w-40 h-40 bg-gray-700 rounded-full flex items-center justify-center text-gray-300 text-3xl">👤</div>
|
||||||
|
<div className="mt-2 px-2 py-1 bg-yellow-400 text-black rounded">{name}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParticipantCard
|
||||||
|
|
||||||
30
packages/studio-panel/src/components/ParticipantsList.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
|
||||||
|
const participants = [
|
||||||
|
{ name: 'Xesar', online: true },
|
||||||
|
{ name: 'Maria', online: true },
|
||||||
|
{ name: 'Juan', online: false },
|
||||||
|
{ name: 'Sofía', online: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ParticipantsList(){
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-50 dark:bg-surface-900 rounded-md p-4">
|
||||||
|
<h4 className="font-semibold mb-3">Personas</h4>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{participants.map((p, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-3">
|
||||||
|
<Avatar />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{p.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{p.online ? 'En línea' : 'Desconectado'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${p.online ? 'bg-green-400' : 'bg-gray-500'}`}></div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
24
packages/studio-panel/src/components/RightTools.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{ key: 'comments', label: 'Comentarios' },
|
||||||
|
{ key: 'banners', label: 'Banners' },
|
||||||
|
{ key: 'media', label: 'Activos multimedia' },
|
||||||
|
{ key: 'style', label: 'Estilo' },
|
||||||
|
{ key: 'notes', label: 'Notas' },
|
||||||
|
{ key: 'people', label: 'Personas' },
|
||||||
|
{ key: 'chat', label: 'Chat privado' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function RightTools(){
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 p-2">
|
||||||
|
{tools.map(t => (
|
||||||
|
<button key={t.key} className={`w-12 h-12 rounded-md bg-white/5 text-white text-xs flex items-center justify-center text-center`} title={t.label}>
|
||||||
|
{t.label.split(' ')[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
31
packages/studio-panel/src/components/Roster.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const mock = new Array(8).fill(null).map((_, i) => ({ id: i + 1, name: `Invitado ${i + 1}`, online: i % 2 === 0 }))
|
||||||
|
|
||||||
|
const Roster: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">Personas</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{mock.map(p => (
|
||||||
|
<li key={p.id} className="flex items-center gap-3 text-sm group hover:bg-gray-800 p-2 rounded">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center text-sm">{p.name[0]}</div>
|
||||||
|
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-gray-900 ${p.online ? 'bg-green-400' : 'bg-gray-500'}`} title={p.online ? 'En línea' : 'Desconectado'} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{p.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">Invitado</div>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition flex items-center gap-2">
|
||||||
|
<button className="px-2 py-1 bg-white/10 rounded text-sm">Invitar</button>
|
||||||
|
<button className="px-2 py-1 bg-red-600 rounded text-sm">Quitar</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Roster
|
||||||
14
packages/studio-panel/src/components/SceneThumbnail.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function SceneThumbnail({ title, selected }: { title: string; selected?: boolean }){
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-3 p-2 rounded-md border ${selected ? 'bg-blue-600 text-white' : 'bg-white/3 text-white/90'}`}>
|
||||||
|
<div className="w-20 h-12 bg-black rounded-sm flex items-center justify-center text-xs text-gray-400">720p</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{title}</div>
|
||||||
|
<div className="text-xs text-gray-400">Demo scene</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
17
packages/studio-panel/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const Sidebar: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Escenas</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="px-2 py-2 bg-gray-800 rounded">Escena 1</li>
|
||||||
|
<li className="px-2 py-2 bg-gray-800 rounded">Escena 2</li>
|
||||||
|
<li className="px-2 py-2 bg-gray-800 rounded">Escena 3</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar
|
||||||
|
|
||||||
42
packages/studio-panel/src/components/StudioLayout.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import VideoGrid from './VideoGrid'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import ChatPanel from './ChatPanel'
|
||||||
|
import Roster from './Roster'
|
||||||
|
import ControlBar from './ControlBar'
|
||||||
|
import Header from './Header'
|
||||||
|
import LivekitConnector from './LivekitConnector'
|
||||||
|
import LowerThird from './LowerThird'
|
||||||
|
|
||||||
|
const StudioLayout: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 text-white">
|
||||||
|
<Header />
|
||||||
|
<div className="flex pt-4">
|
||||||
|
<aside className="w-72 border-r border-gray-800 p-3 hidden md:block">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="mt-4">
|
||||||
|
<LivekitConnector />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 p-4">
|
||||||
|
<div className="max-w-6xl mx-auto motion-safe:animate-fade-in">
|
||||||
|
<VideoGrid />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className="w-80 border-l border-gray-800 p-3 hidden lg:block">
|
||||||
|
<Roster />
|
||||||
|
<ChatPanel />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<ControlBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LowerThird title="AvanzaCast Studio" subtitle="Produciendo" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StudioLayout
|
||||||
35
packages/studio-panel/src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||||
|
try {
|
||||||
|
const t = localStorage.getItem('studio:theme')
|
||||||
|
return (t === 'light' ? 'light' : 'dark')
|
||||||
|
} catch (e) {
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
localStorage.setItem('studio:theme', theme)
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(prev => prev === 'dark' ? 'light' : 'dark')}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
className="px-3 py-1 rounded bg-white/10 text-white hover:bg-white/20 transition"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle
|
||||||
10
packages/studio-panel/src/components/ToggleButton.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ToggleButton({ on, onClick }: { on: boolean; onClick?: () => void }){
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`w-12 h-6 rounded-full p-1 ${on ? 'bg-blue-600' : 'bg-gray-300'}`}>
|
||||||
|
<div className={`w-4 h-4 bg-white rounded-full transform ${on ? 'translate-x-6' : 'translate-x-0'}`}></div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
13
packages/studio-panel/src/components/ToolbarFloating.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import IconMicOff from './icons/IconMicOff'
|
||||||
|
import IconCameraOn from './icons/IconCameraOn'
|
||||||
|
|
||||||
|
export default function ToolbarFloating(){
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-black/70 backdrop-blur-md px-4 py-2 rounded-full flex items-center gap-3">
|
||||||
|
<button className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center"><IconMicOff /></button>
|
||||||
|
<button className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center"><IconCameraOn /></button>
|
||||||
|
<button className="w-10 h-10 rounded-full bg-red-600 text-white flex items-center justify-center">⏹</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
packages/studio-panel/src/components/VideoGrid.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import VideoTile from './VideoTile'
|
||||||
|
|
||||||
|
const participants = new Array(6).fill(null).map((_, i) => ({ id: i + 1, name: `Persona ${i + 1}` }))
|
||||||
|
|
||||||
|
const VideoGrid: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{participants.map(p => (
|
||||||
|
<VideoTile key={p.id} name={p.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoGrid
|
||||||
151
packages/studio-panel/src/components/VideoTile.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
|
||||||
|
type ConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string
|
||||||
|
stream?: MediaStream | null
|
||||||
|
muted?: boolean
|
||||||
|
isLocal?: boolean
|
||||||
|
isSpeaking?: boolean
|
||||||
|
connectionQuality?: ConnectionQuality
|
||||||
|
onToggleMute?: () => void
|
||||||
|
onToggleCamera?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoTile({
|
||||||
|
name,
|
||||||
|
stream = null,
|
||||||
|
muted = false,
|
||||||
|
isLocal = false,
|
||||||
|
isSpeaking = false,
|
||||||
|
connectionQuality = 'good',
|
||||||
|
onToggleMute,
|
||||||
|
onToggleCamera,
|
||||||
|
}: Props) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vid = videoRef.current
|
||||||
|
if (!vid) return
|
||||||
|
if (stream) {
|
||||||
|
try {
|
||||||
|
vid.srcObject = stream
|
||||||
|
// try play silently; ignore promise rejection
|
||||||
|
const p = vid.play()
|
||||||
|
if (p && typeof p.then === 'function') p.catch(() => {})
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// leave poster if no stream
|
||||||
|
vid.srcObject = null
|
||||||
|
}
|
||||||
|
}, [stream])
|
||||||
|
|
||||||
|
// small helper to render quality bars
|
||||||
|
function QualityBars({ q }: { q: ConnectionQuality }) {
|
||||||
|
const levels = { excellent: 4, good: 3, poor: 2, lost: 0 } as Record<ConnectionQuality, number>
|
||||||
|
const filled = levels[q]
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-0.5" aria-hidden>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`w-0.5 ${i < filled ? 'bg-green-400' : 'bg-gray-600'} rounded-sm`}
|
||||||
|
style={{ height: `${6 + i * 4}px` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative bg-black rounded-lg overflow-hidden h-56 flex items-end p-3 shadow-md transition-transform duration-150 ease-out hover:scale-[1.01] focus-within:ring-2 focus-within:ring-blue-500 ${isSpeaking ? 'ring-4 ring-yellow-400/40 animate-pulse' : ''}`}
|
||||||
|
tabIndex={0}
|
||||||
|
onBlur={() => setMenuOpen(false)}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(prev => !prev) }}
|
||||||
|
aria-label={`Video tile de ${name}`}
|
||||||
|
>
|
||||||
|
{/* video element that attaches MediaStream when available */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover bg-black"
|
||||||
|
playsInline
|
||||||
|
muted={muted || isLocal}
|
||||||
|
poster="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='450'%3E%3Crect width='100%25' height='100%25' fill='%23202024'/%3E%3C/svg%3E"
|
||||||
|
aria-hidden={stream ? undefined : true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* dark gradient overlay for readability */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" aria-hidden />
|
||||||
|
|
||||||
|
{/* top-left: avatar+name/status */}
|
||||||
|
<div className="absolute left-3 top-3 z-20 flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar size={36} />
|
||||||
|
{isSpeaking && <span className="absolute -right-1 -bottom-1 w-3 h-3 bg-yellow-400 rounded-full ring-2 ring-black animate-ping-slow" title="Hablando" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-white text-sm leading-tight">
|
||||||
|
<div className="font-medium truncate max-w-[8rem]">{name} {isLocal ? '(You)' : ''}</div>
|
||||||
|
<div className="text-xs text-gray-300">{muted ? 'Muted' : 'Live'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* top-right: connection quality + menu */}
|
||||||
|
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 bg-black/40 rounded-md">
|
||||||
|
<QualityBars q={connectionQuality} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Más opciones"
|
||||||
|
title="Más opciones"
|
||||||
|
onClick={() => setMenuOpen(prev => !prev)}
|
||||||
|
className="w-8 h-8 rounded-full bg-white/10 text-white flex items-center justify-center"
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* contextual menu */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute top-12 right-3 z-30 w-40 bg-gray-900 border border-gray-700 rounded shadow-lg p-2 text-sm">
|
||||||
|
<button className="w-full text-left px-2 py-1 hover:bg-gray-800 rounded" onClick={() => { onToggleMute?.(); setMenuOpen(false) }}>
|
||||||
|
{muted ? 'Desactivar silencio' : 'Silenciar'}
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-2 py-1 hover:bg-gray-800 rounded" onClick={() => { onToggleCamera?.(); setMenuOpen(false) }}>
|
||||||
|
{isLocal ? 'Apagar cámara' : 'Encender cámara'}
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-2 py-1 hover:bg-gray-800 rounded" onClick={() => { navigator.clipboard?.writeText(name); setMenuOpen(false) }}>
|
||||||
|
Copiar nombre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* bottom-right controls (floating) */}
|
||||||
|
<div className="absolute bottom-3 right-3 z-20 flex items-center gap-2 opacity-90">
|
||||||
|
<button
|
||||||
|
onClick={onToggleMute}
|
||||||
|
aria-pressed={muted}
|
||||||
|
title={muted ? 'Activar sonido' : 'Silenciar'}
|
||||||
|
className={`w-9 h-9 rounded-full flex items-center justify-center ${muted ? 'bg-red-600 text-white' : 'bg-white/10 text-white'}`}
|
||||||
|
>
|
||||||
|
{muted ? '🔇' : '🔊'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggleCamera}
|
||||||
|
aria-pressed={!isLocal}
|
||||||
|
title="Alternar cámara"
|
||||||
|
className="w-9 h-9 rounded-full bg-white/10 text-white flex items-center justify-center"
|
||||||
|
>
|
||||||
|
🎥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoTile
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import ChatPanel from '../ChatPanel'
|
||||||
|
|
||||||
|
describe('ChatPanel', () => {
|
||||||
|
test('sends and displays a message', async () => {
|
||||||
|
render(<ChatPanel />)
|
||||||
|
const input = screen.getByPlaceholderText(/escribe un mensaje/i)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await user.type(input, 'Hola Mundo')
|
||||||
|
const button = screen.getByRole('button', { name: /enviar/i })
|
||||||
|
await user.click(button)
|
||||||
|
expect(screen.getByText('Hola Mundo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import Roster from '../Roster'
|
||||||
|
|
||||||
|
test('renders roster items', () => {
|
||||||
|
render(<Roster />)
|
||||||
|
expect(screen.getByText(/Invitado 1/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
// ...existing code...
|
|
||||||
|
|
||||||
type MediaGridProps = {
|
|
||||||
items?: string[]
|
|
||||||
columns?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MediaGrid({ items = ['/figma-assets/screenshot-design.png'], columns = 3 }: MediaGridProps) {
|
|
||||||
const colsClass = columns === 1 ? 'grid-cols-1' : columns === 2 ? 'grid-cols-2' : 'grid-cols-3'
|
|
||||||
|
|
||||||
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
||||||
const target = e.currentTarget
|
|
||||||
// fallback to a safe small logo if the image fails to load
|
|
||||||
if (!target.dataset.fallbackApplied) {
|
|
||||||
target.dataset.fallbackApplied = 'true'
|
|
||||||
target.src = '/figma-assets/logo-avanzacast.svg'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`grid ${colsClass} gap-figma-lg`}>
|
|
||||||
{items.map((src, i) => (
|
|
||||||
<div key={i} className="rounded-figma-card overflow-hidden bg-neutral-800/30">
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={`media-${i}`}
|
|
||||||
className="w-full h-28 object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
onError={handleImgError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
11
packages/studio-panel/src/components/icons/IconCamera.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function IconCamera(){
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 7H6L8 4H16L18 7H20C21.1 7 22 7.9 22 9V17C22 18.1 21.1 19 20 19H4C2.9 19 2 18.1 2 17V9C2 7.9 2.9 7 4 7Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
11
packages/studio-panel/src/components/icons/IconCameraOn.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function IconCameraOn(){
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 7H5L7 4H17L19 7H21C22.1046 7 23 7.89543 23 9V17C23 18.1046 22.1046 19 21 19H3C1.89543 19 1 18.1046 1 17V9C1 7.89543 1.89543 7 3 7Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
12
packages/studio-panel/src/components/icons/IconMic.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function IconMic(){
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 1V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M8 5V11C8 13.2091 9.79086 15 12 15C14.2091 15 16 13.2091 16 11V5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M19 11C19 14.866 15.866 18 12 18C8.13401 18 5 14.866 5 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
11
packages/studio-panel/src/components/icons/IconMicOff.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function IconMicOff(){
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 5V11C9 12.6569 10.3431 14 12 14C12.7956 14 13.5226 13.6147 14.01 13M15 19C12.7909 19 11 17.2091 11 15M19 11C19 13.8659 15.866 17 12 17C8.13401 17 5 13.8659 5 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M2 2L22 22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
// ...existing code...
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
||||||
variant?: 'default' | 'primary' | 'ghost'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Button({
|
|
||||||
children,
|
|
||||||
variant = 'default',
|
|
||||||
className = '',
|
|
||||||
...props
|
|
||||||
}: ButtonProps) {
|
|
||||||
const base = 'inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium transition'
|
|
||||||
const variants: Record<string, string> = {
|
|
||||||
default: 'bg-white/10 text-white hover:bg-white/20',
|
|
||||||
primary: 'bg-gradient-to-br from-blue-600 to-blue-500 text-white shadow-sm',
|
|
||||||
ghost: 'bg-transparent text-white hover:bg-white/4',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className={`${base} ${variants[variant] ?? variants.default} ${className}`} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import React, { useState } from 'react'
|
|
||||||
import { NewTransmissionModal } from '@shared/components'
|
|
||||||
import { useDestinations, Destination } from '../../hooks/useDestinations'
|
|
||||||
|
|
||||||
const PlatformBadge: React.FC<{ color: string; children: React.ReactNode }> = ({ color, children }) => (
|
|
||||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${color}`}>{children}</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const { destinations, addDestination } = useDestinations()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="w-full bg-[#0b1220] text-white shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex items-center justify-between h-12">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-sky-600 flex items-center justify-center">
|
|
||||||
<svg className="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
||||||
<path d="M3 12c3-6 9-8 13-8 0 4-2 8-6 10 4 0 6 2 6 6-6 0-10-3-13-8z" strokeWidth="1.2" stroke="white" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">Transmision</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button onClick={() => setOpen(true)} className="relative flex items-center gap-3 bg-[#2563eb] hover:bg-[#1e40af] text-white px-4 py-2 rounded-md text-sm font-medium shadow">
|
|
||||||
<span>Agregar destino</span>
|
|
||||||
<div className="flex items-center gap-1 ml-2">
|
|
||||||
<PlatformBadge color="bg-red-600">▶</PlatformBadge>
|
|
||||||
<PlatformBadge color="bg-purple-700">𝕋</PlatformBadge>
|
|
||||||
<PlatformBadge color="bg-blue-600">f</PlatformBadge>
|
|
||||||
<PlatformBadge color="bg-indigo-700">in</PlatformBadge>
|
|
||||||
</div>
|
|
||||||
{destinations.length > 0 && (
|
|
||||||
<span className="absolute -top-2 -right-2 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">{destinations.length}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="bg-transparent border border-white/20 hover:bg-white/5 text-white px-4 py-2 rounded-md text-sm font-medium">
|
|
||||||
Grabar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Destinations list removed from inline layout to avoid deforming the header/container.
|
|
||||||
Destinations are managed via the DestinationModal (overlay) which does not affect layout. */}
|
|
||||||
|
|
||||||
<NewTransmissionModal
|
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
onCreate={() => {}}
|
|
||||||
onlyAddDestination
|
|
||||||
onAddDestination={(d: { id: string; platform: string }) => {
|
|
||||||
const dest: Destination = { id: d.id, platform: d.platform, label: d.platform, url: undefined }
|
|
||||||
addDestination(dest)
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
// ...existing code...
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
type IconButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
||||||
'aria-label': string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IconButton({ className = '', children, ...props }: IconButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`inline-flex items-center justify-center p-2 rounded-md hover:bg-white/6 transition ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
const scenesMock = [
|
|
||||||
{ id: 's1', title: 'Demo scene 1' },
|
|
||||||
{ id: 's2', title: 'Demo scene 2' },
|
|
||||||
{ id: 's3', title: 'Discusión grupal' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const LeftSidePanel: React.FC<Props> = ({ open, onToggle }) => {
|
|
||||||
return (
|
|
||||||
<div className="relative h-full">
|
|
||||||
{/* Panel (in-flow) */}
|
|
||||||
<aside
|
|
||||||
aria-hidden={!open}
|
|
||||||
className={`h-full bg-neutral-900 border-r border-gray-800 text-white flex flex-col overflow-hidden transition-all duration-300 ${
|
|
||||||
open ? 'w-64' : 'w-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-3 border-b border-gray-800">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">Escenas</h3>
|
|
||||||
<span className="text-xs bg-purple-600 px-2 py-0.5 rounded">BETA</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-2">Mis Escenas</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 overflow-y-auto flex-1 space-y-3">
|
|
||||||
<button className="w-full flex items-center justify-center border-2 border-dashed border-gray-700 rounded-md p-3 text-sm text-gray-300">
|
|
||||||
Establecer video de introducción
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{scenesMock.map((s) => (
|
|
||||||
<div key={s.id} className="bg-neutral-800 rounded-md overflow-hidden">
|
|
||||||
<div className="h-20 bg-neutral-700/40 flex items-center justify-center">{/* thumbnail */}</div>
|
|
||||||
<div className="px-2 py-2 text-sm">{s.title}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="mt-2 w-full bg-white/5 text-white px-3 py-2 rounded-md text-sm">+ Nueva escena</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 border-t border-gray-800 text-xs text-gray-400">Establecer video de cierre</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Toggle tab - fixed to viewport edge like original behaviour */}
|
|
||||||
<button
|
|
||||||
aria-label={open ? 'Cerrar panel' : 'Abrir panel'}
|
|
||||||
onClick={onToggle}
|
|
||||||
className={`fixed z-50 h-12 w-8 flex items-center justify-center rounded-r-md bg-[#0b1220] text-white shadow top-12 transition-all duration-300 ${
|
|
||||||
open ? 'left-64' : 'left-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
||||||
{open ? (
|
|
||||||
<path d="M15 18l-6-6 6-6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
) : (
|
|
||||||
<path d="M9 6l6 6-6 6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LeftSidePanel
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
type Tab = { id: string; label: string }
|
|
||||||
|
|
||||||
const TABS: Tab[] = [
|
|
||||||
{ id: 'assets', label: 'Activos multimedia' },
|
|
||||||
{ id: 'style', label: 'Estilo' },
|
|
||||||
{ id: 'people', label: 'Personas' },
|
|
||||||
{ id: 'notes', label: 'Notas' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
activeTab: string | null
|
|
||||||
onSelectTab: (id: string | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const RightSidePanel: React.FC<Props> = ({ activeTab, onSelectTab }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Tabs column */}
|
|
||||||
<div className="fixed right-0 top-16 z-50 flex h-[calc(100vh-4rem)] flex-col items-center gap-2 p-2">
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => onSelectTab(activeTab === t.id ? null : t.id)}
|
|
||||||
className={`w-12 h-12 flex items-center justify-center rounded-l-md bg-neutral-800 text-white text-xs overflow-hidden shadow ${
|
|
||||||
activeTab === t.id ? 'bg-blue-600' : 'hover:bg-neutral-700'
|
|
||||||
}`}
|
|
||||||
title={t.label}
|
|
||||||
>
|
|
||||||
{t.label.split(' ')[0]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Panel area */}
|
|
||||||
<div
|
|
||||||
className={`fixed top-12 right-0 z-40 h-[calc(100vh-4rem)] w-72 transform transition-transform duration-300 ease-in-out ${
|
|
||||||
activeTab ? 'translate-x-0' : 'translate-x-full'
|
|
||||||
}`}
|
|
||||||
aria-hidden={!activeTab}
|
|
||||||
>
|
|
||||||
<aside className="h-full bg-neutral-900 border-l border-gray-800 text-white p-4 overflow-y-auto">
|
|
||||||
<h3 className="text-sm font-semibold mb-3">{activeTab ? TABS.find((x) => x.id === activeTab)?.label : ''}</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{activeTab === 'assets' && (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-300 mb-2">Activos multimedia</div>
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
<div className="h-16 bg-neutral-800 rounded-md" />
|
|
||||||
<div className="h-16 bg-neutral-800 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'style' && <div className="text-sm text-gray-300">Opciones de estilo</div>}
|
|
||||||
|
|
||||||
{activeTab === 'people' && <div className="text-sm text-gray-300">Lista de personas</div>}
|
|
||||||
|
|
||||||
{activeTab === 'notes' && <div className="text-sm text-gray-300">Notas y recordatorios</div>}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RightSidePanel
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
// Inline SVG icons
|
|
||||||
const IconChat = ({ size = 20 }: { size?: number }) => (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const IconInfo = ({ size = 20 }: { size?: number }) => (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
||||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={1.5} />
|
|
||||||
<path d="M12 8v.01M11 12h1v4h1" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const IconClock = ({ size = 20 }: { size?: number }) => (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
||||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={1.5} />
|
|
||||||
<path d="M12 7v6l4 2" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const IconUsers = ({ size = 20 }: { size?: number }) => (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<circle cx="12" cy="7" r="4" stroke="currentColor" strokeWidth={1.5} />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
import { useAssets } from '../../hooks/useAssets'
|
|
||||||
import { usePeople } from '../../hooks/usePeople'
|
|
||||||
import { useStyle } from '../../hooks/useStyle'
|
|
||||||
import { useChat } from '../../hooks/useChat'
|
|
||||||
|
|
||||||
type SidebarItem = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
activeTab: string | null
|
|
||||||
onSelectTab: (id: string | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const RightSidebar: React.FC<Props> = ({ activeTab, onSelectTab }) => {
|
|
||||||
const { assets } = useAssets()
|
|
||||||
const { people } = usePeople()
|
|
||||||
const { style, update } = useStyle()
|
|
||||||
const { messages, send } = useChat()
|
|
||||||
|
|
||||||
const items: SidebarItem[] = [
|
|
||||||
{ id: 'comments', label: 'Comentarios', icon: <IconChat /> },
|
|
||||||
{ id: 'style', label: 'Estilo', icon: <IconInfo /> },
|
|
||||||
{ id: 'banners', label: 'Banners', icon: <IconInfo /> },
|
|
||||||
{ id: 'media', label: 'Archivos multimedia', icon: <IconClock /> },
|
|
||||||
{ id: 'people', label: 'Personas', icon: <IconUsers /> },
|
|
||||||
{ id: 'private-chat', label: 'Chat privado', icon: <IconChat /> },
|
|
||||||
{ id: 'notes', label: 'Notas', icon: <IconUsers /> },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-stretch">
|
|
||||||
{/* Panel: in-flow, collapses width when closed */}
|
|
||||||
<div
|
|
||||||
role="region"
|
|
||||||
aria-hidden={!activeTab}
|
|
||||||
className={`h-full bg-[#131418] text-gray-200 overflow-y-auto shadow-lg transition-all duration-300 ${
|
|
||||||
activeTab ? 'w-[320px]' : 'w-0'
|
|
||||||
}`}
|
|
||||||
style={{ pointerEvents: activeTab ? 'auto' : 'none' }}
|
|
||||||
>
|
|
||||||
<div className={`p-4 ${activeTab ? '' : 'hidden'}`}>
|
|
||||||
{activeTab ? (
|
|
||||||
<div>
|
|
||||||
{activeTab === 'media' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Archivos multimedia</h4>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{assets.map((a) => (
|
|
||||||
<li key={a.id} className="text-sm text-gray-300">{a.name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'people' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Personas</h4>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{people.map((p) => (
|
|
||||||
<li key={p.id} className="text-sm text-gray-300">{p.name} <span className="text-xs text-gray-500">{p.role}</span></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'style' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Estilo</h4>
|
|
||||||
<div className="text-sm text-gray-300">Color primario: <span className="font-medium">{style.themeColor}</span></div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<button
|
|
||||||
className="px-3 py-1 bg-blue-600 text-white rounded"
|
|
||||||
onClick={() => update({ showLogo: !style.showLogo })}
|
|
||||||
>
|
|
||||||
Alternar logo ({style.showLogo ? 'ON' : 'OFF'})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'private-chat' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Chat privado</h4>
|
|
||||||
<div className="h-64 overflow-y-auto bg-[#0b0d0f] p-2 rounded">
|
|
||||||
{messages.map((m) => (
|
|
||||||
<div key={m.id} className="text-sm text-gray-300 mb-2">
|
|
||||||
<div className="font-medium text-white">{m.user}</div>
|
|
||||||
<div className="text-gray-400 text-xs">{m.text}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<input type="text" placeholder="Escribe..." className="flex-1 p-2 rounded bg-[#071018] text-sm text-white" onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const val = (e.currentTarget as HTMLInputElement).value.trim()
|
|
||||||
if (val) {
|
|
||||||
send('Host', val)
|
|
||||||
;(e.currentTarget as HTMLInputElement).value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'comments' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Comentarios</h4>
|
|
||||||
<p className="text-sm text-gray-300">Panel de comentarios en tiempo real.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'banners' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Banners</h4>
|
|
||||||
<p className="text-sm text-gray-300">Gestión de banners y overlays.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'notes' && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-2">Notas</h4>
|
|
||||||
<p className="text-sm text-gray-300">Notas y recordatorios del stream.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-gray-400">Seleccione una pestaña para ver opciones</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs aside: always visible on the right */}
|
|
||||||
<aside className="flex-shrink-0 bg-[#0f1720] border-l border-gray-800 flex flex-col items-center py-3" style={{ width: '84.44px', zIndex: 20 }}>
|
|
||||||
{items.map((item) => {
|
|
||||||
const active = activeTab === item.id
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => onSelectTab(active ? null : item.id)}
|
|
||||||
aria-pressed={active}
|
|
||||||
aria-label={item.label}
|
|
||||||
className={`flex flex-col items-center justify-center text-[11px] transition-colors duration-150 ${
|
|
||||||
active
|
|
||||||
? 'bg-gray-700 text-white border-r-4 border-blue-500 shadow-inner'
|
|
||||||
: 'bg-[#0f1720] text-gray-300 hover:bg-gray-800 hover:text-white'
|
|
||||||
}`}
|
|
||||||
style={{ width: '84.44px', height: '84.44px' }}
|
|
||||||
>
|
|
||||||
<div className="mb-1">{item.icon}</div>
|
|
||||||
<div className="text-[11px] leading-tight text-center">{item.label}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RightSidebar
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import React, { useRef, useLayoutEffect, useState } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode
|
|
||||||
padding?: number
|
|
||||||
minScale?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const StudioFrame: React.FC<Props> = ({ children, padding = 24, minScale = 0.6 }) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const [scale, setScale] = useState(1)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!containerRef.current || !contentRef.current) return
|
|
||||||
|
|
||||||
const container = containerRef.current
|
|
||||||
const content = contentRef.current
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
const cw = container.clientWidth - padding
|
|
||||||
const ch = container.clientHeight - padding
|
|
||||||
|
|
||||||
const iw = content.scrollWidth
|
|
||||||
const ih = content.scrollHeight
|
|
||||||
|
|
||||||
const sx = cw / iw
|
|
||||||
const sy = ch / ih
|
|
||||||
const sRaw = Math.min(1, Math.min(sx, sy))
|
|
||||||
const s = Math.max(minScale, sRaw)
|
|
||||||
setScale(s)
|
|
||||||
})
|
|
||||||
|
|
||||||
ro.observe(container)
|
|
||||||
ro.observe(content)
|
|
||||||
|
|
||||||
// initial
|
|
||||||
const init = () => {
|
|
||||||
const cw = container.clientWidth - padding
|
|
||||||
const ch = container.clientHeight - padding
|
|
||||||
const iw = content.scrollWidth
|
|
||||||
const ih = content.scrollHeight
|
|
||||||
const sx = cw / iw
|
|
||||||
const sy = ch / ih
|
|
||||||
const sRaw = Math.min(1, Math.min(sx, sy))
|
|
||||||
const s = Math.max(minScale, sRaw)
|
|
||||||
setScale(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
|
|
||||||
return () => ro.disconnect()
|
|
||||||
}, [padding])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="w-full h-full flex items-start justify-center overflow-hidden">
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className="transform-origin-top-left"
|
|
||||||
style={{ transform: `scale(${scale})`, willChange: 'transform', transition: 'transform 180ms ease' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StudioFrame
|
|
||||||
4
packages/studio-panel/src/global.d.ts
vendored
@ -1,4 +0,0 @@
|
|||||||
declare module '*.css'
|
|
||||||
declare module '*.scss'
|
|
||||||
declare module '*.png'
|
|
||||||
declare module '*.svg'
|
|
||||||