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:
parent
343ba1675e
commit
e43686e36d
80
packages/broadcast-panel/src/components/ExampleModal.tsx
Normal file
80
packages/broadcast-panel/src/components/ExampleModal.tsx
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
29
packages/broadcast-panel/src/components/icons/PlusLarge.tsx
Normal file
29
packages/broadcast-panel/src/components/icons/PlusLarge.tsx
Normal 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
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
17
packages/broadcast-panel/vite.config.ts
Normal file
17
packages/broadcast-panel/vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
158
shared/components/Modal.module.css
Normal file
158
shared/components/Modal.module.css
Normal 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;
|
||||
}
|
||||
76
shared/components/Modal.tsx
Normal file
76
shared/components/Modal.tsx
Normal 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
124
shared/components/README.md
Normal 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
|
||||
@ -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';
|
||||
|
||||
|
||||
|
||||
|
||||
99
shared/components/modal-parts/ModalButton.module.css
Normal file
99
shared/components/modal-parts/ModalButton.module.css
Normal 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;
|
||||
}
|
||||
38
shared/components/modal-parts/ModalButton.tsx
Normal file
38
shared/components/modal-parts/ModalButton.tsx
Normal 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
|
||||
@ -0,0 +1,7 @@
|
||||
/* ModalButtonGroup - Button container for modal actions */
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
17
shared/components/modal-parts/ModalButtonGroup.tsx
Normal file
17
shared/components/modal-parts/ModalButtonGroup.tsx
Normal 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
|
||||
100
shared/components/modal-parts/ModalCheckbox.module.css
Normal file
100
shared/components/modal-parts/ModalCheckbox.module.css
Normal 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;
|
||||
}
|
||||
61
shared/components/modal-parts/ModalCheckbox.tsx
Normal file
61
shared/components/modal-parts/ModalCheckbox.tsx
Normal 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
|
||||
66
shared/components/modal-parts/ModalCopyInput.module.css
Normal file
66
shared/components/modal-parts/ModalCopyInput.module.css
Normal 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;
|
||||
}
|
||||
54
shared/components/modal-parts/ModalCopyInput.tsx
Normal file
54
shared/components/modal-parts/ModalCopyInput.tsx
Normal 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
|
||||
154
shared/components/modal-parts/ModalDateTimeGroup.module.css
Normal file
154
shared/components/modal-parts/ModalDateTimeGroup.module.css
Normal 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);
|
||||
}
|
||||
96
shared/components/modal-parts/ModalDateTimeGroup.tsx
Normal file
96
shared/components/modal-parts/ModalDateTimeGroup.tsx
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
37
shared/components/modal-parts/ModalDestinationButton.tsx
Normal file
37
shared/components/modal-parts/ModalDestinationButton.tsx
Normal 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
|
||||
77
shared/components/modal-parts/ModalInput.module.css
Normal file
77
shared/components/modal-parts/ModalInput.module.css
Normal 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;
|
||||
}
|
||||
46
shared/components/modal-parts/ModalInput.tsx
Normal file
46
shared/components/modal-parts/ModalInput.tsx
Normal 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
|
||||
23
shared/components/modal-parts/ModalLink.module.css
Normal file
23
shared/components/modal-parts/ModalLink.module.css
Normal 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;
|
||||
}
|
||||
41
shared/components/modal-parts/ModalLink.tsx
Normal file
41
shared/components/modal-parts/ModalLink.tsx
Normal 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
|
||||
86
shared/components/modal-parts/ModalPlatformCard.module.css
Normal file
86
shared/components/modal-parts/ModalPlatformCard.module.css
Normal 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;
|
||||
}
|
||||
37
shared/components/modal-parts/ModalPlatformCard.tsx
Normal file
37
shared/components/modal-parts/ModalPlatformCard.tsx
Normal 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
|
||||
141
shared/components/modal-parts/ModalRadioGroup.module.css
Normal file
141
shared/components/modal-parts/ModalRadioGroup.module.css
Normal 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;
|
||||
}
|
||||
67
shared/components/modal-parts/ModalRadioGroup.tsx
Normal file
67
shared/components/modal-parts/ModalRadioGroup.tsx
Normal 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
|
||||
21
shared/components/modal-parts/ModalSection.module.css
Normal file
21
shared/components/modal-parts/ModalSection.module.css
Normal 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;
|
||||
}
|
||||
21
shared/components/modal-parts/ModalSection.tsx
Normal file
21
shared/components/modal-parts/ModalSection.tsx
Normal 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
|
||||
62
shared/components/modal-parts/ModalSelect.module.css
Normal file
62
shared/components/modal-parts/ModalSelect.module.css
Normal 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;
|
||||
}
|
||||
52
shared/components/modal-parts/ModalSelect.tsx
Normal file
52
shared/components/modal-parts/ModalSelect.tsx
Normal 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
|
||||
63
shared/components/modal-parts/ModalShareButtons.module.css
Normal file
63
shared/components/modal-parts/ModalShareButtons.module.css
Normal 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;
|
||||
}
|
||||
71
shared/components/modal-parts/ModalShareButtons.tsx
Normal file
71
shared/components/modal-parts/ModalShareButtons.tsx
Normal 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
|
||||
80
shared/components/modal-parts/ModalTextarea.module.css
Normal file
80
shared/components/modal-parts/ModalTextarea.module.css
Normal 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;
|
||||
}
|
||||
48
shared/components/modal-parts/ModalTextarea.tsx
Normal file
48
shared/components/modal-parts/ModalTextarea.tsx
Normal 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
|
||||
108
shared/components/modal-parts/ModalToggle.module.css
Normal file
108
shared/components/modal-parts/ModalToggle.module.css
Normal 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;
|
||||
}
|
||||
53
shared/components/modal-parts/ModalToggle.tsx
Normal file
53
shared/components/modal-parts/ModalToggle.tsx
Normal 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
|
||||
204
shared/components/modal-parts/README.md
Normal file
204
shared/components/modal-parts/README.md
Normal 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
|
||||
20
shared/components/modal-parts/index.ts
Normal file
20
shared/components/modal-parts/index.ts
Normal 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'
|
||||
Loading…
x
Reference in New Issue
Block a user