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 {
|
/* NewTransmissionModal - StreamYard style */
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
.backButton {
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
width: 32px;
|
||||||
animation: fadeIn 0.2s ease;
|
height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #5f6368;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalContent {
|
.backButton:hover {
|
||||||
background-color: var(--surface-color);
|
background-color: #f1f3f4;
|
||||||
border-radius: 8px;
|
color: #202124;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalHeader {
|
.content {
|
||||||
padding: 20px 24px;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--border-light);
|
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;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalTitle {
|
.selectedDestination::before {
|
||||||
font-size: 20px;
|
content: '';
|
||||||
font-weight: 600;
|
position: absolute;
|
||||||
color: var(--text-primary);
|
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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
[data-theme="dark"] .blankStreamDescription {
|
||||||
position: absolute;
|
color: #9aa0a6;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,23 @@
|
|||||||
import React, { useState } from 'react'
|
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 styles from './NewTransmissionModal.module.css'
|
||||||
import type { Transmission } from '../types'
|
import type { Transmission } from '../types'
|
||||||
|
|
||||||
@ -8,81 +27,390 @@ interface Props {
|
|||||||
onCreate: (t: Transmission) => void
|
onCreate: (t: Transmission) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NewTransmissionModal: React.FC<Props> = ({ open, onClose, onCreate }) => {
|
interface DestinationData {
|
||||||
const [title, setTitle] = useState('')
|
id: string
|
||||||
const [platform, setPlatform] = useState('YouTube')
|
platform: string
|
||||||
const [scheduled, setScheduled] = useState('')
|
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 generateId = () => `t_${Date.now()}_${Math.floor(Math.random()*1000)}`
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleAddDestination = () => {
|
||||||
e.preventDefault()
|
setView('add-destination')
|
||||||
const t: Transmission = { id: generateId(), title, platform, scheduled }
|
|
||||||
onCreate(t)
|
|
||||||
setTitle('')
|
|
||||||
setPlatform('YouTube')
|
|
||||||
setScheduled('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<Modal
|
||||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
open={open}
|
||||||
<div className={styles.modalHeader}>
|
onClose={onClose}
|
||||||
<h3 className={styles.modalTitle}>Crear transmisión en vivo</h3>
|
title={modalTitle}
|
||||||
<button className={styles.closeButton} onClick={onClose}>×</button>
|
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>
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={handleSubmit}>
|
</Modal>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,7 @@
|
|||||||
transition: box-shadow 0.18s ease, transform 0.12s ease, border-color 0.12s ease;
|
transition: box-shadow 0.18s ease, transform 0.12s ease, border-color 0.12s ease;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
position: relative; /* for plus overlay */
|
||||||
}
|
}
|
||||||
|
|
||||||
.createCard:hover {
|
.createCard:hover {
|
||||||
@ -64,6 +65,32 @@
|
|||||||
border-color: var(--primary-blue);
|
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 {
|
.createIconBox {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@ -71,6 +98,43 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--bg-muted);
|
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) {
|
@media (max-width: 1024px) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { MdVideocam, MdFiberManualRecord, MdSchool } from 'react-icons/md'
|
import { MdVideocam, MdFiberManualRecord, MdSchool } from 'react-icons/md'
|
||||||
|
import PlusLarge from './icons/PlusLarge'
|
||||||
import { ThemeProvider } from './ThemeProvider'
|
import { ThemeProvider } from './ThemeProvider'
|
||||||
import { Skeleton, SkeletonCard } from './Skeleton'
|
import { Skeleton, SkeletonCard } from './Skeleton'
|
||||||
import styles from './PageContainer.module.css'
|
import styles from './PageContainer.module.css'
|
||||||
@ -74,26 +75,47 @@ const PageContainer: React.FC = () => {
|
|||||||
<div className={styles.createGrid}>
|
<div className={styles.createGrid}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className={styles.createCard}
|
className={`${styles.createCard} ${styles.cardBlue}`}
|
||||||
>
|
>
|
||||||
<div className={styles.createIconBox}>
|
<div className={styles.createCardInner}>
|
||||||
<MdVideocam size={20} style={{ color: 'var(--primary-blue)' }} />
|
<div className={styles.createIconBox} style={{ background: 'rgba(26,115,232,0.08)' }}>
|
||||||
|
<MdVideocam size={20} />
|
||||||
|
</div>
|
||||||
|
<span>Transmisión en vivo</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<button className={styles.createCard}>
|
<button className={`${styles.createCard} ${styles.cardRed}`}>
|
||||||
<div className={styles.createIconBox}>
|
<div className={styles.createCardInner}>
|
||||||
<MdFiberManualRecord size={20} style={{ color: '#ea4335' }} />
|
<div className={styles.createIconBox} style={{ background: 'rgba(234,67,53,0.08)' }}>
|
||||||
|
<MdFiberManualRecord size={20} />
|
||||||
|
</div>
|
||||||
|
<span>Grabación</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<button className={styles.createCard}>
|
<button className={`${styles.createCard} ${styles.cardGreen}`}>
|
||||||
<div className={styles.createIconBox}>
|
<div className={styles.createCardInner}>
|
||||||
<MdSchool size={20} style={{ color: '#34a853' }} />
|
<div className={styles.createIconBox} style={{ background: 'rgba(52,168,83,0.08)' }}>
|
||||||
|
<MdSchool size={20} />
|
||||||
|
</div>
|
||||||
|
<span>Seminario web On-Air</span>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -55,8 +55,9 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
width: calc(100% + 40px);
|
/* move slightly to the left (-1 spacing ≈ 4px) for a tighter alignment */
|
||||||
margin-left: -20px;
|
width: calc(100% + 26px);
|
||||||
|
margin-left: -10px;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
|
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +93,10 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondaryNavGroup .navItem {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* separator between secondary items (not above the first) */
|
/* separator between secondary items (not above the first) */
|
||||||
.secondaryNavGroup .navItem + .navItem {
|
.secondaryNavGroup .navItem + .navItem {
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
@ -109,6 +114,9 @@
|
|||||||
|
|
||||||
.secondaryNavGroup .navLink {
|
.secondaryNavGroup .navLink {
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
/* match main nav left offset */
|
||||||
|
width: calc(100% + 44px);
|
||||||
|
margin-left: -24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.storageTitle {
|
.storageTitle {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Dropdown } from './Dropdown'
|
|||||||
import { FaYoutube, FaFacebook, FaTwitch, FaLinkedin } from 'react-icons/fa'
|
import { FaYoutube, FaFacebook, FaTwitch, FaLinkedin } from 'react-icons/fa'
|
||||||
import { SkeletonTable } from './Skeleton'
|
import { SkeletonTable } from './Skeleton'
|
||||||
import styles from './TransmissionsTable.module.css'
|
import styles from './TransmissionsTable.module.css'
|
||||||
|
import InviteGuestsModal from './InviteGuestsModal'
|
||||||
import type { Transmission } from '../types'
|
import type { Transmission } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -22,6 +23,8 @@ const platformIcons: Record<string, React.ReactNode> = {
|
|||||||
|
|
||||||
const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate, isLoading }) => {
|
const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate, isLoading }) => {
|
||||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming')
|
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming')
|
||||||
|
const [inviteOpen, setInviteOpen] = useState(false)
|
||||||
|
const [inviteLink, setInviteLink] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
// Filtrado por fechas
|
// Filtrado por fechas
|
||||||
const filtered = transmissions.filter(t => {
|
const filtered = transmissions.filter(t => {
|
||||||
@ -111,10 +114,11 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
|
|||||||
Entrar al estudio
|
Entrar al estudio
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={<button className={styles.moreOptionsButton} aria-label={`Más opciones ${t.title}`}><MdMoreVert size={20} /></button>}
|
trigger={<button className={styles.moreOptionsButton} aria-label={`Más opciones ${t.title}`}><MdMoreVert size={20} /></button>}
|
||||||
items={[
|
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 */} },
|
{ label: 'Editar', icon: <MdEdit size={16} />, onClick: () => {/* editar */} },
|
||||||
{ divider: true, label: '', disabled: false },
|
{ divider: true, label: '', disabled: false },
|
||||||
{ label: 'Ver en YouTube', icon: <MdOpenInNew size={16} />, onClick: () => {/* abrir */} },
|
{ 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 } }
|
{ 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>
|
</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",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
"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 all shared components
|
||||||
export { LanguageSelector } from './LanguageSelector';
|
export { LanguageSelector } from './LanguageSelector';
|
||||||
export { AuthButton } from './AuthButton';
|
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