feat: implement Studio Panel layout and components with Tailwind CSS

This commit is contained in:
Cesar Mendivil 2025-11-11 10:47:34 -07:00
parent 1a28c0eae7
commit 657565046b
123 changed files with 6382 additions and 10910 deletions

View File

@ -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").

View 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

View 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** ✅

View File

@ -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: 4048px (`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`.

View File

@ -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.253.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`

View File

@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 77 KiB

5141
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,16 @@
<!doctype html>
<html lang="es">
<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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AvanzaCast - Plataforma Profesional de Streaming en Vivo</title>
@ -11,9 +21,9 @@
<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">
<!-- Iconos Unicons -->
<link rel="stylesheet" href="https://unicons.iconscout.com/release/v4.0.0/css/line.css">
<!-- 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"> -->
<!-- Material Design Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
</head>

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@iconscout/unicons": "^4.0.8",
"@iconscout/unicons": "^4.2.0",
"@mdi/font": "^7.4.47",
"choices.js": "^10.2.0",
"feather-icons": "^4.29.1",
@ -26,7 +26,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.30.1",
"shufflejs": "^6.1.2",
"swiper": "4.5.0"
"swiper": "^10.3.1"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.4",
@ -36,8 +36,8 @@
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.23",
"tailwindcss": "^4.1.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^4.3.9"
"vite": "^5.4.21"
}
}

View File

@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
'tailwindcss': {},
'autoprefixer': {},
},
}

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'
import { ArrowUpIcon } from '@heroicons/react/24/outline'
export default function BackToTop() {
const [isVisible, setIsVisible] = useState(false)
@ -16,7 +17,8 @@ export default function BackToTop() {
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToTop = () => {
const scrollToTop = (e: React.MouseEvent) => {
e.preventDefault()
window.scrollTo({
top: 0,
behavior: 'smooth'
@ -26,26 +28,16 @@ export default function BackToTop() {
if (!isVisible) return null
return (
<button
onClick={scrollToTop}
<a
href="#"
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"
aria-label="Volver arriba"
onClick={scrollToTop}
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
xmlns="http://www.w3.org/2000/svg"
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>
<ArrowUpIcon className="h-6 w-6" />
</a>
)
}

View File

@ -5,6 +5,11 @@
import React, { useState } from 'react'
import { Logo } from '../../../../shared/components/Logo'
import {
ArrowRightIcon,
ChevronRightIcon,
HeartIcon,
} from '@heroicons/react/24/outline';
const ModernSaasFooter: React.FC = () => {
const [newsletter, setNewsletter] = useState('')
@ -52,7 +57,7 @@ const ModernSaasFooter: React.FC = () => {
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"
>
Suscribirse <i className="uil uil-arrow-right"></i>
Suscribirse <ArrowRightIcon className="inline-block h-4 w-4" />
</button>
</div>
</form>
@ -81,27 +86,27 @@ const ModernSaasFooter: React.FC = () => {
<ul className="list-none flex items-center space-x-2">
<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">
<i className="uil uil-facebook-f align-middle"></i>
<i className="mdi mdi-facebook text-2xl"></i>
</a>
</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">
<i className="uil uil-instagram align-middle"></i>
<i className="mdi mdi-instagram text-2xl"></i>
</a>
</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">
<i className="uil uil-twitter align-middle"></i>
<i className="mdi mdi-twitter text-2xl"></i>
</a>
</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">
<i className="uil uil-linkedin align-middle"></i>
<i className="mdi mdi-linkedin text-2xl"></i>
</a>
</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">
<i className="uil uil-youtube align-middle"></i>
<i className="mdi mdi-youtube text-2xl"></i>
</a>
</li>
</ul>
@ -113,32 +118,32 @@ const ModernSaasFooter: React.FC = () => {
<ul className="list-none footer-list space-y-3">
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</ul>
@ -150,27 +155,27 @@ const ModernSaasFooter: React.FC = () => {
<ul className="list-none footer-list space-y-3">
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</ul>
@ -182,27 +187,27 @@ const ModernSaasFooter: React.FC = () => {
<ul className="list-none footer-list space-y-3">
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</ul>
@ -217,7 +222,7 @@ const ModernSaasFooter: React.FC = () => {
<div className="grid md:grid-cols-2 items-center gap-6">
<div className="md:text-start text-center">
<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">
Nextream
</a>

View File

@ -1,8 +1,9 @@
'use client';
'use client';
import { useState, useEffect } from 'react';
import feather from 'feather-icons';
import Reveal from './Reveal'
import { ArrowRightIcon } from '@heroicons/react/24/outline';
const features = [
{
@ -130,7 +131,7 @@ export default function StreamingFeatures() {
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"
>
Ver Más <i className="uil uil-arrow-right"></i>
Ver Más <ArrowRightIcon className="inline-block h-4 w-4" />
</a>
</div>
</div>

View File

@ -17,69 +17,156 @@
@tailwind components;
@tailwind utilities;
@layer base {
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 {
@apply leading-relaxed;
line-height: 1.625;
}
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 {
@apply bg-indigo-600/90 text-white;
background-color: rgba(79, 70, 229, 0.9);
color: #fff;
}
/* Form inputs base styles */
.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 {
/* Techwind Software Components */
.container {
@apply mx-auto px-4;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
/* Buttons */
.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 {
@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 {
@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-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 {
@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-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 {
@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 {
@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 */
@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 .icon { @apply w-12 h-12 rounded-md flex items-center justify-center text-xl; }
.tab-btn { @apply px-3 py-1 rounded-md text-sm text-slate-600 hover:bg-slate-50; }
.tab-btn.active { @apply text-sky-600 border-b-2 border-sky-500; }
.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; }
}
/* Nextream-style Animations */
@ -229,41 +316,51 @@
/* Techwind Helper Styles */
/* Cookies */
.cookie-popup-not-accepted {
@apply block;
display:block;
animation: cookie-popup-in .5s ease forwards;
}
.cookie-popup-accepted {
@apply hidden;
display:none;
}
@keyframes cookie-popup-in {
from {
bottom: -6.25rem;
}
to {
bottom: 1.25rem;
}
from { bottom: -6.25rem; }
to { bottom: 1.25rem; }
}
/* Preloader */
#preloader {
background-image: linear-gradient(45deg, #ffffff, #ffffff);
z-index: 99999;
@apply fixed inset-0;
position: fixed;
inset: 0;
}
#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 {
@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-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;
}
@ -283,11 +380,11 @@
/* Switcher */
.label .ball {
transition: transform 0.2s linear;
@apply translate-x-0;
transform: translateX(0);
}
.checkbox:checked + .label .ball {
@apply translate-x-6;
transform: translateX(1.5rem);
}
/* Mover animation */
@ -306,7 +403,11 @@
/* Background effect for hero sections */
.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;
}
@ -333,60 +434,72 @@
.background-effect .circles li:nth-child(8),
.background-effect .circles li:nth-child(9),
.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) {
@apply start-1/4;
/* Replaced @apply start-1/4 */
inset-inline-start: 25%;
animation-delay: 0s;
}
.background-effect .circles li:nth-child(2) {
@apply start-[10%];
/* Replaced @apply start-[10%] */
inset-inline-start: 10%;
animation-delay: 2s;
animation-duration: 12s;
}
.background-effect .circles li:nth-child(3) {
@apply start-[70%];
/* Replaced @apply start-[70%] */
inset-inline-start: 70%;
animation-delay: 4s;
}
.background-effect .circles li:nth-child(4) {
@apply start-[40%];
/* Replaced @apply start-[40%] */
inset-inline-start: 40%;
animation-delay: 0s;
animation-duration: 18s;
}
.background-effect .circles li:nth-child(5) {
@apply start-[65%];
/* Replaced @apply start-[65%] */
inset-inline-start: 65%;
animation-delay: 0s;
}
.background-effect .circles li:nth-child(6) {
@apply start-3/4;
/* Replaced @apply start-3/4 */
inset-inline-start: 75%;
animation-delay: 3s;
}
.background-effect .circles li:nth-child(7) {
@apply start-[35%];
/* Replaced @apply start-[35%] */
inset-inline-start: 35%;
animation-delay: 7s;
}
.background-effect .circles li:nth-child(8) {
@apply start-1/2;
/* Replaced @apply start-1/2 */
inset-inline-start: 50%;
animation-delay: 15s;
animation-duration: 45s;
}
.background-effect .circles li:nth-child(9) {
@apply start-[20%];
/* Replaced @apply start-[20%] */
inset-inline-start: 20%;
animation-delay: 2s;
animation-duration: 35s;
}
.background-effect .circles li:nth-child(10) {
@apply start-[85%];
/* Replaced @apply start-[85%] */
inset-inline-start: 85%;
animation-delay: 0s;
animation-duration: 11s;
}

View File

@ -2,16 +2,17 @@
/* Selection */
::selection {
@apply bg-indigo-600/90 text-white;
background-color: rgba(79, 70, 229, 0.9); /* indigo-600 */
color: #ffffff;
}
/* Typography defaults */
p {
@apply leading-relaxed;
line-height: 1.625; /* approx tailwind 'leading-relaxed' */
}
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
@apply leading-normal;
line-height: 1.5; /* tailwind 'leading-normal' */
}
/* Mover animation */
@ -116,61 +117,84 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
}
.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;
}
.background-effect .circles li:nth-child(1) {
@apply size-12 start-1/4;
width: 3rem;
height: 3rem;
left: 25%;
animation-delay: 0s;
}
.background-effect .circles li:nth-child(2) {
@apply size-12 start-[10%];
width: 3rem;
height: 3rem;
left: 10%;
animation-delay: 2s;
animation-duration: 12s;
}
.background-effect .circles li:nth-child(3) {
@apply size-12 start-[70%];
width: 3rem;
height: 3rem;
left: 70%;
animation-delay: 4s;
}
.background-effect .circles li:nth-child(4) {
@apply size-12 start-[40%];
width: 3rem;
height: 3rem;
left: 40%;
animation-delay: 0s;
animation-duration: 18s;
}
.background-effect .circles li:nth-child(5) {
@apply size-12 start-[65%];
width: 3rem;
height: 3rem;
left: 65%;
animation-delay: 0s;
}
.background-effect .circles li:nth-child(6) {
@apply size-12 start-3/4;
width: 3rem;
height: 3rem;
left: 75%;
animation-delay: 3s;
}
.background-effect .circles li:nth-child(7) {
@apply size-12 start-[35%];
width: 3rem;
height: 3rem;
left: 35%;
animation-delay: 7s;
}
.background-effect .circles li:nth-child(8) {
@apply size-12 start-1/2;
width: 3rem;
height: 3rem;
left: 50%;
animation-delay: 15s;
animation-duration: 45s;
}
.background-effect .circles li:nth-child(9) {
@apply size-12 start-[20%];
width: 3rem;
height: 3rem;
left: 20%;
animation-delay: 2s;
animation-duration: 35s;
}
.background-effect .circles li:nth-child(10) {
@apply size-12 start-[85%];
width: 3rem;
height: 3rem;
left: 85%;
animation-delay: 0s;
animation-duration: 11s;
}
@ -210,7 +234,13 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
.spinner .double-bounce1,
.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;
}
@ -221,24 +251,31 @@ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
/* Dark mode toggle switch */
.label .ball {
transition: transform 0.2s linear;
@apply translate-x-0;
transform: translateX(0);
}
.checkbox:checked + .label .ball {
@apply translate-x-6;
transform: translateX(1.5rem); /* approx translate-x-6 */
}
/* Testimonial navigation dots (pagination) */
.tns-nav {
@apply text-center mt-3;
text-align: center;
margin-top: 0.75rem;
}
.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 {
@apply bg-indigo-600 rotate-[45deg];
background-color: rgba(79, 70, 229, 1);
transform: rotate(45deg);
}
/* Smooth infinite scroll slider */

View File

@ -7,7 +7,8 @@ module.exports = {
content: [
'./index.html',
'./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: {
...sharedConfig.theme,

View File

@ -1,9 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
const reactPlugin: any = react()
export default defineConfig({
plugins: [react()],
plugins: [reactPlugin],
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,
},
},
})

View File

@ -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

View File

@ -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

View File

@ -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
```

View File

@ -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"]

View 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

View File

@ -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

View File

@ -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 ... />
</>
)}

View File

@ -1,10 +1,11 @@
<!DOCTYPE html>
<html lang="es">
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta charset="utf-8" />
<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>
<body>
<div id="root"></div>

View File

@ -1,49 +1,36 @@
{
"name": "@avanzacast/broadcast-studio",
"version": "1.0.0",
"name": "@avanzacast/studio-panel",
"version": "0.1.0",
"private": true,
"description": "AvanzaCast - Broadcast Studio",
"type": "module",
"description": "Panel de video conferencia (Studio Panel) para AvanzaCast - basado en componentes vristo adaptados a Tailwind v4",
"scripts": {
"dev": "vite --port 3020",
"dev:vite": "vite --port 3020",
"dev:server": "node server.js",
"dev:full": "concurrently \"npm run dev:vite\" \"npm run dev:server\"",
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 3001",
"typecheck": "tsc --noEmit",
"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"
"preview": "vite preview",
"test": "vitest"
},
"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-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"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.4",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.23",
"tailwindcss": "^4.1.0",
"typescript": "^5.2.2",
"vite": "^4.3.9"
"typescript": "^5.5.0",
"vite": "^4.1.0",
"@vitejs/plugin-react": "^4.0.0",
"tailwindcss": "^4.1.17",
"@tailwindcss/postcss": "^4.1.17",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"vitest": "^1.1.8",
"@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
}
}
}

View File

@ -1,6 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
// use the PostCSS plugin package for Tailwind v4
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 231 KiB

View File

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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"
}
}

View File

@ -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)
})
})

View File

@ -1,15 +1,9 @@
import React from 'react'
import StudioLayout from './layouts/StudioLayout'
import StudioLayout from './components/StudioLayout'
const App: React.FC = () => {
export default function App() {
return (
<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>
<StudioLayout />
)
}
export default App

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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

View 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

View 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

View 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

View 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>
)
}

View 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

View 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>
)
}

View 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>
)
}

View 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

View 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>
)
}

View 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

View 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

View 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

View 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>
)
}

View 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>
)
}

View 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

View 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

View File

@ -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()
})
})

View File

@ -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()
})

View File

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,4 +0,0 @@
declare module '*.css'
declare module '*.scss'
declare module '*.png'
declare module '*.svg'

Some files were not shown because too many files have changed in this diff Show More