feat(modal-parts): add modular components for modal UI

- Introduced ModalDestinationButton for destination selection with customizable icons and labels.
- Added ModalInput for text input with optional character counter.
- Implemented ModalLink for reusable links styled as underlined text.
- Created ModalPlatformCard for platform selection with badges.
- Developed ModalRadioGroup for radio button groups with custom styling.
- Added ModalSection for grouping modal content with optional labels.
- Implemented ModalSelect for dropdown selections with custom styling.
- Created ModalShareButtons for sharing options via Gmail, Email, and Messenger.
- Developed ModalTextarea for multi-line text input with character counter.
- Introduced ModalToggle for toggle switches with optional help text and links.
- Updated README.md with component descriptions, usage examples, and design guidelines.
- Added index.ts for centralized exports of modal components.
This commit is contained in:
Cesar Mendivil 2025-11-06 00:32:08 -07:00
parent 343ba1675e
commit e43686e36d
48 changed files with 3444 additions and 235 deletions

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react'
import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components'
interface Props {
open: boolean
onClose: () => void
}
/**
* Ejemplo de uso de componentes modulares para modales
* Demuestra cómo reutilizar las partes creadas
*/
const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
const [enabled, setEnabled] = useState(true)
return (
<Modal
open={open}
onClose={onClose}
title="Ejemplo de modal modular"
width="md"
footer={
<>
<button
onClick={onClose}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #dadce0',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancelar
</button>
<button
onClick={onClose}
style={{
padding: '8px 24px',
background: '#1a73e8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Aceptar
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ margin: 0, color: '#5f6368', fontSize: '14px' }}>
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer más en{' '}
<ModalLink href="https://example.com/docs">nuestra documentación</ModalLink>.
</p>
<ModalCopyInput
value="https://example.com/invite/abc123"
buttonText="Copiar enlace"
/>
<ModalShareButtons
onGmail={() => console.log('Gmail')}
onEmail={() => console.log('Email')}
onMessenger={() => console.log('Messenger')}
/>
<ModalToggle
checked={enabled}
onChange={setEnabled}
label="Habilitar esta funcionalidad"
helpLink="https://example.com/help"
/>
</div>
</Modal>
)
}
export default ExampleModal

View File

@ -0,0 +1,70 @@
/* Component-specific styles for InviteGuestsModal */
.content {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.helpText {
color: #5f6368;
font-size: 13px;
margin: 0 0 -4px 0;
line-height: 1.5;
width: 100%;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
.planText {
color: #5f6368;
font-size: 13px;
margin: -4px 0 0 0;
line-height: 1.5;
width: 100%;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
.copyInput {
margin: 0;
}
.shareButtons {
margin: 0;
}
.toggle {
padding-top: 12px;
border-top: 1px solid #dadce0;
margin-top: 4px;
}
/* Dark mode */
[data-theme="dark"] .helpText,
[data-theme="dark"] .planText {
color: #9aa0a6;
}
[data-theme="dark"] .toggle {
border-top-color: #5f6368;
}
/* Dark mode */
[data-theme="dark"] .helpText,
[data-theme="dark"] .planText {
color: #9aa0a6;
}
[data-theme="dark"] .toggle {
border-top-color: #5f6368;
}

View File

@ -0,0 +1,72 @@
import React, { useState } from 'react'
import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components'
import styles from './InviteGuestsModal.module.css'
interface Props {
open: boolean
onClose: () => void
link: string
}
const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
const [guestPermissions, setGuestPermissions] = useState(true)
const handleGmailShare = () => {
window.open(`https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`, '_blank')
}
const handleEmailShare = () => {
window.location.href = `mailto:?body=${encodeURIComponent(link)}`
}
const handleMessengerShare = () => {
console.log('Compartir por Messenger:', link)
}
return (
<Modal
open={open}
onClose={onClose}
title="Agrega invitados al estudio"
width="md"
>
<div className={styles.content}>
<p className={styles.helpText}>
Envía este enlace a tus invitados. Es posible que también quieras compartir nuestras {' '}
<ModalLink href="https://support.streamyard.com/hc/en-us/articles/360043291612">
instrucciones para invitados
</ModalLink>.
</p>
<p className={styles.planText}>
Puedes tener hasta 6 personas en pantalla a la vez. {' '}
<ModalLink href="/pricing">Mejora tu plan</ModalLink> {' '}
si necesitas más.
</p>
<ModalCopyInput
value={link}
buttonText="Copiar"
className={styles.copyInput}
/>
<ModalShareButtons
onGmail={handleGmailShare}
onEmail={handleEmailShare}
onMessenger={handleMessengerShare}
className={styles.shareButtons}
/>
<ModalToggle
checked={guestPermissions}
onChange={setGuestPermissions}
label="Los invitados pueden transmitir esto a sus propios destinos"
helpLink="https://support.streamyard.com/hc/en-us/articles/360043291612"
className={styles.toggle}
/>
</div>
</Modal>
)
}
export default InviteGuestsModal

View File

@ -1,164 +1,159 @@
.modalOverlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
/* NewTransmissionModal - StreamYard style */
.backButton {
position: absolute;
top: 16px;
left: 16px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 50%;
color: #5f6368;
cursor: pointer;
transition: all 0.15s;
z-index: 1;
}
.modalContent {
background-color: var(--surface-color);
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s ease;
.backButton:hover {
background-color: #f1f3f4;
color: #202124;
}
.modalHeader {
padding: 20px 24px;
border-bottom: 1px solid var(--border-light);
.content {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.platformGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
padding: 0 24px 24px 24px;
margin-top: -4px;
}
.destinations {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.skipNowContainer {
margin-top: 16px;
text-align: right;
}
.selectedDestination {
position: relative;
}
.modalTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
.selectedDestination::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translate(-28px, -3px);
width: 56px;
height: 56px;
border: 3px solid #1a73e8;
border-radius: 50%;
pointer-events: none;
box-sizing: border-box;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid #e8eaed;
margin: 0 -24px -20px -24px;
gap: 16px;
}
.footerNote {
color: #5f6368;
font-size: 12px;
margin: 0;
line-height: 1.4;
flex: 1;
max-width: 320px;
}
.createButton {
padding: 8px 24px;
background-color: #1a73e8;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
min-width: 120px;
}
.createButton:hover:not(:disabled) {
background-color: #1765cc;
}
.createButton:disabled {
background-color: #e8eaed;
color: #80868b;
cursor: not-allowed;
}
/* Dark mode */
[data-theme="dark"] .backButton {
color: #9aa0a6;
}
[data-theme="dark"] .backButton:hover {
background-color: #3c4043;
color: #e8eaed;
}
[data-theme="dark"] .footer {
border-top-color: #5f6368;
}
[data-theme="dark"] .createButton {
background-color: #8ab4f8;
color: #202124;
}
[data-theme="dark"] .createButton:hover:not(:disabled) {
background-color: #aecbfa;
}
[data-theme="dark"] .createButton:disabled {
background-color: #3c4043;
color: #5f6368;
}
/* Blank stream form */
.blankStreamForm {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 8px;
}
.blankStreamDescription {
color: #5f6368;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.closeButton {
position: absolute;
top: 20px;
right: 20px;
background-color: transparent;
border: none;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.closeButton:hover {
background-color: var(--border-light);
}
.modalBody {
padding: 24px;
}
.formGroup {
margin-bottom: 20px;
}
.formLabel {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
.formInput,
.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);
transition: all 0.2s ease;
}
.formInput:focus,
.formSelect:focus {
outline: none;
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--border-light);
}
.cancelButton {
padding: 10px 20px;
background-color: transparent;
border: 1px solid var(--border-light);
color: var(--text-primary);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.cancelButton:hover {
background-color: var(--border-light);
}
.submitButton {
/* Match header .planButton visual: transparent with primary border, fill on hover */
padding: 10px 20px;
background-color: transparent;
border: 1px solid var(--primary-blue);
color: var(--primary-blue);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.submitButton:hover {
background-color: var(--primary-blue);
color: white;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.modalContent {
width: 95%;
max-height: 95vh;
}
.modalBody {
padding: 20px;
}
[data-theme="dark"] .blankStreamDescription {
color: #9aa0a6;
}

View File

@ -1,4 +1,23 @@
import React, { useState } from 'react'
import {
Modal,
ModalRadioGroup,
ModalSection,
ModalDestinationButton,
ModalInput,
ModalTextarea,
ModalSelect,
ModalCheckbox,
ModalLink,
ModalDateTimeGroup,
ModalButton,
ModalButtonGroup,
ModalPlatformCard
} from '@shared/components'
import { MdVideocam, MdVideoLibrary, MdAdd, MdImage, MdAutoAwesome, MdArrowBack } from 'react-icons/md'
import { FaYoutube, FaFacebook, FaLinkedin, FaTwitch, FaInstagram, FaKickstarterK } from 'react-icons/fa'
import { FaXTwitter } from 'react-icons/fa6'
import { BsInfoCircle } from 'react-icons/bs'
import styles from './NewTransmissionModal.module.css'
import type { Transmission } from '../types'
@ -8,81 +27,390 @@ interface Props {
onCreate: (t: Transmission) => void
}
const NewTransmissionModal: React.FC<Props> = ({ open, onClose, onCreate }) => {
const [title, setTitle] = useState('')
const [platform, setPlatform] = useState('YouTube')
const [scheduled, setScheduled] = useState('')
interface DestinationData {
id: string
platform: string
icon: React.ReactNode
badge?: React.ReactNode
}
if (!open) return null
const NewTransmissionModal: React.FC<Props> = ({ open, onClose, onCreate }) => {
const [view, setView] = useState<'main' | 'add-destination'>('main')
const [source, setSource] = useState('studio')
const [destinations, setDestinations] = useState<DestinationData[]>([
{
id: 'yt_1',
platform: 'YouTube',
icon: <FaYoutube color="#FF0000" />,
badge: <span style={{ color: '#FF0000', fontSize: '12px' }}></span>
}
])
const [selectedDestination, setSelectedDestination] = useState<string | null>(null)
// Blank stream
const [blankTitle, setBlankTitle] = useState('')
// Form fields
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [privacy, setPrivacy] = useState('Pública')
const [category, setCategory] = useState('')
const [addReferral, setAddReferral] = useState(true)
const [scheduleForLater, setScheduleForLater] = useState(false)
// Scheduled date/time
const [scheduledDate, setScheduledDate] = useState('')
const [scheduledHour, setScheduledHour] = useState('01')
const [scheduledMinute, setScheduledMinute] = useState('10')
const generateId = () => `t_${Date.now()}_${Math.floor(Math.random()*1000)}`
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const t: Transmission = { id: generateId(), title, platform, scheduled }
onCreate(t)
setTitle('')
setPlatform('YouTube')
setScheduled('')
const handleAddDestination = () => {
setView('add-destination')
}
const handleBackToMain = () => {
setView('main')
}
const handleSkipForNow = () => {
setSelectedDestination('blank')
}
const handlePlatformSelect = (platform: string) => {
const platformData: Record<string, { icon: React.ReactNode; color: string }> = {
'YouTube': { icon: <FaYoutube color="#FF0000" />, color: '#FF0000' },
'Facebook': { icon: <FaFacebook color="#1877F2" />, color: '#1877F2' },
'LinkedIn': { icon: <FaLinkedin color="#0A66C2" />, color: '#0A66C2' },
'X (Twitter)': { icon: <FaXTwitter color="#000000" />, color: '#000000' },
'Twitch': { icon: <FaTwitch color="#9146FF" />, color: '#9146FF' },
'Instagram Live': { icon: <FaInstagram color="#E4405F" />, color: '#E4405F' },
'Kick': { icon: <FaKickstarterK color="#53FC18" />, color: '#53FC18' },
'Brightcove': { icon: <div style={{ fontSize: '24px', fontWeight: 'bold' }}>B</div>, color: '#000000' }
}
const newDest: DestinationData = {
id: `dest_${Date.now()}`,
platform,
icon: platformData[platform]?.icon || <MdAdd />,
badge: platform === 'YouTube' ? <span style={{ color: '#FF0000', fontSize: '12px' }}></span> : undefined
}
setDestinations([...destinations, newDest])
setView('main')
}
const handleDestinationClick = (destId: string) => {
// Si el destino ya está seleccionado, deseleccionarlo
if (selectedDestination === destId) {
setSelectedDestination(null)
} else {
setSelectedDestination(destId)
}
}
const handleCreate = () => {
if (!selectedDestination) {
alert('Por favor selecciona un destino de transmisión')
return
}
// Si es transmisión en blanco, usar el título del formulario blanco
if (selectedDestination === 'blank') {
// Por el momento solo cierra el modal
// TODO: navegar a studio con título blankTitle
onClose()
return
}
const t: Transmission = {
id: generateId(),
title: title || 'Nueva transmisión',
platform: destinations.find(d => d.id === selectedDestination)?.platform || 'YouTube',
scheduled: ''
}
onCreate(t)
// Reset form
setView('main')
setSource('studio')
setSelectedDestination(null)
setTitle('')
setDescription('')
setPrivacy('Pública')
setCategory('')
setAddReferral(true)
setScheduleForLater(false)
setScheduledDate('')
setScheduledHour('01')
setScheduledMinute('10')
setBlankTitle('')
}
const modalTitle = view === 'add-destination' ? 'Agregar destino' : 'Crear transmisión en vivo'
const showBackButton = view === 'add-destination'
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>Crear transmisión en vivo</h3>
<button className={styles.closeButton} onClick={onClose}>×</button>
<Modal
open={open}
onClose={onClose}
title={modalTitle}
width="md"
>
{showBackButton && (
<button onClick={handleBackToMain} className={styles.backButton}>
<MdArrowBack size={20} />
</button>
)}
{view === 'main' && (
<>
<div className={styles.content}>
<ModalSection>
<ModalRadioGroup
label="Fuente"
helpIcon={<BsInfoCircle />}
name="source"
value={source}
onChange={setSource}
options={[
{ value: 'studio', label: 'Estudio', icon: <MdVideocam /> },
{ value: 'prerecorded', label: 'Video pregrabado', icon: <MdVideoLibrary /> }
]}
/>
</ModalSection>
<ModalSection label="Selecciona los destinos">
<div className={styles.destinations}>
{destinations.map((dest) => (
<div key={dest.id} className={selectedDestination === dest.id ? styles.selectedDestination : ''}>
<ModalDestinationButton
icon={dest.icon}
label=""
badge={dest.badge}
onClick={() => handleDestinationClick(dest.id)}
title={dest.platform}
/>
</div>
))}
<ModalDestinationButton
icon={<MdAdd />}
label=""
onClick={handleAddDestination}
title="Agregar destino"
/>
{!selectedDestination && (
<div className={styles.skipNowContainer}>
<ModalLink onClick={handleSkipForNow}>
Omitir por ahora
</ModalLink>
</div>
)}
</div>
</ModalSection>
{selectedDestination && selectedDestination !== 'blank' && (
<>
<ModalSection>
<ModalInput
label="Título"
value={title}
onChange={setTitle}
maxLength={100}
showCounter={true}
/>
</ModalSection>
<ModalSection>
<ModalTextarea
label="Descripción"
value={description}
onChange={setDescription}
placeholder="Cuéntanos un poco sobre esta transmisión en vivo"
maxLength={5000}
showCounter={true}
rows={3}
/>
</ModalSection>
<ModalSection>
<ModalCheckbox
checked={addReferral}
onChange={setAddReferral}
label="Agrega el mensaje de referencia a la descripción"
helpIcon={<BsInfoCircle />}
subtext="Gana $25 en crédito por cada referencia exitosa."
/>
</ModalSection>
<ModalSection>
<ModalSelect
label="Privacidad"
value={privacy}
onChange={setPrivacy}
options={[
{ value: 'Pública', label: 'Pública' },
{ value: 'No listada', label: 'No listada' },
{ value: 'Privada', label: 'Privada' }
]}
/>
</ModalSection>
<ModalSection>
<ModalSelect
label="Categoría"
value={category}
onChange={setCategory}
placeholder="Seleccionar categoría"
options={[
{ value: 'gaming', label: 'Juegos' },
{ value: 'education', label: 'Educación' },
{ value: 'entertainment', label: 'Entretenimiento' },
{ value: 'music', label: 'Música' },
{ value: 'sports', label: 'Deportes' },
{ value: 'news', label: 'Noticias' },
{ value: 'tech', label: 'Tecnología' }
]}
/>
</ModalSection>
<ModalSection>
<ModalCheckbox
checked={scheduleForLater}
onChange={setScheduleForLater}
label="Programar para más tarde"
/>
</ModalSection>
{scheduleForLater && (
<>
<ModalSection>
<ModalDateTimeGroup
label="Hora de inicio programada"
helpIcon={<BsInfoCircle />}
dateValue={scheduledDate}
hourValue={scheduledHour}
minuteValue={scheduledMinute}
onDateChange={setScheduledDate}
onHourChange={setScheduledHour}
onMinuteChange={setScheduledMinute}
timezone="GMT-7"
/>
</ModalSection>
<ModalSection>
<ModalButtonGroup>
<ModalButton
variant="secondary"
icon={<MdImage />}
>
Subir imagen en miniatura
</ModalButton>
<ModalButton
variant="primary"
icon={<MdAutoAwesome />}
>
Crear con IA
</ModalButton>
</ModalButtonGroup>
</ModalSection>
</>
)}
</>
)}
{selectedDestination === 'blank' && (
<div className={styles.blankStreamForm}>
<p className={styles.blankStreamDescription}>
Empezarás una transmisión en el estudio sin configurar ningún destino.
Podrás agregar destinos más tarde desde el estudio.
</p>
<ModalInput
label="Título de la transmisión (opcional)"
value={blankTitle}
onChange={setBlankTitle}
placeholder="Ej: Mi transmisión en vivo"
maxLength={100}
/>
</div>
)}
</div>
<div className={styles.footer}>
{selectedDestination && selectedDestination !== 'blank' && (
<p className={styles.footerNote}>
Esta transmisión no se grabará en StreamYard. Para grabar, tendrás que{' '}
<ModalLink href="/pricing">pasarte a un plan superior.</ModalLink>
</p>
)}
<button
type="button"
onClick={handleCreate}
className={styles.createButton}
disabled={!selectedDestination}
>
{selectedDestination === 'blank' ? 'Empezar ahora' : 'Crear transmisión en vivo'}
</button>
</div>
</>
)}
{view === 'add-destination' && (
<div className={styles.platformGrid}>
<ModalPlatformCard
icon={<FaYoutube color="#FF0000" />}
label="YouTube"
onClick={() => handlePlatformSelect('YouTube')}
/>
<ModalPlatformCard
icon={<FaFacebook color="#1877F2" />}
label="Facebook"
onClick={() => handlePlatformSelect('Facebook')}
/>
<ModalPlatformCard
icon={<FaLinkedin color="#0A66C2" />}
label="LinkedIn"
onClick={() => handlePlatformSelect('LinkedIn')}
/>
<ModalPlatformCard
icon={<FaXTwitter color="#000000" />}
label="X (Twitter)"
onClick={() => handlePlatformSelect('X (Twitter)')}
/>
<ModalPlatformCard
icon={<FaTwitch color="#9146FF" />}
label="Twitch"
onClick={() => handlePlatformSelect('Twitch')}
/>
<ModalPlatformCard
icon={<FaInstagram color="#E4405F" />}
label="Instagram Live"
onClick={() => handlePlatformSelect('Instagram Live')}
/>
<ModalPlatformCard
icon={<FaKickstarterK color="#53FC18" />}
label="Kick"
onClick={() => handlePlatformSelect('Kick')}
badge="pro"
/>
<ModalPlatformCard
icon={<div style={{ fontSize: '24px', fontWeight: 'bold' }}>B</div>}
label="Brightcove"
onClick={() => handlePlatformSelect('Brightcove')}
/>
<ModalPlatformCard
icon={<div style={{ fontSize: '20px', fontWeight: 'bold' }}>RTMP</div>}
label="Otras plataformas"
onClick={() => handlePlatformSelect('RTMP')}
badge="pro"
/>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.modalBody}>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Título de la transmisión</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={styles.formInput}
placeholder="Ej: Mi primera transmisión"
required
/>
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Plataforma</label>
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className={styles.formSelect}
>
<option value="YouTube">YouTube</option>
<option value="Facebook">Facebook</option>
<option value="Twitch">Twitch</option>
<option value="LinkedIn">LinkedIn</option>
</select>
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Fecha y hora programada (opcional)</label>
<input
value={scheduled}
onChange={(e) => setScheduled(e.target.value)}
placeholder="YYYY-MM-DD HH:mm"
className={styles.formInput}
/>
</div>
</div>
<div className={styles.modalActions}>
<button type="button" onClick={onClose} className={styles.cancelButton}>
Cancelar
</button>
<button type="submit" className={styles.submitButton}>
Crear transmisión
</button>
</div>
</form>
</div>
</div>
)}
</Modal>
)
}

View File

@ -56,6 +56,7 @@
transition: box-shadow 0.18s ease, transform 0.12s ease, border-color 0.12s ease;
font-size: 15px;
font-weight: 600;
position: relative; /* for plus overlay */
}
.createCard:hover {
@ -64,6 +65,32 @@
border-color: var(--primary-blue);
}
/* inner container to keep content layout while allowing absolute plus */
.createCardInner {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
/* plus overlay on the right inside the card */
.createPlus {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%) translateX(8px) scale(0.85);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
display: flex;
align-items: center;
}
.createCard:hover .createPlus {
transform: translateY(-50%) translateX(0) scale(1);
opacity: 1;
}
.createIconBox {
width: 44px;
height: 44px;
@ -71,6 +98,43 @@
display: grid;
place-items: center;
background: var(--bg-muted);
box-shadow: none; /* flat */
}
/* color variants for left icon box and plus badge */
.cardBlue .createIconBox,
.cardBlue .plusBadge { background: rgba(26,115,232,0.12); }
.cardBlue .createIconBox svg { color: var(--primary-blue); fill: var(--primary-blue); stroke: var(--primary-blue); }
.cardRed .createIconBox,
.cardRed .plusBadge { background: rgba(234,67,53,0.12); }
.cardRed .createIconBox svg { color: #ea4335; fill: #ea4335; stroke: #ea4335; }
.cardGreen .createIconBox,
.cardGreen .plusBadge { background: rgba(52,168,83,0.12); }
.cardGreen .createIconBox svg { color: #34a853; fill: #34a853; stroke: #34a853; }
/* ensure icons are solid and not dimmed */
.createIconBox svg {
opacity: 1;
filter: none;
}
/* dark mode: make text inside createCard white for better contrast */
[data-theme="dark"] .createCard {
color: #ffffff;
}
[data-theme="dark"] .createCard .createIconBox svg {
filter: brightness(0) invert(1); /* ensure icons keep contrast if needed */
}
/* badge wrapper for the plus symbol to give a flat rounded rect background */
.plusBadge {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 8px;
}
@media (max-width: 1024px) {

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { MdVideocam, MdFiberManualRecord, MdSchool } from 'react-icons/md'
import PlusLarge from './icons/PlusLarge'
import { ThemeProvider } from './ThemeProvider'
import { Skeleton, SkeletonCard } from './Skeleton'
import styles from './PageContainer.module.css'
@ -74,26 +75,47 @@ const PageContainer: React.FC = () => {
<div className={styles.createGrid}>
<button
onClick={() => setIsModalOpen(true)}
className={styles.createCard}
className={`${styles.createCard} ${styles.cardBlue}`}
>
<div className={styles.createIconBox}>
<MdVideocam size={20} style={{ color: 'var(--primary-blue)' }} />
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(26,115,232,0.08)' }}>
<MdVideocam size={20} />
</div>
<span>Transmisión en vivo</span>
</div>
<span>Transmisión en vivo</span>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(26,115,232,0.08)' }}>
<PlusLarge size={20} color="var(--primary-blue)" />
</div>
</span>
</button>
<button className={styles.createCard}>
<div className={styles.createIconBox}>
<MdFiberManualRecord size={20} style={{ color: '#ea4335' }} />
<button className={`${styles.createCard} ${styles.cardRed}`}>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(234,67,53,0.08)' }}>
<MdFiberManualRecord size={20} />
</div>
<span>Grabación</span>
</div>
<span>Grabación</span>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(234,67,53,0.08)' }}>
<PlusLarge size={20} color="#ea4335" />
</div>
</span>
</button>
<button className={styles.createCard}>
<div className={styles.createIconBox}>
<MdSchool size={20} style={{ color: '#34a853' }} />
<button className={`${styles.createCard} ${styles.cardGreen}`}>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(52,168,83,0.08)' }}>
<MdSchool size={20} />
</div>
<span>Seminario web On-Air</span>
</div>
<span>Seminario web On-Air</span>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(52,168,83,0.08)' }}>
<PlusLarge size={20} color="#34a853" />
</div>
</span>
</button>
</div>
)}

View File

@ -55,8 +55,9 @@
font-weight: 500;
gap: 12px;
border-left: 3px solid transparent;
width: calc(100% + 40px);
margin-left: -20px;
/* move slightly to the left (-1 spacing ≈ 4px) for a tighter alignment */
width: calc(100% + 26px);
margin-left: -10px;
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
}
@ -92,6 +93,10 @@
padding: 0;
}
.secondaryNavGroup .navItem {
margin: 2px 0;
}
/* separator between secondary items (not above the first) */
.secondaryNavGroup .navItem + .navItem {
border-top: 1px solid var(--border-light);
@ -109,6 +114,9 @@
.secondaryNavGroup .navLink {
padding-left: 18px;
/* match main nav left offset */
width: calc(100% + 44px);
margin-left: -24px;
}
.storageTitle {

View File

@ -4,6 +4,7 @@ import { Dropdown } from './Dropdown'
import { FaYoutube, FaFacebook, FaTwitch, FaLinkedin } from 'react-icons/fa'
import { SkeletonTable } from './Skeleton'
import styles from './TransmissionsTable.module.css'
import InviteGuestsModal from './InviteGuestsModal'
import type { Transmission } from '../types'
interface Props {
@ -22,6 +23,8 @@ const platformIcons: Record<string, React.ReactNode> = {
const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate, isLoading }) => {
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming')
const [inviteOpen, setInviteOpen] = useState(false)
const [inviteLink, setInviteLink] = useState<string | undefined>(undefined)
// Filtrado por fechas
const filtered = transmissions.filter(t => {
@ -111,10 +114,11 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
Entrar al estudio
</button>
<>
<Dropdown
trigger={<button className={styles.moreOptionsButton} aria-label={`Más opciones ${t.title}`}><MdMoreVert size={20} /></button>}
items={[
{ label: 'Agregar invitados', icon: <MdPersonAdd size={16} />, onClick: () => console.log('Agregar invitados', t.id) },
{ label: 'Agregar invitados', icon: <MdPersonAdd size={16} />, onClick: () => { setInviteLink(`https://streamyard.com/${t.id}`); setInviteOpen(true) } },
{ label: 'Editar', icon: <MdEdit size={16} />, onClick: () => {/* editar */} },
{ divider: true, label: '', disabled: false },
{ label: 'Ver en YouTube', icon: <MdOpenInNew size={16} />, onClick: () => {/* abrir */} },
@ -122,6 +126,8 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
{ label: 'Eliminar transmisión', icon: <MdDelete size={16} />, onClick: () => onDelete(t.id), containerProps: { className: styles.deleteItem }, labelProps: { className: styles.dangerLabel } }
]}
/>
<InviteGuestsModal open={inviteOpen} onClose={() => setInviteOpen(false)} link={inviteLink} />
</>
</div>
</div>
</div>

View File

@ -0,0 +1,29 @@
import React from 'react'
type Props = {
className?: string
size?: number
color?: string
}
export const PlusLarge: React.FC<Props> = ({ className = '', size = 20, color = 'var(--primary-blue)' }) => {
const half = size / 2
const stroke = Math.max(1, Math.round(size * 0.12))
return (
<svg
className={className}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect x="0" y="0" width={size} height={size} rx={Math.round(size * 0.18)} fill="none" />
<line x1={half} y1={size * 0.18} x2={half} y2={size * 0.82} stroke={color} strokeWidth={stroke} strokeLinecap="round" />
<line x1={size * 0.18} y1={half} x2={size * 0.82} y2={half} stroke={color} strokeWidth={stroke} strokeLinecap="round" />
</svg>
)
}
export default PlusLarge

View File

@ -1,9 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "node_modules/.cache/broadcast-panel.tsbuildinfo"
"tsBuildInfoFile": "node_modules/.cache/broadcast-panel.tsbuildinfo",
"baseUrl": "../..",
"paths": {
"@/*": ["packages/broadcast-panel/src/*"],
"@shared/*": ["shared/*"]
}
},
"include": ["src"]
"include": ["src", "../../shared"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../../shared')
}
},
server: {
port: 5173
}
})

View File

@ -0,0 +1,158 @@
/* Modal base styles - StreamYard/Google Material inspired */
.modal {
position: fixed;
inset: 0;
margin: auto;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2);
max-height: 90vh;
overflow: hidden;
}
.modal::backdrop {
background-color: rgba(0, 0, 0, 0.32);
backdrop-filter: blur(2px);
}
/* Modal widths */
.modal-sm { width: 90%; max-width: 400px; }
.modal-md { width: 90%; max-width: 520px; }
.modal-lg { width: 90%; max-width: 720px; }
.modal-xl { width: 90%; max-width: 960px; }
.modalContent {
background-color: #ffffff;
color: #202124;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 90vh;
}
/* Header */
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #e8eaed;
}
.modalTitle {
font-size: 16px;
font-weight: 400;
margin: 0;
color: #202124;
letter-spacing: 0.1px;
}
.closeButton {
background: transparent;
border: none;
font-size: 24px;
line-height: 1;
color: #5f6368;
cursor: pointer;
padding: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease, color 0.2s ease;
margin: -8px -8px -8px 0;
}
.closeButton:hover {
background-color: #f1f3f4;
color: #202124;
}
/* Modal body */
.modalBody {
padding: 20px 24px;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
/* Footer */
.modalFooter {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e8eaed;
}
/* Responsive */
@media (max-width: 768px) {
.modal-sm,
.modal-md,
.modal-lg,
.modal-xl {
width: 95%;
max-width: none;
}
.modalBody {
padding: 20px;
}
.modalHeader,
.modalFooter {
padding: 16px 20px;
}
}
/* Animation */
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal[open] .modalContent {
animation: modalFadeIn 0.15s cubic-bezier(0.4, 0.0, 0.2, 1);
}
/* Dark mode */
[data-theme="dark"] .modalContent {
background-color: #2d2e30;
color: #e8eaed;
}
[data-theme="dark"] .modalHeader {
border-bottom-color: #5f6368;
}
[data-theme="dark"] .modalTitle {
color: #e8eaed;
}
[data-theme="dark"] .closeButton {
color: #9aa0a6;
}
[data-theme="dark"] .closeButton:hover {
background-color: #3c4043;
color: #e8eaed;
}
[data-theme="dark"] .modalFooter {
border-top-color: #5f6368;
}

View File

@ -0,0 +1,76 @@
import React, { useEffect, useRef } from 'react'
import styles from './Modal.module.css'
export interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: React.ReactNode
footer?: React.ReactNode
width?: 'sm' | 'md' | 'lg' | 'xl'
closeOnOverlayClick?: boolean
}
export const Modal: React.FC<ModalProps> = ({
open,
onClose,
title,
children,
footer,
width = 'md',
closeOnOverlayClick = true
}) => {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open) {
dialog.showModal()
} else {
dialog.close()
}
}, [open])
const handleOverlayClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (closeOnOverlayClick && e.target === dialogRef.current) {
onClose()
}
}
if (!open) return null
return (
<dialog
ref={dialogRef}
className={`${styles.modal} ${styles[`modal-${width}`]}`}
onClick={handleOverlayClick}
>
<div className={styles.modalContent}>
{/* Header */}
{title && (
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>{title}</h3>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Cerrar"
>
×
</button>
</div>
)}
{/* Body */}
<div className={styles.modalBody}>{children}</div>
{/* Footer */}
{footer && <div className={styles.modalFooter}>{footer}</div>}
</div>
</dialog>
)
}
export default Modal

124
shared/components/README.md Normal file
View File

@ -0,0 +1,124 @@
# Shared Components - AvanzaCast
Componentes reutilizables compartidos entre todos los paquetes del monorepo AvanzaCast.
## Estructura
```
shared/components/
├── Modal.tsx # Componente base de modal
├── Modal.module.css
├── modal-parts/ # Componentes modulares para modales
│ ├── ModalLink.tsx # Enlaces azules estilo StreamYard
│ ├── ModalCopyInput.tsx # Input + botón copiar
│ ├── ModalShareButtons.tsx # Botones de compartir (Gmail, Email, Messenger)
│ ├── ModalToggle.tsx # Toggle switch Material Design
│ ├── README.md # Documentación detallada
│ └── *.module.css
├── LanguageSelector.tsx # Selector de idioma
├── AuthButton.tsx # Botón de autenticación
└── index.ts # Exportaciones centralizadas
```
## Uso en Paquetes
### Configuración TypeScript
Cada paquete debe configurar su `tsconfig.json`:
```json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../..",
"paths": {
"@/*": ["packages/[paquete]/src/*"],
"@shared/*": ["shared/*"]
}
},
"include": ["src", "../../shared"]
}
```
### Configuración Vite
Agregar alias en `vite.config.ts`:
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../../shared')
}
}
})
```
### Importación
```typescript
// Importar componentes individuales
import { Modal, ModalLink, ModalCopyInput } from '@shared/components'
// O usando path completo
import { Modal } from '@shared/components/Modal'
import { ModalLink } from '@shared/components/modal-parts/ModalLink'
```
## Componentes Disponibles
### Modal System
- **Modal**: Componente base usando `<dialog>` nativo
- **ModalLink**: Enlaces azules subrayados
- **ModalCopyInput**: Input con botón de copiar
- **ModalShareButtons**: Botones de compartir en redes sociales
- **ModalToggle**: Toggle switch con icono de ayuda
Ver documentación completa en `modal-parts/README.md`
### UI Components
- **LanguageSelector**: Selector de idioma multilenguaje
- **AuthButton**: Botón de autenticación/login
## Diseño y Estilo
Todos los componentes siguen el sistema de diseño de **StreamYard/Google Material**:
- **Colores**: `#1a73e8` (primary blue), `#202124` (text), `#5f6368` (secondary)
- **Espaciado**: Múltiplos de 4px (8px, 12px, 16px, 24px)
- **Bordes**: Border radius 4px
- **Tipografía**: 14px body, 16px títulos
- **Dark Mode**: Soporte completo con atributo `[data-theme="dark"]`
## Ventajas
**Reutilización**: Un solo componente usado en todos los paquetes
**Mantenibilidad**: Cambios centralizados
**Consistencia**: Diseño uniforme en toda la aplicación
**Performance**: Code splitting automático con Vite
**TypeScript**: Tipado completo y autocomplete
**Testing**: Un solo lugar para testear componentes
## Agregar Nuevos Componentes
1. Crear componente en `shared/components/`
2. Agregar estilos en `.module.css`
3. Exportar en `index.ts`
4. Documentar en este README
5. Importar usando `@shared/components`
## Ejemplos
Ver `packages/broadcast-panel/src/components/` para ejemplos de uso:
- `InviteGuestsModal.tsx` - Uso completo del sistema modular
- `NewTransmissionModal.tsx` - Modal con formulario
- `ExampleModal.tsx` - Demostración de todos los componentes

View File

@ -1,3 +1,25 @@
// Export all shared components
export { LanguageSelector } from './LanguageSelector';
export { AuthButton } from './AuthButton';
export { Modal } from './Modal';
export type { ModalProps } from './Modal';
// Modal parts - reusable components
export { ModalLink } from './modal-parts/ModalLink';
export { ModalCopyInput } from './modal-parts/ModalCopyInput';
export { ModalShareButtons } from './modal-parts/ModalShareButtons';
export { ModalToggle } from './modal-parts/ModalToggle';
export { default as ModalRadioGroup } from './modal-parts/ModalRadioGroup';
export { default as ModalDestinationButton } from './modal-parts/ModalDestinationButton';
export { default as ModalSection } from './modal-parts/ModalSection';
export { default as ModalInput } from './modal-parts/ModalInput';
export { default as ModalTextarea } from './modal-parts/ModalTextarea';
export { default as ModalSelect } from './modal-parts/ModalSelect';
export { default as ModalCheckbox } from './modal-parts/ModalCheckbox';
export { default as ModalDateTimeGroup } from './modal-parts/ModalDateTimeGroup';
export { default as ModalButton } from './modal-parts/ModalButton';
export { default as ModalButtonGroup } from './modal-parts/ModalButtonGroup';
export { default as ModalPlatformCard } from './modal-parts/ModalPlatformCard';

View File

@ -0,0 +1,99 @@
/* ModalButton - StreamYard style buttons */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
border: 1px solid transparent;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.icon {
display: flex;
align-items: center;
font-size: 18px;
}
/* Primary variant - Blue background */
.primary {
background-color: #1a73e8;
color: white;
border-color: #1a73e8;
}
.primary:hover:not(:disabled) {
background-color: #1765cc;
border-color: #1765cc;
}
/* Secondary variant - White with border */
.secondary {
background-color: #ffffff;
color: #5f6368;
border-color: #dadce0;
}
.secondary:hover:not(:disabled) {
background-color: #f8f9fa;
border-color: #bdc1c6;
}
/* Outlined variant - Transparent with border */
.outlined {
background-color: transparent;
color: #5f6368;
border-color: #dadce0;
}
.outlined:hover:not(:disabled) {
background-color: #f8f9fa;
border-color: #bdc1c6;
color: #202124;
}
/* Dark mode */
[data-theme="dark"] .primary {
background-color: #8ab4f8;
color: #202124;
border-color: #8ab4f8;
}
[data-theme="dark"] .primary:hover:not(:disabled) {
background-color: #aecbfa;
border-color: #aecbfa;
}
[data-theme="dark"] .secondary {
background-color: #3c4043;
color: #e8eaed;
border-color: #5f6368;
}
[data-theme="dark"] .secondary:hover:not(:disabled) {
background-color: #5f6368;
border-color: #80868b;
}
[data-theme="dark"] .outlined {
background-color: transparent;
color: #9aa0a6;
border-color: #5f6368;
}
[data-theme="dark"] .outlined:hover:not(:disabled) {
background-color: #3c4043;
border-color: #80868b;
color: #e8eaed;
}

View File

@ -0,0 +1,38 @@
import React from 'react'
import styles from './ModalButton.module.css'
interface Props {
children: React.ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'outlined'
icon?: React.ReactNode
type?: 'button' | 'submit'
disabled?: boolean
className?: string
}
const ModalButton: React.FC<Props> = ({
children,
onClick,
variant = 'outlined',
icon,
type = 'button',
disabled = false,
className = ''
}) => {
const variantClass = styles[variant] || styles.outlined
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`${styles.button} ${variantClass} ${className}`}
>
{icon && <span className={styles.icon}>{icon}</span>}
{children}
</button>
)
}
export default ModalButton

View File

@ -0,0 +1,7 @@
/* ModalButtonGroup - Button container for modal actions */
.buttonGroup {
display: flex;
gap: 12px;
align-items: center;
}

View File

@ -0,0 +1,17 @@
import React from 'react'
import styles from './ModalButtonGroup.module.css'
interface Props {
children: React.ReactNode
className?: string
}
const ModalButtonGroup: React.FC<Props> = ({ children, className = '' }) => {
return (
<div className={`${styles.buttonGroup} ${className}`}>
{children}
</div>
)
}
export default ModalButtonGroup

View File

@ -0,0 +1,100 @@
/* ModalCheckbox - StreamYard style checkbox */
.container {
width: 100%;
}
.labelWrapper {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
user-select: none;
}
.input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox {
width: 18px;
height: 18px;
border: 2px solid #5f6368;
border-radius: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
margin-top: 1px;
}
.input:checked ~ .checkbox {
background-color: #1a73e8;
border-color: #1a73e8;
}
.label {
color: #202124;
font-size: 14px;
font-weight: 400;
display: flex;
align-items: center;
gap: 4px;
}
.helpButton {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: #5f6368;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
transition: color 0.15s;
}
.helpButton:hover {
color: #202124;
}
.subtext {
color: #5f6368;
font-size: 12px;
margin: 4px 0 0 26px;
line-height: 1.4;
}
/* Dark mode */
[data-theme="dark"] .checkbox {
border-color: #9aa0a6;
}
[data-theme="dark"] .input:checked ~ .checkbox {
background-color: #8ab4f8;
border-color: #8ab4f8;
}
[data-theme="dark"] .label {
color: #e8eaed;
}
[data-theme="dark"] .helpButton {
color: #9aa0a6;
}
[data-theme="dark"] .helpButton:hover {
color: #e8eaed;
}
[data-theme="dark"] .subtext {
color: #9aa0a6;
}

View File

@ -0,0 +1,61 @@
import React from 'react'
import styles from './ModalCheckbox.module.css'
interface Props {
checked: boolean
onChange: (checked: boolean) => void
label: string
helpIcon?: React.ReactNode
subtext?: string
className?: string
}
const ModalCheckbox: React.FC<Props> = ({
checked,
onChange,
label,
helpIcon,
subtext,
className = ''
}) => {
return (
<div className={`${styles.container} ${className}`}>
<label className={styles.labelWrapper}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className={styles.input}
/>
<span className={styles.checkbox}>
{checked && (
<svg width="12" height="10" viewBox="0 0 12 10" fill="none">
<path
d="M1 5L4.5 8.5L11 1"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
<span className={styles.label}>
{label}
{helpIcon && (
<button
type="button"
className={styles.helpButton}
aria-label="Ayuda"
>
{helpIcon}
</button>
)}
</span>
</label>
{subtext && <p className={styles.subtext}>{subtext}</p>}
</div>
)
}
export default ModalCheckbox

View File

@ -0,0 +1,66 @@
.container {
display: flex;
gap: 12px;
align-items: stretch;
}
.input {
flex: 1;
padding: 10px 14px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
background: #ffffff;
color: #202124;
font-family: inherit;
transition: border-color 0.2s;
}
.input:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 1px #1a73e8;
}
.button {
padding: 10px 24px;
background: #1a73e8;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, box-shadow 0.2s;
white-space: nowrap;
min-width: 100px;
}
.button:hover {
background: #1765cc;
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
}
.button:active {
background: #1557b0;
}
/* Dark mode */
[data-theme="dark"] .input {
background: #2d2e30;
border-color: #5f6368;
color: #e8eaed;
}
[data-theme="dark"] .input:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 1px #8ab4f8;
}
[data-theme="dark"] .button {
background: #1a73e8;
}
[data-theme="dark"] .button:hover {
background: #2b7de9;
}

View File

@ -0,0 +1,54 @@
import React, { useState } from 'react'
import styles from './ModalCopyInput.module.css'
interface Props {
value: string
buttonText?: string
copiedText?: string
onCopy?: (value: string) => void
className?: string
}
/**
* Componente de input con botón de copiar
* Estilo StreamYard: input ancho + botón azul brillante
*/
export const ModalCopyInput: React.FC<Props> = ({
value,
buttonText = 'Copiar',
copiedText = '✓ Copiado',
onCopy,
className = ''
}) => {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value)
setCopied(true)
onCopy?.(value)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Error al copiar:', err)
}
}
return (
<div className={`${styles.container} ${className}`}>
<input
type="text"
value={value}
readOnly
className={styles.input}
/>
<button
onClick={handleCopy}
className={styles.button}
>
{copied ? copiedText : buttonText}
</button>
</div>
)
}
export default ModalCopyInput

View File

@ -0,0 +1,154 @@
/* ModalDateTimeGroup - StreamYard style date/time picker */
.container {
width: 100%;
}
.labelRow {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
}
.label {
color: #5f6368;
font-size: 14px;
font-weight: 400;
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.timezone {
color: #80868b;
font-size: 13px;
}
.helpButton {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: #5f6368;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
transition: color 0.15s;
}
.helpButton:hover {
color: #202124;
}
.inputs {
display: flex;
gap: 12px;
}
.dateInput {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
color: #202124;
background-color: #ffffff;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.dateInput:hover {
border-color: #bdc1c6;
}
.dateInput:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 1px #1a73e8;
}
.timeSelect {
width: 70px;
padding: 8px 28px 8px 12px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
color: #202124;
background-color: #ffffff;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%235f6368' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px;
appearance: none;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.timeSelect:hover {
border-color: #bdc1c6;
}
.timeSelect:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 1px #1a73e8;
}
/* Dark mode */
[data-theme="dark"] .label {
color: #9aa0a6;
}
[data-theme="dark"] .timezone {
color: #80868b;
}
[data-theme="dark"] .helpButton {
color: #9aa0a6;
}
[data-theme="dark"] .helpButton:hover {
color: #e8eaed;
}
[data-theme="dark"] .dateInput,
[data-theme="dark"] .timeSelect {
background-color: #3c4043;
border-color: #5f6368;
color: #e8eaed;
}
[data-theme="dark"] .timeSelect {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%239aa0a6' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
}
[data-theme="dark"] .dateInput:hover,
[data-theme="dark"] .timeSelect:hover {
border-color: #80868b;
}
[data-theme="dark"] .dateInput:focus,
[data-theme="dark"] .timeSelect:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 1px #8ab4f8;
}
/* Custom date input styling */
.dateInput::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.6;
}
.dateInput::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
[data-theme="dark"] .dateInput::-webkit-calendar-picker-indicator {
filter: invert(1);
}

View File

@ -0,0 +1,96 @@
import React from 'react'
import styles from './ModalDateTimeGroup.module.css'
interface Props {
label?: string
helpIcon?: React.ReactNode
dateValue: string
hourValue: string
minuteValue: string
onDateChange: (value: string) => void
onHourChange: (value: string) => void
onMinuteChange: (value: string) => void
timezone?: string
className?: string
}
const ModalDateTimeGroup: React.FC<Props> = ({
label,
helpIcon,
dateValue,
hourValue,
minuteValue,
onDateChange,
onHourChange,
onMinuteChange,
timezone = 'GMT-7',
className = ''
}) => {
// Generate hour options (00-23)
const hours = Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, '0')
return { value: hour, label: hour }
})
// Generate minute options (00-59)
const minutes = Array.from({ length: 60 }, (_, i) => {
const minute = i.toString().padStart(2, '0')
return { value: minute, label: minute }
})
return (
<div className={`${styles.container} ${className}`}>
{label && (
<div className={styles.labelRow}>
<label className={styles.label}>
{label} <span className={styles.timezone}>{timezone}</span>
</label>
{helpIcon && (
<button
type="button"
className={styles.helpButton}
aria-label="Ayuda"
>
{helpIcon}
</button>
)}
</div>
)}
<div className={styles.inputs}>
<input
type="date"
value={dateValue}
onChange={(e) => onDateChange(e.target.value)}
className={styles.dateInput}
/>
<select
value={hourValue}
onChange={(e) => onHourChange(e.target.value)}
className={styles.timeSelect}
>
{hours.map((hour) => (
<option key={hour.value} value={hour.value}>
{hour.label}
</option>
))}
</select>
<select
value={minuteValue}
onChange={(e) => onMinuteChange(e.target.value)}
className={styles.timeSelect}
>
{minutes.map((minute) => (
<option key={minute.value} value={minute.value}>
{minute.label}
</option>
))}
</select>
</div>
</div>
)
}
export default ModalDateTimeGroup

View File

@ -0,0 +1,83 @@
/* ModalDestinationButton - StreamYard style destination selector */
.button {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.15s;
}
.button:hover {
opacity: 0.8;
}
.iconContainer {
position: relative;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #f1f3f4;
transition: background-color 0.15s;
}
.button:hover .iconContainer {
background-color: #e8eaed;
}
.iconContainer svg,
.iconContainer img {
width: 24px;
height: 24px;
}
.badge {
position: absolute;
bottom: -2px;
right: -2px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.badge svg,
.badge img {
width: 16px;
height: 16px;
}
.label {
color: #5f6368;
font-size: 13px;
font-weight: 400;
text-align: center;
}
/* Dark mode */
[data-theme="dark"] .iconContainer {
background-color: #3c4043;
}
[data-theme="dark"] .button:hover .iconContainer {
background-color: #5f6368;
}
[data-theme="dark"] .badge {
background-color: #3c4043;
}
[data-theme="dark"] .label {
color: #9aa0a6;
}

View File

@ -0,0 +1,37 @@
import React from 'react'
import styles from './ModalDestinationButton.module.css'
interface Props {
icon: React.ReactNode
label: string
onClick: () => void
badge?: React.ReactNode
className?: string
title?: string
}
const ModalDestinationButton: React.FC<Props> = ({
icon,
label,
onClick,
badge,
className = '',
title
}) => {
return (
<button
type="button"
onClick={onClick}
className={`${styles.button} ${className}`}
title={title}
>
<div className={styles.iconContainer}>
{icon}
{badge && <div className={styles.badge}>{badge}</div>}
</div>
<span className={styles.label}>{label}</span>
</button>
)
}
export default ModalDestinationButton

View File

@ -0,0 +1,77 @@
/* ModalInput - StreamYard style text input */
.container {
width: 100%;
position: relative;
}
.label {
display: block;
color: #5f6368;
font-size: 14px;
font-weight: 400;
margin-bottom: 8px;
}
.input {
width: 100%;
padding: 8px 12px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
color: #202124;
background-color: #ffffff;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.input:hover {
border-color: #bdc1c6;
}
.input:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 1px #1a73e8;
}
.input::placeholder {
color: #80868b;
}
.counter {
position: absolute;
right: 12px;
bottom: 8px;
color: #5f6368;
font-size: 12px;
pointer-events: none;
}
/* Dark mode */
[data-theme="dark"] .label {
color: #9aa0a6;
}
[data-theme="dark"] .input {
background-color: #3c4043;
border-color: #5f6368;
color: #e8eaed;
}
[data-theme="dark"] .input:hover {
border-color: #80868b;
}
[data-theme="dark"] .input:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 1px #8ab4f8;
}
[data-theme="dark"] .input::placeholder {
color: #9aa0a6;
}
[data-theme="dark"] .counter {
color: #9aa0a6;
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import styles from './ModalInput.module.css'
interface Props {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
maxLength?: number
showCounter?: boolean
required?: boolean
className?: string
}
const ModalInput: React.FC<Props> = ({
label,
value,
onChange,
placeholder,
maxLength,
showCounter = false,
required = false,
className = ''
}) => {
return (
<div className={`${styles.container} ${className}`}>
<label className={styles.label}>{label}</label>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
required={required}
className={styles.input}
/>
{showCounter && maxLength && (
<div className={styles.counter}>
{value.length}/{maxLength}
</div>
)}
</div>
)
}
export default ModalInput

View File

@ -0,0 +1,23 @@
.link {
color: #1a73e8;
text-decoration: underline;
cursor: pointer;
transition: opacity 0.2s;
}
.link:hover {
opacity: 0.8;
}
.linkButton {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: inherit;
text-align: inherit;
}
[data-theme="dark"] .link {
color: #4d9fff;
}

View File

@ -0,0 +1,41 @@
import React from 'react'
import styles from './ModalLink.module.css'
interface Props {
href?: string
onClick?: () => void
children: React.ReactNode
className?: string
}
/**
* Componente de enlace reutilizable para modales
* Estilo: texto azul subrayado que se usa en textos de ayuda
* Soporta tanto navegación (href) como acciones (onClick)
*/
export const ModalLink: React.FC<Props> = ({ href, onClick, children, className = '' }) => {
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={`${styles.link} ${styles.linkButton} ${className}`}
>
{children}
</button>
)
}
return (
<a
href={href || '#'}
target="_blank"
rel="noopener noreferrer"
className={`${styles.link} ${className}`}
>
{children}
</a>
)
}
export default ModalLink

View File

@ -0,0 +1,86 @@
/* ModalPlatformCard - StreamYard style platform selector card */
.card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px 16px;
background-color: #ffffff;
border: 1px solid #dadce0;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
position: relative;
min-width: 160px;
}
.card:hover {
border-color: #1a73e8;
background-color: #f8f9fa;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.badge {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 14px;
}
.badge.pro {
background-color: #fef7e0;
}
.badge.new {
background-color: #e8f0fe;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
.icon svg {
width: 32px;
height: 32px;
}
.label {
color: #202124;
font-size: 14px;
font-weight: 400;
text-align: center;
}
/* Dark mode */
[data-theme="dark"] .card {
background-color: #3c4043;
border-color: #5f6368;
}
[data-theme="dark"] .card:hover {
border-color: #8ab4f8;
background-color: #5f6368;
}
[data-theme="dark"] .badge.pro {
background-color: #5f4b08;
}
[data-theme="dark"] .badge.new {
background-color: #1a3a52;
}
[data-theme="dark"] .label {
color: #e8eaed;
}

View File

@ -0,0 +1,37 @@
import React from 'react'
import styles from './ModalPlatformCard.module.css'
interface Props {
icon: React.ReactNode
label: string
onClick: () => void
badge?: 'pro' | 'new'
className?: string
}
const ModalPlatformCard: React.FC<Props> = ({
icon,
label,
onClick,
badge,
className = ''
}) => {
return (
<button
type="button"
onClick={onClick}
className={`${styles.card} ${className}`}
>
{badge && (
<div className={`${styles.badge} ${styles[badge]}`}>
{badge === 'pro' && '⭐'}
{badge === 'new' && '✨'}
</div>
)}
<div className={styles.icon}>{icon}</div>
<span className={styles.label}>{label}</span>
</button>
)
}
export default ModalPlatformCard

View File

@ -0,0 +1,141 @@
/* ModalRadioGroup - StreamYard style radio buttons */
.container {
width: 100%;
}
.labelRow {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.label {
color: #5f6368;
font-size: 14px;
font-weight: 400;
margin: 0;
}
.helpButton {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: #5f6368;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
transition: color 0.15s;
}
.helpButton:hover {
color: #202124;
}
.optionsContainer {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
}
.radioInput {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.radioCustom {
width: 16px;
height: 16px;
border: 2px solid #5f6368;
border-radius: 50%;
position: relative;
flex-shrink: 0;
transition: border-color 0.15s;
}
.radioInput:checked ~ .radioCustom {
border-color: #1a73e8;
}
.radioInput:checked ~ .radioCustom::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #1a73e8;
}
.icon {
display: flex;
align-items: center;
color: #5f6368;
font-size: 14px;
}
.radioInput:checked ~ .icon {
color: #1a73e8;
}
.optionLabel {
color: #202124;
font-size: 14px;
font-weight: 400;
}
/* Dark mode */
[data-theme="dark"] .label {
color: #9aa0a6;
}
[data-theme="dark"] .helpButton {
color: #9aa0a6;
}
[data-theme="dark"] .helpButton:hover {
color: #e8eaed;
}
[data-theme="dark"] .radioCustom {
border-color: #9aa0a6;
}
[data-theme="dark"] .icon {
color: #9aa0a6;
}
[data-theme="dark"] .radioInput:checked ~ .icon {
color: #8ab4f8;
}
[data-theme="dark"] .optionLabel {
color: #e8eaed;
}
[data-theme="dark"] .radioInput:checked ~ .radioCustom {
border-color: #8ab4f8;
}
[data-theme="dark"] .radioInput:checked ~ .radioCustom::after {
background-color: #8ab4f8;
}

View File

@ -0,0 +1,67 @@
import React from 'react'
import styles from './ModalRadioGroup.module.css'
interface RadioOption {
value: string
label: string
icon?: React.ReactNode
}
interface Props {
label?: string
helpIcon?: React.ReactNode
options: RadioOption[]
value: string
onChange: (value: string) => void
name: string
className?: string
}
const ModalRadioGroup: React.FC<Props> = ({
label,
helpIcon,
options,
value,
onChange,
name,
className = ''
}) => {
return (
<div className={`${styles.container} ${className}`}>
{label && (
<div className={styles.labelRow}>
<label className={styles.label}>{label}</label>
{helpIcon && (
<button
type="button"
className={styles.helpButton}
aria-label="Ayuda"
>
{helpIcon}
</button>
)}
</div>
)}
<div className={styles.optionsContainer}>
{options.map((option) => (
<label key={option.value} className={styles.option}>
<input
type="radio"
name={name}
value={option.value}
checked={value === option.value}
onChange={(e) => onChange(e.target.value)}
className={styles.radioInput}
/>
<span className={styles.radioCustom}></span>
{option.icon && <span className={styles.icon}>{option.icon}</span>}
<span className={styles.optionLabel}>{option.label}</span>
</label>
))}
</div>
</div>
)
}
export default ModalRadioGroup

View File

@ -0,0 +1,21 @@
/* ModalSection - Section grouping for modal content */
.section {
width: 100%;
}
.label {
color: #5f6368;
font-size: 14px;
font-weight: 400;
margin: 0 0 12px 0;
}
.content {
width: 100%;
}
/* Dark mode */
[data-theme="dark"] .label {
color: #9aa0a6;
}

View File

@ -0,0 +1,21 @@
import React from 'react'
import styles from './ModalSection.module.css'
interface Props {
label?: string
children: React.ReactNode
className?: string
}
const ModalSection: React.FC<Props> = ({ label, children, className = '' }) => {
return (
<div className={`${styles.section} ${className}`}>
{label && <h3 className={styles.label}>{label}</h3>}
<div className={styles.content}>
{children}
</div>
</div>
)
}
export default ModalSection

View File

@ -0,0 +1,62 @@
/* ModalSelect - StreamYard style select dropdown */
.container {
width: 100%;
}
.label {
display: block;
color: #5f6368;
font-size: 14px;
font-weight: 400;
margin-bottom: 8px;
}
.select {
width: 100%;
padding: 8px 32px 8px 12px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
color: #202124;
background-color: #ffffff;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235f6368' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
appearance: none;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.select:hover {
border-color: #bdc1c6;
}
.select:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 1px #1a73e8;
}
/* Dark mode */
[data-theme="dark"] .label {
color: #9aa0a6;
}
[data-theme="dark"] .select {
background-color: #3c4043;
border-color: #5f6368;
color: #e8eaed;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239aa0a6' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
}
[data-theme="dark"] .select:hover {
border-color: #80868b;
}
[data-theme="dark"] .select:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 1px #8ab4f8;
}

View File

@ -0,0 +1,52 @@
import React from 'react'
import styles from './ModalSelect.module.css'
interface SelectOption {
value: string
label: string
}
interface Props {
label: string
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
required?: boolean
className?: string
}
const ModalSelect: React.FC<Props> = ({
label,
value,
onChange,
options,
placeholder,
required = false,
className = ''
}) => {
return (
<div className={`${styles.container} ${className}`}>
<label className={styles.label}>{label}</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
className={styles.select}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
export default ModalSelect

View File

@ -0,0 +1,63 @@
.container {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f1f3f4;
border: 1px solid #dadce0;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: #3c4043;
font-weight: 500;
}
.button:hover {
background: #e8f0fe;
border-color: #1a73e8;
color: #1a73e8;
}
.button:hover .icon {
color: #1a73e8;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
color: #5f6368;
transition: color 0.2s;
}
.label {
font-family: inherit;
}
/* Dark mode */
[data-theme="dark"] .button {
background: #2d2e30;
border-color: #5f6368;
color: #e8eaed;
}
[data-theme="dark"] .button:hover {
background: #3c4043;
border-color: #8ab4f8;
color: #8ab4f8;
}
[data-theme="dark"] .button:hover .icon {
color: #8ab4f8;
}
[data-theme="dark"] .icon {
color: #9aa0a6;
}

View File

@ -0,0 +1,71 @@
import React from 'react'
import { SiGmail } from 'react-icons/si'
import { MdEmail } from 'react-icons/md'
import { FaFacebookMessenger } from 'react-icons/fa'
import styles from './ModalShareButtons.module.css'
interface ShareButton {
icon: React.ReactNode
label: string
onClick: () => void
}
interface Props {
onGmail?: () => void
onEmail?: () => void
onMessenger?: () => void
customButtons?: ShareButton[]
className?: string
}
/**
* Componente de botones de compartir para modales
* Estilo StreamYard: icono + texto, fondo gris claro, hover azul
*/
export const ModalShareButtons: React.FC<Props> = ({
onGmail,
onEmail,
onMessenger,
customButtons,
className = ''
}) => {
const defaultButtons: ShareButton[] = [
...(onGmail ? [{
icon: <SiGmail size={18} />,
label: 'Gmail',
onClick: onGmail
}] : []),
...(onEmail ? [{
icon: <MdEmail size={18} />,
label: 'Correo electrónico',
onClick: onEmail
}] : []),
...(onMessenger ? [{
icon: <FaFacebookMessenger size={18} />,
label: 'Messenger',
onClick: onMessenger
}] : [])
]
const buttons = customButtons || defaultButtons
if (buttons.length === 0) return null
return (
<div className={`${styles.container} ${className}`}>
{buttons.map((button, index) => (
<button
key={index}
onClick={button.onClick}
className={styles.button}
type="button"
>
<span className={styles.icon}>{button.icon}</span>
<span className={styles.label}>{button.label}</span>
</button>
))}
</div>
)
}
export default ModalShareButtons

View File

@ -0,0 +1,80 @@
/* ModalTextarea - StreamYard style textarea */
.container {
width: 100%;
position: relative;
}
.label {
display: block;
color: #5f6368;
font-size: 14px;
font-weight: 400;
margin-bottom: 8px;
}
.textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
color: #202124;
background-color: #ffffff;
font-family: inherit;
resize: vertical;
min-height: 60px;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.textarea:hover {
border-color: #bdc1c6;
}
.textarea:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 1px #1a73e8;
}
.textarea::placeholder {
color: #80868b;
}
.counter {
position: absolute;
right: 12px;
bottom: 8px;
color: #5f6368;
font-size: 12px;
pointer-events: none;
}
/* Dark mode */
[data-theme="dark"] .label {
color: #9aa0a6;
}
[data-theme="dark"] .textarea {
background-color: #3c4043;
border-color: #5f6368;
color: #e8eaed;
}
[data-theme="dark"] .textarea:hover {
border-color: #80868b;
}
[data-theme="dark"] .textarea:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 1px #8ab4f8;
}
[data-theme="dark"] .textarea::placeholder {
color: #9aa0a6;
}
[data-theme="dark"] .counter {
color: #9aa0a6;
}

View File

@ -0,0 +1,48 @@
import React from 'react'
import styles from './ModalTextarea.module.css'
interface Props {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
maxLength?: number
showCounter?: boolean
rows?: number
required?: boolean
className?: string
}
const ModalTextarea: React.FC<Props> = ({
label,
value,
onChange,
placeholder,
maxLength,
showCounter = false,
rows = 3,
required = false,
className = ''
}) => {
return (
<div className={`${styles.container} ${className}`}>
<label className={styles.label}>{label}</label>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
rows={rows}
required={required}
className={styles.textarea}
/>
{showCounter && maxLength && (
<div className={styles.counter}>
{value.length}/{maxLength}
</div>
)}
</div>
)
}
export default ModalTextarea

View File

@ -0,0 +1,108 @@
.container {
display: flex;
align-items: center;
gap: 8px;
}
.label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
position: relative;
flex: 1;
}
.checkbox {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
/* Custom toggle slider */
.slider {
position: relative;
width: 36px;
height: 20px;
background: #dadce0;
border-radius: 10px;
transition: background-color 0.2s;
flex-shrink: 0;
}
.slider::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.checkbox:checked + .slider {
background: #1a73e8;
}
.checkbox:checked + .slider::before {
transform: translateX(16px);
}
.checkbox:focus + .slider {
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}
.text {
font-size: 14px;
color: #3c4043;
line-height: 1.4;
}
.helpButton {
background: transparent;
border: none;
color: #5f6368;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
flex-shrink: 0;
}
.helpButton:hover {
background: #f1f3f4;
color: #1a73e8;
}
/* Dark mode */
[data-theme="dark"] .slider {
background: #5f6368;
}
[data-theme="dark"] .slider::before {
background: #e8eaed;
}
[data-theme="dark"] .checkbox:checked + .slider {
background: #8ab4f8;
}
[data-theme="dark"] .text {
color: #e8eaed;
}
[data-theme="dark"] .helpButton {
color: #9aa0a6;
}
[data-theme="dark"] .helpButton:hover {
background: #3c4043;
color: #8ab4f8;
}

View File

@ -0,0 +1,53 @@
import React from 'react'
import { BsInfoCircle } from 'react-icons/bs'
import styles from './ModalToggle.module.css'
interface Props {
checked: boolean
onChange: (checked: boolean) => void
label: string
helpText?: string
helpLink?: string
className?: string
}
/**
* Componente de toggle/checkbox para modales
* Estilo StreamYard: switch + texto + icono de ayuda opcional
*/
export const ModalToggle: React.FC<Props> = ({
checked,
onChange,
label,
helpText,
helpLink,
className = ''
}) => {
return (
<div className={`${styles.container} ${className}`}>
<label className={styles.label}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className={styles.checkbox}
/>
<span className={styles.slider}></span>
<span className={styles.text}>{label}</span>
</label>
{(helpText || helpLink) && (
<button
type="button"
className={styles.helpButton}
aria-label="Más información"
onClick={() => helpLink && window.open(helpLink, '_blank')}
>
<BsInfoCircle size={16} />
</button>
)}
</div>
)
}
export default ModalToggle

View File

@ -0,0 +1,204 @@
# Sistema de Componentes Modulares para Modales
## Descripción
Sistema de componentes reutilizables basado en el diseño de **StreamYard** y **Google Material Design** que permite crear modales consistentes y atractivos con mínimo código.
## Componentes Disponibles
### 1. `Modal` (Base)
Componente contenedor principal usando `<dialog>` nativo de HTML5.
**Props:**
- `open: boolean` - Controla visibilidad
- `onClose: () => void` - Callback al cerrar
- `title?: string` - Título del modal
- `children: ReactNode` - Contenido principal
- `footer?: ReactNode` - Botones de acción
- `width?: 'sm' | 'md' | 'lg' | 'xl'` - Ancho predefinido
- `closeOnOverlayClick?: boolean` - Cerrar al hacer clic afuera
### 2. `ModalLink`
Enlaces azules subrayados para textos de ayuda.
**Props:**
- `href: string` - URL del enlace
- `children: ReactNode` - Texto del enlace
**Ejemplo:**
```tsx
<ModalLink href="https://docs.example.com">
nuestra documentación
</ModalLink>
```
### 3. `ModalCopyInput`
Input de solo lectura con botón de copiar integrado.
**Props:**
- `value: string` - Texto a copiar
- `buttonText?: string` - Texto del botón (default: "Copiar")
- `copiedText?: string` - Texto al copiar (default: "✓ Copiado")
- `onCopy?: (value: string) => void` - Callback al copiar
**Ejemplo:**
```tsx
<ModalCopyInput
value="https://streamyard.com/abc123"
buttonText="Copiar enlace"
/>
```
### 4. `ModalShareButtons`
Botones para compartir por diferentes medios (Gmail, Email, Messenger).
**Props:**
- `onGmail?: () => void` - Acción para Gmail
- `onEmail?: () => void` - Acción para Email
- `onMessenger?: () => void` - Acción para Messenger
- `customButtons?: ShareButton[]` - Botones personalizados
**Ejemplo:**
```tsx
<ModalShareButtons
onGmail={() => window.open('...')}
onEmail={() => window.location.href = 'mailto:...'}
onMessenger={() => console.log('Messenger')}
/>
```
### 5. `ModalToggle`
Toggle switch con estilo Material + icono de ayuda opcional.
**Props:**
- `checked: boolean` - Estado del toggle
- `onChange: (checked: boolean) => void` - Callback al cambiar
- `label: string` - Texto descriptivo
- `helpText?: string` - Texto de ayuda
- `helpLink?: string` - URL de ayuda
**Ejemplo:**
```tsx
<ModalToggle
checked={enabled}
onChange={setEnabled}
label="Los invitados pueden transmitir a sus destinos"
helpLink="https://support.example.com/article"
/>
```
## Uso Completo
```tsx
import React, { useState } from 'react'
import { Modal } from './Modal'
import {
ModalLink,
ModalCopyInput,
ModalShareButtons,
ModalToggle
} from './modal-parts'
const MyModal: React.FC<Props> = ({ open, onClose, inviteLink }) => {
const [allowGuests, setAllowGuests] = useState(true)
return (
<Modal
open={open}
onClose={onClose}
title="Invitar colaboradores"
width="md"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ color: '#5f6368', fontSize: '14px' }}>
Comparte este enlace. Lee las{' '}
<ModalLink href="/instructions">
instrucciones para invitados
</ModalLink>.
</p>
<ModalCopyInput value={inviteLink} />
<ModalShareButtons
onGmail={() => window.open(`https://mail.google.com/...`)}
onEmail={() => window.location.href = `mailto:...`}
/>
<ModalToggle
checked={allowGuests}
onChange={setAllowGuests}
label="Permitir que invitados transmitan"
helpLink="/help/guest-permissions"
/>
</div>
</Modal>
)
}
```
## Diseño y Estilo
### Colores (Light Mode)
- **Primary Blue**: `#1a73e8` (botones principales, links, hover)
- **Text Primary**: `#202124`
- **Text Secondary**: `#5f6368`
- **Border**: `#dadce0`
- **Background**: `#ffffff`
- **Hover Background**: `#f1f3f4`
### Colores (Dark Mode)
- **Primary Blue**: `#8ab4f8`
- **Text Primary**: `#e8eaed`
- **Text Secondary**: `#9aa0a6`
- **Border**: `#5f6368`
- **Background**: `#2d2e30`
- **Hover Background**: `#3c4043`
### Espaciado
- Gap entre elementos: `16px`
- Padding body: `24px`
- Padding header/footer: `20-24px`
### Tipografía
- Título: `16px` normal (no bold)
- Body text: `14px`
- Line height: `1.4-1.5`
## Ventajas del Sistema
**Reutilizable**: Cada componente es independiente
**Consistente**: Diseño uniforme basado en StreamYard
**Mantenible**: Cambios centralizados en un solo lugar
**Flexible**: Combina componentes según necesidad
**Accesible**: Soporte keyboard, ARIA labels, focus management
**Responsive**: Adaptable a mobile y desktop
**Dark Mode**: Soporte completo de theming
## Archivos
```
src/components/
├── Modal.tsx # Componente base
├── Modal.module.css
├── modal-parts/
│ ├── index.ts # Exportaciones
│ ├── ModalLink.tsx
│ ├── ModalLink.module.css
│ ├── ModalCopyInput.tsx
│ ├── ModalCopyInput.module.css
│ ├── ModalShareButtons.tsx
│ ├── ModalShareButtons.module.css
│ ├── ModalToggle.tsx
│ └── ModalToggle.module.css
├── InviteGuestsModal.tsx # Implementación real
├── ExampleModal.tsx # Ejemplo de uso
└── ...
```
## Próximos Pasos
- [ ] Agregar más variantes de botones (outlined, text)
- [ ] Componente `ModalSelect` para dropdowns
- [ ] Componente `ModalTextarea` para texto largo
- [ ] Animaciones de transición entre secciones
- [ ] Tests unitarios para cada componente

View File

@ -0,0 +1,20 @@
/**
* Componentes modulares reutilizables para modales
* Diseño basado en StreamYard/Google Material
*/
export { default as ModalLink } from './ModalLink'
export { default as ModalCopyInput } from './ModalCopyInput'
export { default as ModalShareButtons } from './ModalShareButtons'
export { default as ModalToggle } from './ModalToggle'
export { default as ModalRadioGroup } from './ModalRadioGroup'
export { default as ModalDestinationButton } from './ModalDestinationButton'
export { default as ModalSection } from './ModalSection'
export { default as ModalInput } from './ModalInput'
export { default as ModalTextarea } from './ModalTextarea'
export { default as ModalSelect } from './ModalSelect'
export { default as ModalCheckbox } from './ModalCheckbox'
export { default as ModalDateTimeGroup } from './ModalDateTimeGroup'
export { default as ModalButton } from './ModalButton'
export { default as ModalButtonGroup } from './ModalButtonGroup'
export { default as ModalPlatformCard } from './ModalPlatformCard'