diff --git a/packages/broadcast-panel/src/components/ExampleModal.tsx b/packages/broadcast-panel/src/components/ExampleModal.tsx new file mode 100644 index 0000000..6132cc8 --- /dev/null +++ b/packages/broadcast-panel/src/components/ExampleModal.tsx @@ -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 = ({ open, onClose }) => { + const [enabled, setEnabled] = useState(true) + + return ( + + + + + } + > +
+

+ Este es un ejemplo de cómo usar los componentes modulares. Puedes leer más en{' '} + nuestra documentación. +

+ + + + console.log('Gmail')} + onEmail={() => console.log('Email')} + onMessenger={() => console.log('Messenger')} + /> + + +
+
+ ) +} + +export default ExampleModal diff --git a/packages/broadcast-panel/src/components/InviteGuestsModal.module.css b/packages/broadcast-panel/src/components/InviteGuestsModal.module.css new file mode 100644 index 0000000..c98077c --- /dev/null +++ b/packages/broadcast-panel/src/components/InviteGuestsModal.module.css @@ -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; +} + diff --git a/packages/broadcast-panel/src/components/InviteGuestsModal.tsx b/packages/broadcast-panel/src/components/InviteGuestsModal.tsx new file mode 100644 index 0000000..ffa7f7b --- /dev/null +++ b/packages/broadcast-panel/src/components/InviteGuestsModal.tsx @@ -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 = ({ 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 ( + +
+

+ Envía este enlace a tus invitados. Es posible que también quieras compartir nuestras {' '} + + instrucciones para invitados + . +

+ +

+ Puedes tener hasta 6 personas en pantalla a la vez. {' '} + Mejora tu plan {' '} + si necesitas más. +

+ + + + + + +
+
+ ) +} + +export default InviteGuestsModal diff --git a/packages/broadcast-panel/src/components/NewTransmissionModal.module.css b/packages/broadcast-panel/src/components/NewTransmissionModal.module.css index 1580856..53a85d7 100644 --- a/packages/broadcast-panel/src/components/NewTransmissionModal.module.css +++ b/packages/broadcast-panel/src/components/NewTransmissionModal.module.css @@ -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; } diff --git a/packages/broadcast-panel/src/components/NewTransmissionModal.tsx b/packages/broadcast-panel/src/components/NewTransmissionModal.tsx index cb24c46..ad994b8 100644 --- a/packages/broadcast-panel/src/components/NewTransmissionModal.tsx +++ b/packages/broadcast-panel/src/components/NewTransmissionModal.tsx @@ -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 = ({ 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 = ({ open, onClose, onCreate }) => { + const [view, setView] = useState<'main' | 'add-destination'>('main') + const [source, setSource] = useState('studio') + const [destinations, setDestinations] = useState([ + { + id: 'yt_1', + platform: 'YouTube', + icon: , + badge: + } + ]) + const [selectedDestination, setSelectedDestination] = useState(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 = { + 'YouTube': { icon: , color: '#FF0000' }, + 'Facebook': { icon: , color: '#1877F2' }, + 'LinkedIn': { icon: , color: '#0A66C2' }, + 'X (Twitter)': { icon: , color: '#000000' }, + 'Twitch': { icon: , color: '#9146FF' }, + 'Instagram Live': { icon: , color: '#E4405F' }, + 'Kick': { icon: , color: '#53FC18' }, + 'Brightcove': { icon:
B
, color: '#000000' } + } + + const newDest: DestinationData = { + id: `dest_${Date.now()}`, + platform, + icon: platformData[platform]?.icon || , + badge: platform === 'YouTube' ? : 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 ( -
-
e.stopPropagation()}> -
-

Crear transmisión en vivo

- + + {showBackButton && ( + + )} + + {view === 'main' && ( + <> +
+ + } + name="source" + value={source} + onChange={setSource} + options={[ + { value: 'studio', label: 'Estudio', icon: }, + { value: 'prerecorded', label: 'Video pregrabado', icon: } + ]} + /> + + + +
+ {destinations.map((dest) => ( +
+ handleDestinationClick(dest.id)} + title={dest.platform} + /> +
+ ))} + + } + label="" + onClick={handleAddDestination} + title="Agregar destino" + /> + {!selectedDestination && ( +
+ + Omitir por ahora + +
+ )} +
+ + +
+ + {selectedDestination && selectedDestination !== 'blank' && ( + <> + + + + + + + + + + } + subtext="Gana $25 en crédito por cada referencia exitosa." + /> + + + + + + + + + + + + + + + {scheduleForLater && ( + <> + + } + dateValue={scheduledDate} + hourValue={scheduledHour} + minuteValue={scheduledMinute} + onDateChange={setScheduledDate} + onHourChange={setScheduledHour} + onMinuteChange={setScheduledMinute} + timezone="GMT-7" + /> + + + + + } + > + Subir imagen en miniatura + + } + > + Crear con IA + + + + + )} + + )} + + {selectedDestination === 'blank' && ( +
+

+ Empezarás una transmisión en el estudio sin configurar ningún destino. + Podrás agregar destinos más tarde desde el estudio. +

+ + +
+ )} +
+ +
+ {selectedDestination && selectedDestination !== 'blank' && ( +

+ Esta transmisión no se grabará en StreamYard. Para grabar, tendrás que{' '} + pasarte a un plan superior. +

+ )} + +
+ + )} + + {view === 'add-destination' && ( +
+ } + label="YouTube" + onClick={() => handlePlatformSelect('YouTube')} + /> + } + label="Facebook" + onClick={() => handlePlatformSelect('Facebook')} + /> + } + label="LinkedIn" + onClick={() => handlePlatformSelect('LinkedIn')} + /> + } + label="X (Twitter)" + onClick={() => handlePlatformSelect('X (Twitter)')} + /> + } + label="Twitch" + onClick={() => handlePlatformSelect('Twitch')} + /> + } + label="Instagram Live" + onClick={() => handlePlatformSelect('Instagram Live')} + /> + } + label="Kick" + onClick={() => handlePlatformSelect('Kick')} + badge="pro" + /> + B
} + label="Brightcove" + onClick={() => handlePlatformSelect('Brightcove')} + /> + RTMP
} + label="Otras plataformas" + onClick={() => handlePlatformSelect('RTMP')} + badge="pro" + />
- -
-
-
- - setTitle(e.target.value)} - className={styles.formInput} - placeholder="Ej: Mi primera transmisión" - required - /> -
- -
- - -
- -
- - setScheduled(e.target.value)} - placeholder="YYYY-MM-DD HH:mm" - className={styles.formInput} - /> -
-
- -
- - -
-
-
- + )} + ) } diff --git a/packages/broadcast-panel/src/components/PageContainer.module.css b/packages/broadcast-panel/src/components/PageContainer.module.css index 89534ed..941e68b 100644 --- a/packages/broadcast-panel/src/components/PageContainer.module.css +++ b/packages/broadcast-panel/src/components/PageContainer.module.css @@ -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) { diff --git a/packages/broadcast-panel/src/components/PageContainer.tsx b/packages/broadcast-panel/src/components/PageContainer.tsx index 467f3d2..d4289eb 100644 --- a/packages/broadcast-panel/src/components/PageContainer.tsx +++ b/packages/broadcast-panel/src/components/PageContainer.tsx @@ -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 = () => {
- -
)} diff --git a/packages/broadcast-panel/src/components/Sidebar.module.css b/packages/broadcast-panel/src/components/Sidebar.module.css index 97b307b..09bba95 100644 --- a/packages/broadcast-panel/src/components/Sidebar.module.css +++ b/packages/broadcast-panel/src/components/Sidebar.module.css @@ -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 { diff --git a/packages/broadcast-panel/src/components/TransmissionsTable.tsx b/packages/broadcast-panel/src/components/TransmissionsTable.tsx index e593a3f..51b0c83 100644 --- a/packages/broadcast-panel/src/components/TransmissionsTable.tsx +++ b/packages/broadcast-panel/src/components/TransmissionsTable.tsx @@ -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 = { const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate, isLoading }) => { const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming') + const [inviteOpen, setInviteOpen] = useState(false) + const [inviteLink, setInviteLink] = useState(undefined) // Filtrado por fechas const filtered = transmissions.filter(t => { @@ -111,10 +114,11 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate Entrar al estudio + <> } items={[ - { label: 'Agregar invitados', icon: , onClick: () => console.log('Agregar invitados', t.id) }, + { label: 'Agregar invitados', icon: , onClick: () => { setInviteLink(`https://streamyard.com/${t.id}`); setInviteOpen(true) } }, { label: 'Editar', icon: , onClick: () => {/* editar */} }, { divider: true, label: '', disabled: false }, { label: 'Ver en YouTube', icon: , onClick: () => {/* abrir */} }, @@ -122,6 +126,8 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate { label: 'Eliminar transmisión', icon: , onClick: () => onDelete(t.id), containerProps: { className: styles.deleteItem }, labelProps: { className: styles.dangerLabel } } ]} /> + setInviteOpen(false)} link={inviteLink} /> + diff --git a/packages/broadcast-panel/src/components/icons/PlusLarge.tsx b/packages/broadcast-panel/src/components/icons/PlusLarge.tsx new file mode 100644 index 0000000..18c5aa5 --- /dev/null +++ b/packages/broadcast-panel/src/components/icons/PlusLarge.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +type Props = { + className?: string + size?: number + color?: string +} + +export const PlusLarge: React.FC = ({ className = '', size = 20, color = 'var(--primary-blue)' }) => { + const half = size / 2 + const stroke = Math.max(1, Math.round(size * 0.12)) + return ( + + ) +} + +export default PlusLarge diff --git a/packages/broadcast-panel/tsconfig.json b/packages/broadcast-panel/tsconfig.json index b21e535..9532bdd 100644 --- a/packages/broadcast-panel/tsconfig.json +++ b/packages/broadcast-panel/tsconfig.json @@ -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"] } diff --git a/packages/broadcast-panel/vite.config.ts b/packages/broadcast-panel/vite.config.ts new file mode 100644 index 0000000..6e33c0a --- /dev/null +++ b/packages/broadcast-panel/vite.config.ts @@ -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 + } +}) diff --git a/shared/components/Modal.module.css b/shared/components/Modal.module.css new file mode 100644 index 0000000..07ff808 --- /dev/null +++ b/shared/components/Modal.module.css @@ -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; +} diff --git a/shared/components/Modal.tsx b/shared/components/Modal.tsx new file mode 100644 index 0000000..5f1e87c --- /dev/null +++ b/shared/components/Modal.tsx @@ -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 = ({ + open, + onClose, + title, + children, + footer, + width = 'md', + closeOnOverlayClick = true +}) => { + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + if (open) { + dialog.showModal() + } else { + dialog.close() + } + }, [open]) + + const handleOverlayClick = (e: React.MouseEvent) => { + if (closeOnOverlayClick && e.target === dialogRef.current) { + onClose() + } + } + + if (!open) return null + + return ( + +
+ {/* Header */} + {title && ( +
+

{title}

+ +
+ )} + + {/* Body */} +
{children}
+ + {/* Footer */} + {footer &&
{footer}
} +
+
+ ) +} + +export default Modal diff --git a/shared/components/README.md b/shared/components/README.md new file mode 100644 index 0000000..6b49765 --- /dev/null +++ b/shared/components/README.md @@ -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 `` 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 diff --git a/shared/components/index.ts b/shared/components/index.ts index 300ba81..64a07a1 100644 --- a/shared/components/index.ts +++ b/shared/components/index.ts @@ -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'; + + + diff --git a/shared/components/modal-parts/ModalButton.module.css b/shared/components/modal-parts/ModalButton.module.css new file mode 100644 index 0000000..6275ad3 --- /dev/null +++ b/shared/components/modal-parts/ModalButton.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalButton.tsx b/shared/components/modal-parts/ModalButton.tsx new file mode 100644 index 0000000..e6fe683 --- /dev/null +++ b/shared/components/modal-parts/ModalButton.tsx @@ -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 = ({ + children, + onClick, + variant = 'outlined', + icon, + type = 'button', + disabled = false, + className = '' +}) => { + const variantClass = styles[variant] || styles.outlined + + return ( + + ) +} + +export default ModalButton diff --git a/shared/components/modal-parts/ModalButtonGroup.module.css b/shared/components/modal-parts/ModalButtonGroup.module.css new file mode 100644 index 0000000..d8f003f --- /dev/null +++ b/shared/components/modal-parts/ModalButtonGroup.module.css @@ -0,0 +1,7 @@ +/* ModalButtonGroup - Button container for modal actions */ + +.buttonGroup { + display: flex; + gap: 12px; + align-items: center; +} diff --git a/shared/components/modal-parts/ModalButtonGroup.tsx b/shared/components/modal-parts/ModalButtonGroup.tsx new file mode 100644 index 0000000..cc5552c --- /dev/null +++ b/shared/components/modal-parts/ModalButtonGroup.tsx @@ -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 = ({ children, className = '' }) => { + return ( +
+ {children} +
+ ) +} + +export default ModalButtonGroup diff --git a/shared/components/modal-parts/ModalCheckbox.module.css b/shared/components/modal-parts/ModalCheckbox.module.css new file mode 100644 index 0000000..c8268c2 --- /dev/null +++ b/shared/components/modal-parts/ModalCheckbox.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalCheckbox.tsx b/shared/components/modal-parts/ModalCheckbox.tsx new file mode 100644 index 0000000..8ad797a --- /dev/null +++ b/shared/components/modal-parts/ModalCheckbox.tsx @@ -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 = ({ + checked, + onChange, + label, + helpIcon, + subtext, + className = '' +}) => { + return ( +
+ + {subtext &&

{subtext}

} +
+ ) +} + +export default ModalCheckbox diff --git a/shared/components/modal-parts/ModalCopyInput.module.css b/shared/components/modal-parts/ModalCopyInput.module.css new file mode 100644 index 0000000..aefb76c --- /dev/null +++ b/shared/components/modal-parts/ModalCopyInput.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalCopyInput.tsx b/shared/components/modal-parts/ModalCopyInput.tsx new file mode 100644 index 0000000..512e180 --- /dev/null +++ b/shared/components/modal-parts/ModalCopyInput.tsx @@ -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 = ({ + 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 ( +
+ + +
+ ) +} + +export default ModalCopyInput diff --git a/shared/components/modal-parts/ModalDateTimeGroup.module.css b/shared/components/modal-parts/ModalDateTimeGroup.module.css new file mode 100644 index 0000000..5f6e3c1 --- /dev/null +++ b/shared/components/modal-parts/ModalDateTimeGroup.module.css @@ -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); +} diff --git a/shared/components/modal-parts/ModalDateTimeGroup.tsx b/shared/components/modal-parts/ModalDateTimeGroup.tsx new file mode 100644 index 0000000..0056bee --- /dev/null +++ b/shared/components/modal-parts/ModalDateTimeGroup.tsx @@ -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 = ({ + 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 ( +
+ {label && ( +
+ + {helpIcon && ( + + )} +
+ )} + +
+ onDateChange(e.target.value)} + className={styles.dateInput} + /> + + + + +
+
+ ) +} + +export default ModalDateTimeGroup diff --git a/shared/components/modal-parts/ModalDestinationButton.module.css b/shared/components/modal-parts/ModalDestinationButton.module.css new file mode 100644 index 0000000..87eae26 --- /dev/null +++ b/shared/components/modal-parts/ModalDestinationButton.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalDestinationButton.tsx b/shared/components/modal-parts/ModalDestinationButton.tsx new file mode 100644 index 0000000..b0b5051 --- /dev/null +++ b/shared/components/modal-parts/ModalDestinationButton.tsx @@ -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 = ({ + icon, + label, + onClick, + badge, + className = '', + title +}) => { + return ( + + ) +} + +export default ModalDestinationButton diff --git a/shared/components/modal-parts/ModalInput.module.css b/shared/components/modal-parts/ModalInput.module.css new file mode 100644 index 0000000..fb27be0 --- /dev/null +++ b/shared/components/modal-parts/ModalInput.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalInput.tsx b/shared/components/modal-parts/ModalInput.tsx new file mode 100644 index 0000000..60a5ae5 --- /dev/null +++ b/shared/components/modal-parts/ModalInput.tsx @@ -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 = ({ + label, + value, + onChange, + placeholder, + maxLength, + showCounter = false, + required = false, + className = '' +}) => { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + maxLength={maxLength} + required={required} + className={styles.input} + /> + {showCounter && maxLength && ( +
+ {value.length}/{maxLength} +
+ )} +
+ ) +} + +export default ModalInput diff --git a/shared/components/modal-parts/ModalLink.module.css b/shared/components/modal-parts/ModalLink.module.css new file mode 100644 index 0000000..c55fed3 --- /dev/null +++ b/shared/components/modal-parts/ModalLink.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalLink.tsx b/shared/components/modal-parts/ModalLink.tsx new file mode 100644 index 0000000..86d9cf4 --- /dev/null +++ b/shared/components/modal-parts/ModalLink.tsx @@ -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 = ({ href, onClick, children, className = '' }) => { + if (onClick) { + return ( + + ) + } + + return ( + + {children} + + ) +} + +export default ModalLink diff --git a/shared/components/modal-parts/ModalPlatformCard.module.css b/shared/components/modal-parts/ModalPlatformCard.module.css new file mode 100644 index 0000000..bf43e35 --- /dev/null +++ b/shared/components/modal-parts/ModalPlatformCard.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalPlatformCard.tsx b/shared/components/modal-parts/ModalPlatformCard.tsx new file mode 100644 index 0000000..cd281c5 --- /dev/null +++ b/shared/components/modal-parts/ModalPlatformCard.tsx @@ -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 = ({ + icon, + label, + onClick, + badge, + className = '' +}) => { + return ( + + ) +} + +export default ModalPlatformCard diff --git a/shared/components/modal-parts/ModalRadioGroup.module.css b/shared/components/modal-parts/ModalRadioGroup.module.css new file mode 100644 index 0000000..90eb720 --- /dev/null +++ b/shared/components/modal-parts/ModalRadioGroup.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalRadioGroup.tsx b/shared/components/modal-parts/ModalRadioGroup.tsx new file mode 100644 index 0000000..3c009fc --- /dev/null +++ b/shared/components/modal-parts/ModalRadioGroup.tsx @@ -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 = ({ + label, + helpIcon, + options, + value, + onChange, + name, + className = '' +}) => { + return ( +
+ {label && ( +
+ + {helpIcon && ( + + )} +
+ )} + +
+ {options.map((option) => ( + + ))} +
+
+ ) +} + +export default ModalRadioGroup diff --git a/shared/components/modal-parts/ModalSection.module.css b/shared/components/modal-parts/ModalSection.module.css new file mode 100644 index 0000000..4ebc92d --- /dev/null +++ b/shared/components/modal-parts/ModalSection.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalSection.tsx b/shared/components/modal-parts/ModalSection.tsx new file mode 100644 index 0000000..3541646 --- /dev/null +++ b/shared/components/modal-parts/ModalSection.tsx @@ -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 = ({ label, children, className = '' }) => { + return ( +
+ {label &&

{label}

} +
+ {children} +
+
+ ) +} + +export default ModalSection diff --git a/shared/components/modal-parts/ModalSelect.module.css b/shared/components/modal-parts/ModalSelect.module.css new file mode 100644 index 0000000..b6725ab --- /dev/null +++ b/shared/components/modal-parts/ModalSelect.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalSelect.tsx b/shared/components/modal-parts/ModalSelect.tsx new file mode 100644 index 0000000..76827de --- /dev/null +++ b/shared/components/modal-parts/ModalSelect.tsx @@ -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 = ({ + label, + value, + onChange, + options, + placeholder, + required = false, + className = '' +}) => { + return ( +
+ + +
+ ) +} + +export default ModalSelect diff --git a/shared/components/modal-parts/ModalShareButtons.module.css b/shared/components/modal-parts/ModalShareButtons.module.css new file mode 100644 index 0000000..681b39f --- /dev/null +++ b/shared/components/modal-parts/ModalShareButtons.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalShareButtons.tsx b/shared/components/modal-parts/ModalShareButtons.tsx new file mode 100644 index 0000000..4e23d6f --- /dev/null +++ b/shared/components/modal-parts/ModalShareButtons.tsx @@ -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 = ({ + onGmail, + onEmail, + onMessenger, + customButtons, + className = '' +}) => { + const defaultButtons: ShareButton[] = [ + ...(onGmail ? [{ + icon: , + label: 'Gmail', + onClick: onGmail + }] : []), + ...(onEmail ? [{ + icon: , + label: 'Correo electrónico', + onClick: onEmail + }] : []), + ...(onMessenger ? [{ + icon: , + label: 'Messenger', + onClick: onMessenger + }] : []) + ] + + const buttons = customButtons || defaultButtons + + if (buttons.length === 0) return null + + return ( +
+ {buttons.map((button, index) => ( + + ))} +
+ ) +} + +export default ModalShareButtons diff --git a/shared/components/modal-parts/ModalTextarea.module.css b/shared/components/modal-parts/ModalTextarea.module.css new file mode 100644 index 0000000..3cae9c2 --- /dev/null +++ b/shared/components/modal-parts/ModalTextarea.module.css @@ -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; +} diff --git a/shared/components/modal-parts/ModalTextarea.tsx b/shared/components/modal-parts/ModalTextarea.tsx new file mode 100644 index 0000000..87a6b3f --- /dev/null +++ b/shared/components/modal-parts/ModalTextarea.tsx @@ -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 = ({ + label, + value, + onChange, + placeholder, + maxLength, + showCounter = false, + rows = 3, + required = false, + className = '' +}) => { + return ( +
+ +