1545 lines
58 KiB
Markdown
1545 lines
58 KiB
Markdown
**INFORME TÉCNICO COMPLEMENTARIO (Análisis de UX del Video)**
|
|
|
|
**I. Estructura y Componentes Lógicos (Actualización):**
|
|
|
|
Se confirman los componentes previamente definidos (`PageContainer`, `Sidebar`, `Header`, `HeroSection`, `TransmissionsTable`) y se añaden los siguientes componentes lógicos clave basados en la interactividad observada:
|
|
|
|
* **`Modal.tsx` (Componente Reutilizable):** Un componente genérico para la superposición de modales.
|
|
* **Jerarquía DOM:** Un `div` principal para la capa de superposición (`overlay`), y un `div` anidado para el contenido del modal (`modal-content`). Un botón de cierre (`x`) y un `h2` para el título.
|
|
* **Medidas Clave:**
|
|
* Overlay: `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 2000; display: flex; justify-content: center; align-items: center;`.
|
|
* Modal Content: `background-color: var(--surface-color); border-radius: 8px; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.15); max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; position: relative; padding: 24px;`.
|
|
* Botón de cierre (x): `position: absolute; top: 16px; right: 16px;`.
|
|
* **`DropdownMenu.tsx` (Componente Reutilizable):** Para el menú "Mi cuenta".
|
|
* **Jerarquía DOM:** Un `div` principal que se posiciona absolutamente. `ul` con `li` para los elementos del menú.
|
|
* **Medidas Clave:**
|
|
* Contenedor: `position: absolute; top: 100%; right: 0; background-color: var(--surface-color); border-radius: 8px; box-shadow: 0px 4px 12px var(--shadow-light); min-width: 250px; z-index: 1500;`.
|
|
* Items: `padding: 10px 16px; font-size: 14px;`.
|
|
* **`ToggleSwitch.tsx` (Componente Reutilizable):** Para las opciones de encendido/apagado.
|
|
* **Jerarquía DOM:** Un `label` que contiene un `input[type="checkbox"]` oculto y un `span` para el "slider" visual.
|
|
* **Medidas Clave:** `width: 40px; height: 22px; border-radius: 11px;` para el slider. `circle` o `handle` de `18px` de diámetro.
|
|
* **`TabbedContent.tsx` (Componente Lógico para Pestañas):** Se observa en "Configuración del equipo" y dentro del modal "Agregar destino".
|
|
* **Jerarquía DOM:** Contenedor de `button` para las pestañas y un `div` para el contenido activo.
|
|
* **`FormInput.tsx`, `FormDropdown.tsx`, `FormCheckbox.tsx` (Componentes de Formulario):** Se utilizarán para estandarizar los campos de entrada en los modales y páginas de configuración.
|
|
|
|
**II. Métricas Pixel-Perfect (Actualización):**
|
|
|
|
* **Tipografía Adicional:**
|
|
* **Títulos de Modal (h2):** `font-size: 20px; font-weight: 600;`.
|
|
* **Texto dentro de Modales/Formularios:**
|
|
* Labels: `font-size: 14px; font-weight: 500;`.
|
|
* Input Text: `font-size: 14px; font-weight: 400;`.
|
|
* **Elementos de Dropdown:** `font-size: 14px; font-weight: 400;`.
|
|
* **Paleta de Colores Adicional:**
|
|
* **Overlay de Modal:** `rgba(0, 0, 0, 0.5)` - Negro semitransparente.
|
|
* **Fondo de Toggle (off):** `var(--border-light)` (gris claro).
|
|
* **Fondo de Toggle (on):** `var(--primary-blue)`.
|
|
* **Espaciado y Bordeado Adicional:**
|
|
* **Padding de Modal:** `24px` alrededor del contenido.
|
|
* **Input Fields:** `padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border-light);`.
|
|
* **Toggle Switch:** Dimensiones ya mencionadas. El "handle" del toggle tiene `border-radius: 50%; background-color: white; box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);`.
|
|
* **Dropdown Separadores:** `border-bottom: 1px solid var(--border-light);` para separar grupos de elementos en el menú "Mi cuenta".
|
|
* **Tooltip:** `background-color: var(--text-primary); color: var(--surface-color); padding: 6px 10px; border-radius: 4px; font-size: 12px;`.
|
|
|
|
**III. Funcionalidad y Scripts Internos (Actualización y Expansión):**
|
|
|
|
Se confirma y expande la funcionalidad descrita previamente, añadiendo los siguientes puntos clave:
|
|
|
|
1. **Manejo Centralizado de Modales:**
|
|
* Se implementará un estado global (posiblemente a través de `useState` en `PageContainer` o un contexto simple si se requiere compartir estado entre más componentes) para controlar la visibilidad y el tipo de modal a mostrar.
|
|
* Cada modal tendrá su propia lógica interna para manejar formularios, opciones y acciones, pero su apertura y cierre serán gestionados por el componente padre o el contexto.
|
|
* Se observó que los modales pueden tener contenido complejo como **tabs (`Agregar destino` modal)** y **videos (`¡Obtén StreamYard On-Air!`)**.
|
|
* **Cierre de Modal:** Los modales se cierran al hacer clic en el botón 'x' o al presionar la tecla `Escape`.
|
|
|
|
2. **Manejo de Dropdown "Mi cuenta":**
|
|
* El componente `Header` gestionará el estado de visibilidad del dropdown "Mi cuenta" utilizando `useState`.
|
|
* Un `useEffect` con un *event listener* para `click` en `document` se usará para cerrar el dropdown cuando se haga clic fuera de él.
|
|
* Los elementos del dropdown son enlaces de navegación que pueden cambiar la ruta o abrir sub-secciones dentro de la aplicación.
|
|
|
|
3. **Implementación de Toggle Switches:**
|
|
* Los componentes `ToggleSwitch` serán reutilizables y gestionarán su propio estado `checked` (`useState`).
|
|
* Se pasarán *props* `onChange` para que los componentes padres puedan reaccionar a los cambios de estado (ej. "Eliminar grabaciones automáticas", "Grabaciones locales").
|
|
|
|
4. **Navegación en el Sidebar y Pestañas de Contenido:**
|
|
* La barra lateral sigue un patrón de "enlaces activos", que se actualizará al hacer clic en ellos, reflejando el cambio de ruta en la URL o el contenido cargado en el área principal.
|
|
* En secciones como "Configuración del equipo" y el modal "Agregar destino", se utilizará `useState` para gestionar la pestaña activa, cambiando el contenido renderizado (`renderizado condicional`).
|
|
|
|
5. **Formularios Interactivos:**
|
|
* Modales como "Crear transmisión en vivo" incluyen campos de texto, radio buttons y dropdowns. Estos necesitarán gestión de estado (`useState`) para sus valores y validación básica `(inline validation, if complex)`.
|
|
* El campo de destino en "Crear transmisión en vivo" muestra avatares de plataformas y un botón de "añadir", que abre otro modal (`Agregar destino`). Esto implica una interacción entre modales o un flujo de apertura en cadena.
|
|
* **Tooltips al Hover:** El video muestra un tooltip al pasar el cursor sobre los avatares de destino, indicando el nombre de la plataforma.
|
|
|
|
6. **Redirecciones Externas:**
|
|
* El enlace "Estado del sistema" en el sidebar y el icono de notificación en el header (`Mi cuenta`) redirigen a URLs externas, lo que se implementará con `target="_blank" rel="noopener noreferrer"` para abrir en una nueva pestaña.
|
|
|
|
7. **Estado de Carga (Loading States):**
|
|
* Aunque breve, se observa un *spinner* de carga al navegar a la "Biblioteca", sugiriendo la necesidad de implementar estados de carga para mejorar la experiencia del usuario durante la recuperación de datos.
|
|
|
|
8. **Gestión de Rutas/Vistas:**
|
|
* Dada la navegación entre "Inicio", "Biblioteca", "Destinos", "Miembros", "Configuración del equipo", "Referidos" y "Estado del sistema", la aplicación requerirá un sistema de enrutamiento (ej. React Router DOM) para manejar las diferentes vistas y mantener el estado de la URL. Esto no se implementará directamente en los componentes dados, pero es una consideración crucial para la aplicación completa.
|
|
|
|
---
|
|
|
|
**GENERACIÓN DE CÓDIGO (Componentes Adicionales y Modificaciones)**
|
|
|
|
Para integrar la nueva funcionalidad, se necesitarán algunos ajustes y componentes adicionales.
|
|
|
|
**1. `src/components/PageContainer.tsx` (Actualización - Añadir control de Modal)**
|
|
|
|
```tsx
|
|
import React, { useState } from 'react';
|
|
import styles from './PageContainer.module.css';
|
|
import Sidebar from './Sidebar/Sidebar';
|
|
import Header from './Header/Header';
|
|
import HeroSection from './HeroSection/HeroSection';
|
|
import TransmissionsTable from './TransmissionsTable/TransmissionsTable';
|
|
import Modal from '../components/Modal/Modal'; // Nuevo componente Modal
|
|
import CreateRecordingModal from '../components/Modals/CreateRecordingModal'; // Modal específico
|
|
import StreamYardOnAirModal from '../components/Modals/StreamYardOnAirModal'; // Modal específico
|
|
import CreateLiveStreamModal from '../components/Modals/CreateLiveStreamModal'; // Modal específico
|
|
|
|
// Definir tipos para el estado del modal
|
|
type ModalType = 'createRecording' | 'streamYardOnAir' | 'createLiveStream' | null;
|
|
|
|
const PageContainer: React.FC = () => {
|
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
|
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
|
const [activeSidebarLink, setActiveSidebarLink] = useState<string>('Inicio'); // Para simular navegación
|
|
|
|
const toggleTheme = () => {
|
|
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
|
|
};
|
|
|
|
const openModal = (modalType: ModalType) => {
|
|
setActiveModal(modalType);
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setActiveModal(null);
|
|
};
|
|
|
|
const handleSidebarLinkClick = (linkId: string) => {
|
|
setActiveSidebarLink(linkId);
|
|
// En una aplicación real, esto manejaría el enrutamiento (ej. history.push('/${linkId.toLowerCase()}'))
|
|
// Para esta simulación, solo cambiaremos el contenido si es "Inicio"
|
|
if (linkId !== 'Inicio') {
|
|
console.log(`Navegando a: ${linkId}`);
|
|
// Simular carga de contenido diferente aquí si es necesario para otros links
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<div className={`${styles.pageContainer} ${theme === 'dark' ? styles.themeDark : styles.themeLight}`}>
|
|
<Sidebar activeLink={activeSidebarLink} onLinkClick={handleSidebarLinkClick} />
|
|
|
|
<div className={styles.mainContent}>
|
|
<Header currentTheme={theme} onToggleTheme={toggleTheme} />
|
|
|
|
<div className={styles.contentWrapper}>
|
|
{/* Renderizado condicional del contenido principal basado en el link activo */}
|
|
{activeSidebarLink === 'Inicio' && (
|
|
<>
|
|
{/* Se pasa la función openModal a HeroSection */}
|
|
<HeroSection onOpenModal={openModal} />
|
|
<TransmissionsTable />
|
|
</>
|
|
)}
|
|
|
|
{activeSidebarLink === 'Configuracion' && (
|
|
<div>
|
|
<h2 className={styles.tempSectionTitle}>Configuración del equipo</h2>
|
|
{/* Aquí iría el componente de configuración con tabs General/Facturación */}
|
|
</div>
|
|
)}
|
|
|
|
{activeSidebarLink === 'Referidos' && (
|
|
<div>
|
|
<h2 className={styles.tempSectionTitle}>Referidos</h2>
|
|
{/* Aquí iría el componente de referidos */}
|
|
</div>
|
|
)}
|
|
|
|
{activeSidebarLink === 'Biblioteca' && (
|
|
<div>
|
|
<h2 className={styles.tempSectionTitle}>Biblioteca</h2>
|
|
<p className={styles.noContentMessage}>Actualmente no tienes grabaciones.</p>
|
|
<p className={styles.noContentMessage}>Tus grabaciones aparecerán aquí</p>
|
|
</div>
|
|
)}
|
|
|
|
{activeSidebarLink === 'Destinos' && (
|
|
<div>
|
|
<h2 className={styles.tempSectionTitle}>Destinos</h2>
|
|
{/* Aquí iría el componente de destinos, con botón para abrir modal "Agregar destino" */}
|
|
</div>
|
|
)}
|
|
|
|
{activeSidebarLink === 'Miembros' && (
|
|
<div>
|
|
<h2 className={styles.tempSectionTitle}>Miembros</h2>
|
|
{/* Aquí iría el componente de miembros */}
|
|
</div>
|
|
)}
|
|
|
|
{/* Otros contenidos para el sidebar */}
|
|
</div>
|
|
|
|
<button className={styles.helpButton}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-help-circle">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
|
</svg>
|
|
Ayuda
|
|
</button>
|
|
</div>
|
|
|
|
{/* Renderizado de modales condicional */}
|
|
{activeModal === 'createRecording' && (
|
|
<Modal title="Crea una grabación" onClose={closeModal}>
|
|
<CreateRecordingModal />
|
|
</Modal>
|
|
)}
|
|
{activeModal === 'streamYardOnAir' && (
|
|
<Modal title="¡Obtén StreamYard On-Air!" onClose={closeModal}>
|
|
<StreamYardOnAirModal />
|
|
</Modal>
|
|
)}
|
|
{activeModal === 'createLiveStream' && (
|
|
<Modal title="Crear transmisión en vivo" onClose={closeModal}>
|
|
<CreateLiveStreamModal />
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PageContainer;
|
|
```
|
|
|
|
**2. `src/components/PageContainer.module.css` (Actualización - Añadir estilos genéricos)**
|
|
|
|
```css
|
|
/* ... (estilos existentes) ... */
|
|
|
|
.tempSectionTitle {
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.noContentMessage {
|
|
font-size: 16px;
|
|
color: var(--text-secondary);
|
|
text-align: center;
|
|
margin-top: 50px;
|
|
}
|
|
|
|
/* Media Queries para responsividad */
|
|
@media (max-width: 768px) {
|
|
/* ... (estilos existentes) ... */
|
|
}
|
|
```
|
|
|
|
**3. `src/components/Sidebar/Sidebar.tsx` (Actualización - Añadir `onLinkClick` prop)**
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import styles from './Sidebar.module.css';
|
|
|
|
interface SidebarProps {
|
|
activeLink: string;
|
|
onLinkClick: (linkId: string) => void;
|
|
}
|
|
|
|
const Sidebar: React.FC<SidebarProps> = ({ activeLink, onLinkClick }) => {
|
|
const navItems = [
|
|
{ id: 'Inicio', label: 'Inicio', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-home"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg> },
|
|
{ id: 'Biblioteca', label: 'Biblioteca', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-video"><path d="M23 7l-7 5V2l7 5z"></path><path d="M22 17H7a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h15v16z"></path></svg> },
|
|
{ id: 'Destinos', label: 'Destinos', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-share-2"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg> },
|
|
{ id: 'Miembros', label: 'Miembros', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-users"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg> },
|
|
];
|
|
|
|
const secondaryItems = [
|
|
{ id: 'Referidos', label: 'Referidos', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-gift"><polyline points="20 12 20 22 4 22 4 12"></polyline><rect x="2" y="7" width="20" height="5"></rect><line x1="12" y1="22" x2="12" y2="7"></line><path d="M12 7H7.5a2.5 2 0 0 1 0-5C11 2 12 7 12 7z"></path><path d="M12 7h4.5a2.5 2 0 0 0 0-5C13 2 12 7 12 7z"></path></svg> },
|
|
{ id: 'Configuracion', label: 'Configuración del equipo', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg> },
|
|
{ id: 'Estado del sistema', label: 'Estado del sistema', icon: <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-bar-chart-2"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg> },
|
|
];
|
|
|
|
return (
|
|
<aside className={styles.sidebar}>
|
|
<div className={styles.logoSection}>
|
|
<img src="https://static.streamyard.com/images/logo-icon.svg" alt="StreamYard Logo" className={styles.logoIcon} />
|
|
<span className={styles.logoText}>StreamYard</span>
|
|
</div>
|
|
|
|
<nav className={styles.navMenu}>
|
|
<ul className={styles.navList}>
|
|
{navItems.map((item) => (
|
|
<li key={item.id} className={`${styles.navItem} ${activeLink === item.id ? styles.activeLink : ''}`}>
|
|
<a
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onLinkClick(item.id);
|
|
}}
|
|
className={styles.navLink}
|
|
>
|
|
{item.icon}
|
|
<span>{item.label}</span>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className={styles.secondaryNavGroup}>
|
|
<ul className={styles.navList}>
|
|
{secondaryItems.map((item) => (
|
|
<li key={item.id} className={`${styles.navItem} ${activeLink === item.id ? styles.activeLink : ''}`}>
|
|
{/* Condicional para enlaces externos */}
|
|
{item.id === 'Estado del sistema' ? (
|
|
<a
|
|
href="https://status.streamyard.com/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={styles.navLink}
|
|
>
|
|
{item.icon}
|
|
<span>{item.label}</span>
|
|
</a>
|
|
) : (
|
|
<a
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onLinkClick(item.id);
|
|
}}
|
|
className={styles.navLink}
|
|
>
|
|
{item.icon}
|
|
<span>{item.label}</span>
|
|
</a>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className={styles.storageInfo}>
|
|
<h4 className={styles.storageTitle}>Almacenamiento <span className={styles.infoIcon}>?</span></h4>
|
|
<div className={styles.progressBarContainer}>
|
|
<div className={styles.progressBarFill} style={{ width: '0%' }}></div>
|
|
</div>
|
|
<p className={styles.storageUsage}>0 de 5 horas</p>
|
|
<a href="#" className={styles.addMoreLink}>Agregar más</a>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default Sidebar;
|
|
```
|
|
|
|
**4. `src/components/Header/Header.tsx` (Actualización - Añadir `DropdownMenu` y `Tooltip`)**
|
|
|
|
```tsx
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import styles from './Header.module.css';
|
|
import DropdownMenu from './DropdownMenu'; // Nuevo componente DropdownMenu
|
|
|
|
interface HeaderProps {
|
|
currentTheme: 'light' | 'dark';
|
|
onToggleTheme: () => void;
|
|
}
|
|
|
|
const Header: React.FC<HeaderProps> = ({ currentTheme, onToggleTheme }) => {
|
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Cierra el dropdown si se hace clic fuera
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(event.target as Node) &&
|
|
userMenuRef.current &&
|
|
!userMenuRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
const handleUserMenuClick = () => {
|
|
setIsDropdownOpen((prev) => !prev);
|
|
};
|
|
|
|
const handleNotificationClick = () => {
|
|
window.open('https://new.streamyard.com/', '_blank', 'noopener noreferrer');
|
|
};
|
|
|
|
return (
|
|
<header className={styles.header}>
|
|
<div className={styles.headerActions}>
|
|
<button className={styles.planButton}>Mejora tu plan</button>
|
|
|
|
<div className={styles.themeToggleGroup}>
|
|
<button
|
|
className={`${styles.themeToggleButton} ${currentTheme === 'light' ? styles.active : ''}`}
|
|
onClick={onToggleTheme}
|
|
aria-label="Toggle light theme"
|
|
data-tooltip={currentTheme === 'light' ? 'Modo claro' : 'Modo automático'} // Tooltip dinámico
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-sun">
|
|
<circle cx="12" cy="12" r="5"></circle>
|
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
|
</svg>
|
|
<span className={styles.tooltip}>Modo claro</span> {/* Tooltip */}
|
|
</button>
|
|
<button
|
|
className={`${styles.themeToggleButton} ${currentTheme === 'dark' ? styles.active : ''}`}
|
|
onClick={onToggleTheme}
|
|
aria-label="Toggle dark theme"
|
|
data-tooltip={currentTheme === 'dark' ? 'Modo oscuro' : 'Modo automático'} // Tooltip dinámico
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-moon">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
|
</svg>
|
|
<span className={styles.tooltip}>Modo oscuro</span> {/* Tooltip */}
|
|
</button>
|
|
</div>
|
|
|
|
<button className={styles.notificationButton} onClick={handleNotificationClick}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-bell">
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
|
</svg>
|
|
<span className={styles.notificationDot}></span>
|
|
</button>
|
|
|
|
<div className={styles.userMenuWrapper}>
|
|
<div className={styles.userMenu} onClick={handleUserMenuClick} ref={userMenuRef}>
|
|
<span>Mi cuenta</span>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-chevron-down">
|
|
<polyline points="6 9 12 15 18 9"></polyline>
|
|
</svg>
|
|
</div>
|
|
{isDropdownOpen && <DropdownMenu ref={dropdownRef} />}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
|
|
export default Header;
|
|
```
|
|
|
|
**5. `src/components/Header/Header.module.css` (Actualización - Añadir estilos para Dropdown y Tooltip)**
|
|
|
|
```css
|
|
/* ... (estilos existentes) ... */
|
|
|
|
.themeToggleButton {
|
|
/* ... (estilos existentes) ... */
|
|
position: relative; /* Para el tooltip */
|
|
}
|
|
|
|
.themeToggleButton .tooltip {
|
|
visibility: hidden;
|
|
background-color: var(--text-primary);
|
|
color: var(--surface-color);
|
|
text-align: center;
|
|
border-radius: 4px;
|
|
padding: 6px 10px;
|
|
position: absolute;
|
|
z-index: 1;
|
|
bottom: -35px; /* Posicionar debajo del botón */
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
font-size: 12px;
|
|
white-space: nowrap; /* Evita que el texto se rompa */
|
|
}
|
|
|
|
.themeToggleButton:hover .tooltip {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
}
|
|
|
|
.userMenuWrapper {
|
|
position: relative; /* Contenedor para posicionar el dropdown */
|
|
}
|
|
|
|
.userMenu {
|
|
/* ... (estilos existentes) ... */
|
|
}
|
|
|
|
/* Media Queries para responsividad */
|
|
@media (max-width: 768px) {
|
|
/* ... (estilos existentes) ... */
|
|
.themeToggleGroup {
|
|
display: none; /* Ocultar el selector de tema en móviles */
|
|
}
|
|
|
|
/* Ajustes para el tooltip en móviles si se decide mostrar */
|
|
.themeToggleButton .tooltip {
|
|
display: none; /* Ocultar tooltips en móviles por simplicidad */
|
|
}
|
|
}
|
|
```
|
|
|
|
**6. `src/components/Header/DropdownMenu.tsx` (Nuevo componente DropdownMenu)**
|
|
|
|
```tsx
|
|
import React, { forwardRef } from 'react';
|
|
import styles from './DropdownMenu.module.css';
|
|
|
|
const DropdownMenu = forwardRef<HTMLDivElement>((props, ref) => {
|
|
return (
|
|
<div className={styles.dropdownMenu} ref={ref}>
|
|
<ul className={styles.menuList}>
|
|
<li className={styles.menuItemHeader}>nexttv.stream@gmail.com</li>
|
|
<li className={styles.menuItem}><a href="#">Referidos</a></li>
|
|
<li className={styles.menuItem}><a href="#">Configuración del equipo</a></li>
|
|
<li className={styles.menuItem}><a href="#">Facturación</a></li>
|
|
<li className={styles.menuItem}><a href="#">Configuración de la cuenta</a></li>
|
|
<li className={styles.menuItemSeparator}></li> {/* Separador visual */}
|
|
<li className={styles.menuItem}><a href="#">Contacto</a></li>
|
|
<li className={styles.menuItem}><a href="#">Mis tickets de soporte</a></li>
|
|
<li className={styles.menuItem}><a href="#">Centro de ayuda</a></li>
|
|
<li className={styles.menuItem}><a href="#">Recursos para socios</a></li>
|
|
<li className={styles.menuItemSeparator}></li> {/* Separador visual */}
|
|
<li className={styles.menuItem}><a href="#">Términos de servicio</a></li>
|
|
<li className={styles.menuItem}><a href="#">Política de privacidad</a></li>
|
|
<li className={styles.menuItemSeparator}></li> {/* Separador visual */}
|
|
<li className={styles.menuItem}><a href="#">Cerrar sesión</a></li>
|
|
<li className={styles.menuItemButton}>
|
|
<button className={styles.helpButtonDropdown}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-help-circle">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
|
</svg>
|
|
Ayuda
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default DropdownMenu;
|
|
```
|
|
|
|
**7. `src/components/Header/DropdownMenu.module.css` (Nuevo CSS para DropdownMenu)**
|
|
|
|
```css
|
|
.dropdownMenu {
|
|
position: absolute;
|
|
top: 100%; /* Posiciona debajo del elemento padre */
|
|
right: 0;
|
|
background-color: var(--surface-color);
|
|
border-radius: 8px;
|
|
box-shadow: 0px 4px 12px var(--shadow-light); /* Sombra para el dropdown */
|
|
min-width: 250px;
|
|
z-index: 1500;
|
|
padding: 8px 0; /* Padding interno */
|
|
margin-top: 10px; /* Separación del botón "Mi cuenta" */
|
|
}
|
|
|
|
.menuList {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.menuItemHeader {
|
|
padding: 10px 16px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
margin-bottom: 8px; /* Espacio antes del primer separador */
|
|
}
|
|
|
|
.menuItem a {
|
|
display: block;
|
|
padding: 10px 16px;
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
transition: background-color 0.15s ease-in-out;
|
|
}
|
|
|
|
.menuItem a:hover {
|
|
background-color: var(--active-bg-light); /* Fondo azul claro al hover */
|
|
}
|
|
|
|
.menuItemSeparator {
|
|
height: 1px;
|
|
background-color: var(--border-light);
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.menuItemButton {
|
|
padding: 8px 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
.helpButtonDropdown {
|
|
background-color: var(--primary-blue);
|
|
color: #ffffff;
|
|
padding: 10px 16px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
width: 100%; /* Ocupa todo el ancho */
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.helpButtonDropdown:hover {
|
|
background-color: #146bdc; /* Azul un poco más oscuro al hacer hover */
|
|
}
|
|
```
|
|
|
|
**8. `src/components/HeroSection/HeroSection.tsx` (Actualización - Añadir `onOpenModal` prop)**
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import styles from './HeroSection.module.css';
|
|
import { ModalType } from '../../components/PageContainer'; // Importar ModalType
|
|
|
|
interface CardProps {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
onClick: () => void; // Añadir prop onClick
|
|
}
|
|
|
|
const CreateCard: React.FC<CardProps> = ({ icon, title, onClick }) => {
|
|
return (
|
|
<div className={styles.createCard} onClick={onClick}> {/* Añadir onClick */}
|
|
<div className={styles.cardIcon}>{icon}</div>
|
|
<p className={styles.cardTitle}>{title}</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface HeroSectionProps {
|
|
onOpenModal: (modalType: ModalType) => void;
|
|
}
|
|
|
|
const HeroSection: React.FC<HeroSectionProps> = ({ onOpenModal }) => {
|
|
return (
|
|
<section className={styles.heroSection}>
|
|
<h2 className={styles.sectionTitle}>Crear</h2>
|
|
<div className={styles.cardGrid}>
|
|
<CreateCard
|
|
icon={<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-video"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>}
|
|
title="Transmisión en vivo"
|
|
onClick={() => onOpenModal('createLiveStream')} // Abrir modal específico
|
|
/>
|
|
<CreateCard
|
|
icon={<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-radio"><circle cx="12" cy="12" r="2"></circle><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"></path></svg>}
|
|
title="Grabación"
|
|
onClick={() => onOpenModal('createRecording')} // Abrir modal específico
|
|
/>
|
|
<CreateCard
|
|
icon={<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-monitor"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>}
|
|
title="Seminario web On-Air"
|
|
onClick={() => onOpenModal('streamYardOnAir')} // Abrir modal específico
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default HeroSection;
|
|
```
|
|
|
|
**9. `src/components/Modal/Modal.tsx` (Nuevo componente genérico de Modal)**
|
|
|
|
```tsx
|
|
import React, { useEffect } from 'react';
|
|
import styles from './Modal.module.css';
|
|
|
|
interface ModalProps {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const Modal: React.FC<ModalProps> = ({ title, onClose, children }) => {
|
|
// Cierra el modal al presionar la tecla Escape
|
|
useEffect(() => {
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleEscape);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className={styles.modalOverlay} onClick={onClose}>
|
|
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}> {/* Evita cerrar al hacer clic dentro */}
|
|
<button className={styles.closeButton} onClick={onClose} aria-label="Cerrar">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-x">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
<h2 className={styles.modalTitle}>{title}</h2>
|
|
<div className={styles.modalBody}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Modal;
|
|
```
|
|
|
|
**10. `src/components/Modal/Modal.module.css` (Nuevo CSS para Modal)**
|
|
|
|
```css
|
|
.modalOverlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0, 0, 0, 0.5); /* Overlay semitransparente */
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 2000; /* Asegura que esté por encima de todo */
|
|
backdrop-filter: blur(2px); /* Efecto de desenfoque */
|
|
}
|
|
|
|
.modalContent {
|
|
background-color: var(--surface-color);
|
|
border-radius: 8px;
|
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.15); /* Sombra más prominente */
|
|
max-width: 500px; /* Ancho máximo */
|
|
width: 90%; /* Ajuste responsivo */
|
|
max-height: 90vh; /* Altura máxima con scroll */
|
|
overflow-y: auto;
|
|
position: relative;
|
|
padding: 24px;
|
|
}
|
|
|
|
.closeButton {
|
|
position: absolute;
|
|
top: 16px;
|
|
right: 16px;
|
|
background-color: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--text-secondary);
|
|
padding: 0;
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
border-radius: 50%;
|
|
transition: background-color 0.2s ease-in-out;
|
|
}
|
|
|
|
.closeButton:hover {
|
|
background-color: var(--border-light);
|
|
}
|
|
|
|
.modalTitle {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 24px;
|
|
margin-top: 0;
|
|
padding-right: 40px; /* Espacio para el botón de cerrar */
|
|
}
|
|
|
|
.modalBody {
|
|
/* Estilos para el contenido dentro del modal */
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* Media Queries para responsividad */
|
|
@media (max-width: 768px) {
|
|
.modalContent {
|
|
width: 95%;
|
|
padding: 16px;
|
|
}
|
|
|
|
.modalTitle {
|
|
font-size: 18px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.closeButton {
|
|
top: 10px;
|
|
right: 10px;
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
}
|
|
```
|
|
|
|
**11. `src/components/Forms/ToggleSwitch.tsx` (Nuevo componente ToggleSwitch)**
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import styles from './ToggleSwitch.module.css';
|
|
|
|
interface ToggleSwitchProps {
|
|
id: string;
|
|
label: string;
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
description?: string;
|
|
}
|
|
|
|
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ id, label, checked, onChange, description }) => {
|
|
return (
|
|
<div className={styles.toggleWrapper}>
|
|
<label htmlFor={id} className={styles.toggleLabel}>
|
|
{label}
|
|
{description && <span className={styles.toggleDescription}>{description}</span>}
|
|
<input
|
|
type="checkbox"
|
|
id={id}
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className={styles.toggleInput}
|
|
/>
|
|
<span className={styles.toggleSlider}></span>
|
|
</label>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ToggleSwitch;
|
|
```
|
|
|
|
**12. `src/components/Forms/ToggleSwitch.module.css` (Nuevo CSS para ToggleSwitch)**
|
|
|
|
```css
|
|
.toggleWrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.toggleLabel {
|
|
display: flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
position: relative;
|
|
}
|
|
|
|
.toggleDescription {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-left: 8px;
|
|
flex-basis: 100%; /* Permite que la descripción ocupe su propio espacio si es larga */
|
|
}
|
|
|
|
.toggleInput {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggleSlider {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 40px; /* Ancho del slider */
|
|
height: 22px; /* Altura del slider */
|
|
background-color: var(--border-light); /* Fondo cuando está OFF */
|
|
border-radius: 11px; /* Forma de píldora */
|
|
transition: background-color 0.2s ease;
|
|
margin-left: 12px;
|
|
flex-shrink: 0; /* Evita que el slider se encoja */
|
|
}
|
|
|
|
.toggleSlider:before {
|
|
content: "";
|
|
position: absolute;
|
|
height: 18px; /* Diámetro del "handle" */
|
|
width: 18px; /* Diámetro del "handle" */
|
|
left: 2px;
|
|
bottom: 2px;
|
|
background-color: white;
|
|
border-radius: 50%;
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1); /* Sombra del handle */
|
|
}
|
|
|
|
.toggleInput:checked + .toggleSlider {
|
|
background-color: var(--primary-blue); /* Fondo cuando está ON */
|
|
}
|
|
|
|
.toggleInput:checked + .toggleSlider:before {
|
|
transform: translateX(18px); /* Mueve el handle a la derecha */
|
|
}
|
|
```
|
|
|
|
**13. `src/components/Modals/CreateRecordingModal.tsx` (Nuevo componente para modal "Crea una grabación")**
|
|
|
|
```tsx
|
|
import React, { useState } from 'react';
|
|
import styles from './ModalContent.module.css'; // Usar un CSS genérico para contenido de modal
|
|
import ToggleSwitch from '../Forms/ToggleSwitch';
|
|
|
|
const CreateRecordingModal: React.FC = () => {
|
|
const [recordingTitle, setRecordingTitle] = useState('');
|
|
const [localRecordings, setLocalRecordings] = useState(false);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
console.log('Crear grabación:', { recordingTitle, localRecordings });
|
|
// Lógica para crear grabación
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<div className={styles.formGroup}>
|
|
<label htmlFor="recordingTitle" className={styles.formLabel}>Título de la grabación</label>
|
|
<input
|
|
type="text"
|
|
id="recordingTitle"
|
|
className={styles.formInput}
|
|
value={recordingTitle}
|
|
onChange={(e) => setRecordingTitle(e.target.value)}
|
|
placeholder="Mi nueva grabación"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<ToggleSwitch
|
|
id="localRecordings"
|
|
label="Grabaciones locales?"
|
|
description="Grabaciones de la mejor calidad con archivos de audio y video individuales para cada participante, necesario para las grabaciones en 4K Ultra HD."
|
|
checked={localRecordings}
|
|
onChange={setLocalRecordings}
|
|
/>
|
|
|
|
<div className={styles.modalActions}>
|
|
<button type="submit" className={styles.primaryButton}>Crear grabación</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
export default CreateRecordingModal;
|
|
```
|
|
|
|
**14. `src/components/Modals/StreamYardOnAirModal.tsx` (Nuevo componente para modal "Obtén StreamYard On-Air!")**
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import styles from './ModalContent.module.css';
|
|
|
|
const StreamYardOnAirModal: React.FC = () => {
|
|
return (
|
|
<div>
|
|
<p className={styles.modalDescription}>
|
|
Organizo un seminario web, una transmisión en vivo o un evento en StreamYard, o insértalo en tu sitio web. Además, tienes la opción de recopilar direcciones de correo electrónico a través de un formulario de registro.
|
|
</p>
|
|
|
|
{/* Simulación del video */}
|
|
<div className={styles.videoPlaceholder}>
|
|
<img src="https://static.streamyard.com/images/hero_img.svg" alt="StreamYard On-Air Video" className={styles.videoThumbnail} />
|
|
{/* Aquí iría el reproductor de video real */}
|
|
</div>
|
|
|
|
<h3 className={styles.planFeatureTitle}>Advanced</h3>
|
|
<p className={styles.planFeatureDescription}>Para profesionales que quieren llevar su contenido a otro nivel.</p>
|
|
|
|
<ul className={styles.featureList}>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Webinars en vivo
|
|
</li>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Alta definición completa (1080p)
|
|
</li>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Grabaciones locales en 4K (2160p)
|
|
</li>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Logos, Superposiciones, Clips de video, Fondos
|
|
</li>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Transmisión múltiple - 8 destinos
|
|
</li>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Transmisión y grabación ilimitados
|
|
</li>
|
|
<li className={styles.featureItem}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
y mucho más
|
|
</li>
|
|
</ul>
|
|
|
|
<div className={styles.modalActions}>
|
|
<button className={styles.primaryButton}>Continuar</button>
|
|
<a href="#" className={styles.secondaryLink}>Mostrar otros planes</a>
|
|
<p className={styles.commitmentText}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={styles.checkIcon}><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
Sin compromisos y fácil de cancelar.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StreamYardOnAirModal;
|
|
```
|
|
|
|
**15. `src/components/Modals/CreateLiveStreamModal.tsx` (Nuevo componente para modal "Crear transmisión en vivo")**
|
|
|
|
```tsx
|
|
import React, { useState } from 'react';
|
|
import styles from './ModalContent.module.css'; // Usar CSS genérico para contenido de modal
|
|
|
|
const CreateLiveStreamModal: React.FC = () => {
|
|
const [source, setSource] = useState<'studio' | 'preRecorded'>('studio');
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [addReferenceMessage, setAddReferenceMessage] = useState(false);
|
|
const [privacy, setPrivacy] = useState('Publica');
|
|
const [category, setCategory] = useState('');
|
|
const [scheduleLater, setScheduleLater] = useState(false);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
console.log('Crear transmisión en vivo:', {
|
|
source, title, description, addReferenceMessage, privacy, category, scheduleLater
|
|
});
|
|
// Lógica para crear transmisión en vivo
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.formLabel}>Fuente <span className={styles.infoIcon}>?</span></label>
|
|
<div className={styles.radioGroup}>
|
|
<label className={styles.radioLabel}>
|
|
<input
|
|
type="radio"
|
|
name="source"
|
|
value="studio"
|
|
checked={source === 'studio'}
|
|
onChange={() => setSource('studio')}
|
|
className={styles.radioInput}
|
|
/>
|
|
Estudio
|
|
</label>
|
|
<label className={styles.radioLabel}>
|
|
<input
|
|
type="radio"
|
|
name="source"
|
|
value="preRecorded"
|
|
checked={source === 'preRecorded'}
|
|
onChange={() => setSource('preRecorded')}
|
|
className={styles.radioInput}
|
|
/>
|
|
Video pregrabado
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.formLabel}>Selecciona los destinos</label>
|
|
<div className={styles.destinationsRow}>
|
|
{/* Avatar de destino simulado */}
|
|
<div className={styles.destinationAvatar} data-tooltip="Da Nation Freedom">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="#E5E7EB" stroke="none" className="avatar"><circle cx="12" cy="12" r="10"></circle><text x="50%" y="50%" dominantBaseline="middle" textAnchor="middle" fill="#6B7280" fontSize="12px">a</text></svg>
|
|
<span className={styles.tooltip}>Da Nation Freedom</span> {/* Tooltip */}
|
|
</div>
|
|
<button type="button" className={styles.addDestinationButton}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-plus">
|
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
</svg>
|
|
</button>
|
|
<a href="#" className={styles.secondaryLink}>Omitir por ahora</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label htmlFor="streamTitle" className={styles.formLabel}>Título</label>
|
|
<input
|
|
type="text"
|
|
id="streamTitle"
|
|
className={styles.formInput}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
required
|
|
/>
|
|
<span className={styles.charCount}>0/100</span>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label htmlFor="streamDescription" className={styles.formLabel}>Descripción</label>
|
|
<textarea
|
|
id="streamDescription"
|
|
className={styles.formTextarea}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Cuéntanos un poco sobre esta transmisión en vivo"
|
|
></textarea>
|
|
<span className={styles.charCount}>0/5000</span>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={addReferenceMessage}
|
|
onChange={(e) => setAddReferenceMessage(e.target.checked)}
|
|
className={styles.checkboxInput}
|
|
/>
|
|
Agrega el mensaje de referencia a la descripción <span className={styles.infoIcon}>?</span>
|
|
<p className={styles.checkboxDescription}>Gana $25 por cada crédito de referencia exitoso.</p>
|
|
</label>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label htmlFor="privacy" className={styles.formLabel}>Privacidad</label>
|
|
<select
|
|
id="privacy"
|
|
className={styles.formSelect}
|
|
value={privacy}
|
|
onChange={(e) => setPrivacy(e.target.value)}
|
|
>
|
|
<option value="Publica">Pública</option>
|
|
<option value="Privada">Privada</option>
|
|
<option value="NoListada">No listada</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label htmlFor="category" className={styles.formLabel}>Categoría</label>
|
|
<select
|
|
id="category"
|
|
className={styles.formSelect}
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
>
|
|
<option value="">Seleccionar categoría</option>
|
|
{/* Opciones de categoría */}
|
|
</select>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={scheduleLater}
|
|
onChange={(e) => setScheduleLater(e.target.checked)}
|
|
className={styles.checkboxInput}
|
|
/>
|
|
Programar para más tarde
|
|
</label>
|
|
</div>
|
|
|
|
<div className={styles.modalActions}>
|
|
<button type="submit" className={styles.primaryButton}>Crear transmisión en vivo</button>
|
|
<p className={styles.smallText}>Esta transmisión no se grabará en StreamYard. Para grabar, tendrás que <a href="#" className={styles.secondaryLink}>pasarte a un plan superior</a>.</p>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
export default CreateLiveStreamModal;
|
|
```
|
|
|
|
**16. `src/components/Modals/ModalContent.module.css` (Nuevo CSS genérico para el contenido de los modales de formulario)**
|
|
|
|
```css
|
|
.formGroup {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.formLabel {
|
|
display: block;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
margin-bottom: 8px;
|
|
display: flex; /* Para el icono de info */
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.formInput,
|
|
.formTextarea,
|
|
.formSelect {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-light);
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
background-color: var(--surface-color);
|
|
box-sizing: border-box; /* Asegura que padding y borde no aumenten el ancho */
|
|
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
|
}
|
|
|
|
.formInput:focus,
|
|
.formTextarea:focus,
|
|
.formSelect:focus {
|
|
border-color: var(--primary-blue);
|
|
box-shadow: 0 0 0 2px rgba(24, 118, 242, 0.2); /* Sombra de foco azul */
|
|
outline: none;
|
|
}
|
|
|
|
.formTextarea {
|
|
min-height: 80px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.charCount {
|
|
display: block;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
text-align: right;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.radioGroup {
|
|
display: flex;
|
|
gap: 20px;
|
|
}
|
|
|
|
.radioLabel {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.radioInput {
|
|
/* Estilos básicos, se puede personalizar con CSS avanzado para radios custom */
|
|
transform: scale(1.1); /* Ligeramente más grande */
|
|
accent-color: var(--primary-blue); /* Color del radio button */
|
|
}
|
|
|
|
.checkboxLabel {
|
|
display: flex;
|
|
align-items: flex-start; /* Alinea el texto con el checkbox */
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.checkboxInput {
|
|
margin-top: 3px; /* Ajuste visual */
|
|
transform: scale(1.1);
|
|
accent-color: var(--primary-blue);
|
|
flex-shrink: 0; /* Evita que el checkbox se encoja */
|
|
}
|
|
|
|
.checkboxDescription {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
line-height: 1.4;
|
|
flex-grow: 1; /* Permite que la descripción ocupe el espacio restante */
|
|
}
|
|
|
|
.modalActions {
|
|
margin-top: 30px;
|
|
text-align: right;
|
|
border-top: 1px solid var(--border-light);
|
|
padding-top: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.primaryButton {
|
|
background-color: var(--primary-blue);
|
|
color: #ffffff;
|
|
padding: 12px 20px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: background-color 0.2s ease-in-out;
|
|
width: auto; /* Para que no ocupe todo el ancho */
|
|
align-self: flex-end; /* Alinea a la derecha en columna */
|
|
}
|
|
|
|
.primaryButton:hover {
|
|
background-color: #146bdc;
|
|
}
|
|
|
|
.secondaryLink {
|
|
color: var(--primary-blue);
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.secondaryLink:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.infoIcon {
|
|
background-color: var(--border-light);
|
|
border-radius: 50%;
|
|
width: 16px;
|
|
height: 16px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
cursor: help; /* Indica que hay más información */
|
|
}
|
|
|
|
.modalDescription {
|
|
font-size: 15px;
|
|
line-height: 1.6;
|
|
color: var(--text-primary);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.videoPlaceholder {
|
|
background-color: #000; /* Fondo negro para simular el video */
|
|
width: 100%;
|
|
padding-bottom: 56.25%; /* Ratio 16:9 */
|
|
position: relative;
|
|
border-radius: 8px;
|
|
overflow: hidden; /* Asegura que la imagen no se salga */
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.videoThumbnail {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
|
|
.planFeatureTitle {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-top: 24px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.planFeatureDescription {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.featureList {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0 0 20px 0;
|
|
}
|
|
|
|
.featureItem {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.checkIcon {
|
|
color: #28a745; /* Verde para los checks */
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.commitmentText {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-top: 10px;
|
|
align-self: flex-end; /* Alinea a la derecha en columna */
|
|
}
|
|
|
|
.destinationsRow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.destinationAvatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background-color: var(--border-light);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
position: relative; /* Para el tooltip */
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Tooltip para avatares de destino */
|
|
.destinationAvatar .tooltip {
|
|
visibility: hidden;
|
|
background-color: var(--text-primary);
|
|
color: var(--surface-color);
|
|
text-align: center;
|
|
border-radius: 4px;
|
|
padding: 6px 10px;
|
|
position: absolute;
|
|
z-index: 10;
|
|
bottom: 120%; /* Posicionar encima del avatar */
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.destinationAvatar:hover .tooltip {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
}
|
|
|
|
.addDestinationButton {
|
|
background-color: transparent;
|
|
border: 1px solid var(--border-light);
|
|
border-radius: 50%;
|
|
width: 36px;
|
|
height: 36px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
|
|
.addDestinationButton:hover {
|
|
border-color: var(--primary-blue);
|
|
color: var(--primary-blue);
|
|
background-color: var(--active-bg-light);
|
|
}
|
|
|
|
.smallText {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
text-align: right;
|
|
}
|
|
|
|
/* Media Queries para responsividad */
|
|
@media (max-width: 768px) {
|
|
.formLabel {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.formInput,
|
|
.formTextarea,
|
|
.formSelect,
|
|
.radioLabel,
|
|
.checkboxLabel {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.radioGroup {
|
|
flex-direction: column; /* Apilar radios en móviles */
|
|
gap: 10px;
|
|
}
|
|
|
|
.charCount, .checkboxDescription, .smallText {
|
|
font-size: 11px;
|
|
}
|
|
|
|
.modalActions {
|
|
padding-top: 15px;
|
|
gap: 10px;
|
|
}
|
|
.primaryButton, .secondaryLink, .commitmentText {
|
|
align-self: center; /* Centrar botones y enlaces en columna en móvil */
|
|
width: 100%; /* Ocupar todo el ancho */
|
|
text-align: center;
|
|
}
|
|
|
|
.primaryButton {
|
|
padding: 10px 15px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.secondaryLink {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.featureList {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.destinationAvatar {
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
|
|
.destinationAvatar .tooltip {
|
|
display: none; /* Ocultar tooltips en móviles */
|
|
}
|
|
|
|
.addDestinationButton {
|
|
width: 32px;
|
|
height: 32px;
|
|
}
|
|
}
|
|
``` |