feat: agregar funcionalidad para añadir destinos en el modal de nueva transmisión
This commit is contained in:
parent
164f7fba21
commit
243615d2b7
@ -63,8 +63,7 @@ src/components/
|
|||||||
├── Header.module.css # Estilos del header
|
├── Header.module.css # Estilos del header
|
||||||
├── TransmissionsTable.tsx # Tabla de transmisiones
|
├── TransmissionsTable.tsx # Tabla de transmisiones
|
||||||
├── TransmissionsTable.module.css
|
├── TransmissionsTable.module.css
|
||||||
├── NewTransmissionModal.tsx # Modal de creación
|
└── NewTransmissionModal (migrado a `shared/components/NewTransmissionModal.tsx`)
|
||||||
└── NewTransmissionModal.module.css
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Desarrollo
|
## 🚀 Desarrollo
|
||||||
|
|||||||
@ -188,8 +188,7 @@ src/components/
|
|||||||
├── Header.module.css
|
├── Header.module.css
|
||||||
├── TransmissionsTable.tsx # Tabla con filtrado
|
├── TransmissionsTable.tsx # Tabla con filtrado
|
||||||
├── TransmissionsTable.module.css
|
├── TransmissionsTable.module.css
|
||||||
├── NewTransmissionModal.tsx # Modal de creación
|
└── (Ahora usa `shared/components/NewTransmissionModal.tsx` y su CSS compartida)
|
||||||
└── NewTransmissionModal.module.css
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Scripts
|
## 🚀 Scripts
|
||||||
|
|||||||
@ -27,6 +27,10 @@ interface Props {
|
|||||||
onCreate: (t: Transmission) => void
|
onCreate: (t: Transmission) => void
|
||||||
onUpdate?: (t: Transmission) => void
|
onUpdate?: (t: Transmission) => void
|
||||||
transmission?: Transmission // Transmisión a editar
|
transmission?: Transmission // Transmisión a editar
|
||||||
|
// If provided, modal can be used only to add a destination. When true, selecting a platform
|
||||||
|
// will call onAddDestination with the created DestinationData and close the modal.
|
||||||
|
onlyAddDestination?: boolean
|
||||||
|
onAddDestination?: (d: DestinationData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DestinationData {
|
interface DestinationData {
|
||||||
@ -137,6 +141,12 @@ const NewTransmissionModal: React.FC<Props> = ({ open, onClose, onCreate, onUpda
|
|||||||
icon: platformData[platform]?.icon || <MdAdd />,
|
icon: platformData[platform]?.icon || <MdAdd />,
|
||||||
badge: platform === 'YouTube' ? <span style={{ color: '#FF0000', fontSize: '12px' }}>▶</span> : undefined
|
badge: platform === 'YouTube' ? <span style={{ color: '#FF0000', fontSize: '12px' }}>▶</span> : undefined
|
||||||
}
|
}
|
||||||
|
// If this modal is used only to add a destination, call the callback and close
|
||||||
|
if (onlyAddDestination && onAddDestination) {
|
||||||
|
onAddDestination(newDest)
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setDestinations([...destinations, newDest])
|
setDestinations([...destinations, newDest])
|
||||||
setView('main')
|
setView('main')
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import styles from './PageContainer.module.css'
|
|||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import TransmissionsTable from './TransmissionsTable'
|
import TransmissionsTable from './TransmissionsTable'
|
||||||
import NewTransmissionModal from './NewTransmissionModal'
|
import { NewTransmissionModal } from '@shared/components'
|
||||||
import Studio from './Studio'
|
import Studio from './Studio'
|
||||||
import type { Transmission } from '../types'
|
import type { Transmission } from '../types'
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ 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 InviteGuestsModal from './InviteGuestsModal'
|
||||||
import NewTransmissionModal from './NewTransmissionModal'
|
import { NewTransmissionModal } from '@shared/components'
|
||||||
import type { Transmission } from '../types'
|
import type { Transmission } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
10
packages/broadcast-panel/src/env.d.ts
vendored
Normal file
10
packages/broadcast-panel/src/env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Local env declarations for broadcast-panel (Vite-style import.meta.env)
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_TOKEN_SERVER_URL?: string
|
||||||
|
readonly VITE_STUDIO_URL?: string
|
||||||
|
[key: string]: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@ -1,7 +1,2 @@
|
|||||||
export interface Transmission {
|
export type { Transmission } from '@shared/types'
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
platform: string
|
|
||||||
scheduled: string
|
|
||||||
createdAt?: string // Fecha de creación
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"baseUrl": "../..",
|
"baseUrl": "../..",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["packages/broadcast-panel/src/*"],
|
"@/*": ["packages/broadcast-panel/src/*"],
|
||||||
"@shared/*": ["shared/*"]
|
"@shared/*": ["shared/*"],
|
||||||
|
"@shared": ["shared"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "../../shared"],
|
"include": ["src", "../../shared"],
|
||||||
|
|||||||
@ -1,120 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useDestinations, Destination } from '../../hooks/useDestinations'
|
|
||||||
|
|
||||||
const PLATFORMS = [
|
|
||||||
{ id: 'youtube', label: 'YouTube', color: 'bg-red-600' },
|
|
||||||
{ id: 'twitch', label: 'Twitch', color: 'bg-purple-700' },
|
|
||||||
{ id: 'facebook', label: 'Facebook', color: 'bg-blue-600' },
|
|
||||||
{ id: 'linkedin', label: 'LinkedIn', color: 'bg-indigo-700' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
editing?: Destination | null
|
|
||||||
onSaved?: (d: Destination) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DestinationModal: React.FC<Props> = ({ open, onClose, editing = null, onSaved }) => {
|
|
||||||
const { addDestination, updateDestination } = useDestinations()
|
|
||||||
const backdropRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const [platform, setPlatform] = useState(PLATFORMS[0].id)
|
|
||||||
const [label, setLabel] = useState('')
|
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
setPlatform(editing.platform)
|
|
||||||
setLabel(editing.label)
|
|
||||||
setUrl(editing.url || '')
|
|
||||||
} else {
|
|
||||||
setPlatform(PLATFORMS[0].id)
|
|
||||||
setLabel('')
|
|
||||||
setUrl('')
|
|
||||||
}
|
|
||||||
setError(null)
|
|
||||||
}, [editing, open])
|
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey)
|
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
|
||||||
}, [open, onClose])
|
|
||||||
|
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
if (!label || label.trim().length === 0) {
|
|
||||||
setError('La etiqueta es obligatoria')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (url && !/^https?:\/\//.test(url)) {
|
|
||||||
setError('La URL debe comenzar con http:// o https://')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
setError(null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!validate()) return
|
|
||||||
if (editing) {
|
|
||||||
updateDestination(editing.id, { platform, label, url: url || undefined })
|
|
||||||
onSaved && onSaved({ ...editing, platform, label, url: url || undefined })
|
|
||||||
} else {
|
|
||||||
const newD = addDestination({ platform, label, url: url || undefined })
|
|
||||||
onSaved && onSaved(newD)
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={backdropRef}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// close when clicking on the backdrop (but not when clicking inside the dialog)
|
|
||||||
if (e.target === backdropRef.current) onClose()
|
|
||||||
}}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
>
|
|
||||||
<div onMouseDown={(e) => e.stopPropagation()} className="w-full max-w-md bg-white dark:bg-gray-900 rounded-md p-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">{editing ? 'Editar destino' : 'Agregar destino'}</h3>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<label className="text-sm text-gray-600 dark:text-gray-300">Plataforma</label>
|
|
||||||
<div className="flex gap-2 mb-2">
|
|
||||||
{PLATFORMS.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
onClick={() => setPlatform(p.id)}
|
|
||||||
className={`px-3 py-2 rounded-md border ${platform === p.id ? 'border-gray-700' : 'border-transparent'} flex-1 text-sm`}
|
|
||||||
>
|
|
||||||
<span className={`${p.color} inline-block w-4 h-4 rounded-full mr-2`} />
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="text-sm text-gray-600 dark:text-gray-300">Etiqueta</label>
|
|
||||||
<input value={label} onChange={(e) => setLabel(e.target.value)} className="px-3 py-2 rounded-md bg-gray-100 dark:bg-gray-800" />
|
|
||||||
|
|
||||||
<label className="text-sm text-gray-600 dark:text-gray-300">URL (opcional)</label>
|
|
||||||
<input value={url} onChange={(e) => setUrl(e.target.value)} className="px-3 py-2 rounded-md bg-gray-100 dark:bg-gray-800" />
|
|
||||||
|
|
||||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
|
||||||
<button onClick={onClose} className="px-3 py-2 rounded-md bg-gray-200 hover:bg-gray-300">Cerrar</button>
|
|
||||||
<button onClick={handleAdd} className="px-3 py-2 rounded-md bg-[#2563eb] text-white">{editing ? 'Guardar' : 'Agregar'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DestinationModal
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import DestinationModal from './DestinationModal'
|
import { NewTransmissionModal } from '@shared/components'
|
||||||
import { useDestinations, Destination } from '../../hooks/useDestinations'
|
import { useDestinations, Destination } from '../../hooks/useDestinations'
|
||||||
|
|
||||||
const PlatformBadge: React.FC<{ color: string; children: React.ReactNode }> = ({ color, children }) => (
|
const PlatformBadge: React.FC<{ color: string; children: React.ReactNode }> = ({ color, children }) => (
|
||||||
@ -8,8 +8,7 @@ const PlatformBadge: React.FC<{ color: string; children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Destination | null>(null)
|
const { destinations, addDestination } = useDestinations()
|
||||||
const { destinations } = useDestinations()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -50,13 +49,16 @@ const Header: React.FC = () => {
|
|||||||
{/* Destinations list removed from inline layout to avoid deforming the header/container.
|
{/* Destinations list removed from inline layout to avoid deforming the header/container.
|
||||||
Destinations are managed via the DestinationModal (overlay) which does not affect layout. */}
|
Destinations are managed via the DestinationModal (overlay) which does not affect layout. */}
|
||||||
|
|
||||||
<DestinationModal
|
<NewTransmissionModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => {
|
onClose={() => setOpen(false)}
|
||||||
|
onCreate={() => {}}
|
||||||
|
onlyAddDestination
|
||||||
|
onAddDestination={(d: { id: string; platform: string }) => {
|
||||||
|
const dest: Destination = { id: d.id, platform: d.platform, label: d.platform, url: undefined }
|
||||||
|
addDestination(dest)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setEditing(null)
|
|
||||||
}}
|
}}
|
||||||
editing={editing}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Destination as SharedDestination } from '@shared/types'
|
||||||
|
|
||||||
export type Destination = {
|
export type Destination = SharedDestination
|
||||||
id: string
|
|
||||||
platform: string
|
|
||||||
label: string
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'studio_panel_destinations'
|
const STORAGE_KEY = 'studio_panel_destinations'
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,11 @@
|
|||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"baseUrl": "..",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["../shared/*", "../../shared/*"],
|
||||||
|
"@shared": ["../shared", "../../shared"]
|
||||||
|
},
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|||||||
168
shared/components/NewTransmissionModal.module.css
Normal file
168
shared/components/NewTransmissionModal.module.css
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
/* NewTransmissionModal - StreamYard style (migrated to shared) */
|
||||||
|
|
||||||
|
.modalWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalWrapper.hasBackButton :global(.modalHeader) {
|
||||||
|
padding-left: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
left: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #5f6368;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platformGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 24px 24px 24px;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destinations {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skipNowContainer {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedDestination {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedDestination::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-28px, -3px);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border: 3px solid #1a73e8;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #e8eaed;
|
||||||
|
margin: 0 -24px -20px -24px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerNote {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton {
|
||||||
|
padding: 8px 24px;
|
||||||
|
background-color: #1a73e8;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton:hover:not(:disabled) {
|
||||||
|
background-color: #1765cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton:disabled {
|
||||||
|
background-color: #e8eaed;
|
||||||
|
color: #80868b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
[data-theme="dark"] .backButton {
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .backButton:hover {
|
||||||
|
background-color: #3c4043;
|
||||||
|
color: #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .footer {
|
||||||
|
border-top-color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .createButton {
|
||||||
|
background-color: #8ab4f8;
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .createButton:hover:not(:disabled) {
|
||||||
|
background-color: #aecbfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .createButton:disabled {
|
||||||
|
background-color: #3c4043;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blank stream form */
|
||||||
|
.blankStreamForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blankStreamDescription {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .blankStreamDescription {
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
2
shared/components/NewTransmissionModal.module.css.d.ts
vendored
Normal file
2
shared/components/NewTransmissionModal.module.css.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
declare const styles: { [className: string]: string }
|
||||||
|
export default styles
|
||||||
313
shared/components/NewTransmissionModal.tsx
Normal file
313
shared/components/NewTransmissionModal.tsx
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalRadioGroup,
|
||||||
|
ModalSection,
|
||||||
|
ModalDestinationButton,
|
||||||
|
ModalInput,
|
||||||
|
ModalTextarea,
|
||||||
|
ModalSelect,
|
||||||
|
ModalCheckbox,
|
||||||
|
ModalLink,
|
||||||
|
ModalDateTimeGroup,
|
||||||
|
ModalButton,
|
||||||
|
ModalButtonGroup,
|
||||||
|
ModalPlatformCard
|
||||||
|
} from '.'
|
||||||
|
import { MdVideocam, MdVideoLibrary, MdAdd, MdImage, MdAutoAwesome, MdArrowBack } from 'react-icons/md'
|
||||||
|
import { FaYoutube, FaFacebook, FaLinkedin, FaTwitch, FaInstagram, FaKickstarterK } from 'react-icons/fa'
|
||||||
|
import { FaXTwitter } from 'react-icons/fa6'
|
||||||
|
import { BsInfoCircle } from 'react-icons/bs'
|
||||||
|
import styles from './NewTransmissionModal.module.css'
|
||||||
|
import type { Transmission, Destination } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCreate?: (t: Transmission) => void
|
||||||
|
onUpdate?: (t: Transmission) => void
|
||||||
|
transmission?: Transmission
|
||||||
|
onlyAddDestination?: boolean
|
||||||
|
onAddDestination?: (d: DestinationData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type DestinationData = Destination & {
|
||||||
|
id: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
badge?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewTransmissionModal: React.FC<Props> = ({ open, onClose, onCreate, onUpdate, transmission, onlyAddDestination, onAddDestination }) => {
|
||||||
|
const [view, setView] = useState<'main' | 'add-destination'>('main')
|
||||||
|
const [source, setSource] = useState('studio')
|
||||||
|
const isEditMode = !!transmission
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const [blankTitle, setBlankTitle] = useState('')
|
||||||
|
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)
|
||||||
|
|
||||||
|
const [scheduledDate, setScheduledDate] = useState('')
|
||||||
|
const [scheduledHour, setScheduledHour] = useState('01')
|
||||||
|
const [scheduledMinute, setScheduledMinute] = useState('10')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transmission && open) {
|
||||||
|
setTitle(transmission.title)
|
||||||
|
if (transmission.platform === 'Genérico') {
|
||||||
|
setSelectedDestination('blank')
|
||||||
|
setBlankTitle(transmission.title)
|
||||||
|
} else {
|
||||||
|
setSelectedDestination(null)
|
||||||
|
}
|
||||||
|
} else if (!open) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}, [transmission, open])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setView('main')
|
||||||
|
setSource('studio')
|
||||||
|
setSelectedDestination(null)
|
||||||
|
setTitle('')
|
||||||
|
setDescription('')
|
||||||
|
setPrivacy('Pública')
|
||||||
|
setCategory('')
|
||||||
|
setAddReferral(true)
|
||||||
|
setScheduleForLater(false)
|
||||||
|
setScheduledDate('')
|
||||||
|
setScheduledHour('01')
|
||||||
|
setScheduledMinute('10')
|
||||||
|
setBlankTitle('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateId = () => `t_${Date.now()}_${Math.floor(Math.random()*1000)}`
|
||||||
|
|
||||||
|
const handleAddDestination = () => setView('add-destination')
|
||||||
|
const handleBackToMain = () => setView('main')
|
||||||
|
const handleSkipForNow = () => setSelectedDestination('blank')
|
||||||
|
|
||||||
|
const handlePlatformSelect = (platform: string) => {
|
||||||
|
const platformData: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||||
|
'YouTube': { icon: <FaYoutube color="#FF0000" />, color: '#FF0000' },
|
||||||
|
'Facebook': { icon: <FaFacebook color="#1877F2" />, color: '#1877F2' },
|
||||||
|
'LinkedIn': { icon: <FaLinkedin color="#0A66C2" />, color: '#0A66C2' },
|
||||||
|
'X (Twitter)': { icon: <FaXTwitter color="#000000" />, color: '#000000' },
|
||||||
|
'Twitch': { icon: <FaTwitch color="#9146FF" />, color: '#9146FF' },
|
||||||
|
'Instagram Live': { icon: <FaInstagram color="#E4405F" />, color: '#E4405F' },
|
||||||
|
'Kick': { icon: <FaKickstarterK color="#53FC18" />, color: '#53FC18' },
|
||||||
|
'Brightcove': { icon: <div style={{ fontSize: '24px', fontWeight: 'bold' }}>B</div>, color: '#000000' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDest: DestinationData = {
|
||||||
|
id: `dest_${Date.now()}`,
|
||||||
|
platform,
|
||||||
|
icon: platformData[platform]?.icon || <MdAdd />,
|
||||||
|
badge: platform === 'YouTube' ? <span style={{ color: '#FF0000', fontSize: '12px' }}>▶</span> : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyAddDestination && onAddDestination) {
|
||||||
|
onAddDestination(newDest)
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDestinations([...destinations, newDest])
|
||||||
|
setView('main')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDestinationClick = (destId: string) => {
|
||||||
|
if (selectedDestination === destId) setSelectedDestination(null)
|
||||||
|
else setSelectedDestination(destId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!selectedDestination) {
|
||||||
|
alert('Por favor selecciona un destino de transmisión')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDestination === 'blank') {
|
||||||
|
const blankTransmission: Transmission = {
|
||||||
|
id: isEditMode && transmission ? transmission.id : generateId(),
|
||||||
|
title: blankTitle || 'Transmisión en vivo',
|
||||||
|
platform: 'Genérico',
|
||||||
|
scheduled: 'Próximamente',
|
||||||
|
createdAt: isEditMode && transmission?.createdAt ? transmission.createdAt : new Date().toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditMode && onUpdate) onUpdate(blankTransmission)
|
||||||
|
else if (onCreate) onCreate(blankTransmission)
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const t: Transmission = {
|
||||||
|
id: isEditMode && transmission ? transmission.id : generateId(),
|
||||||
|
title: title || 'Nueva transmisión',
|
||||||
|
platform: destinations.find(d => d.id === selectedDestination)?.platform || 'YouTube',
|
||||||
|
scheduled: '',
|
||||||
|
createdAt: isEditMode && transmission?.createdAt ? transmission.createdAt : new Date().toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditMode && onUpdate) onUpdate(t)
|
||||||
|
else if (onCreate) onCreate(t)
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalTitle = view === 'add-destination' ? 'Agregar destino' : (isEditMode ? 'Editar transmisión' : 'Crear transmisión en vivo')
|
||||||
|
const showBackButton = view === 'add-destination'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.modalWrapper} ${showBackButton ? styles.hasBackButton : ''}`}>
|
||||||
|
<Modal open={open} onClose={onClose} title={modalTitle} width="md">
|
||||||
|
{showBackButton && (
|
||||||
|
<button onClick={handleBackToMain} className={styles.backButton}>
|
||||||
|
<MdArrowBack size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'main' && (
|
||||||
|
<>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<ModalSection>
|
||||||
|
<ModalRadioGroup
|
||||||
|
label="Fuente"
|
||||||
|
helpIcon={<BsInfoCircle />}
|
||||||
|
name="source"
|
||||||
|
value={source}
|
||||||
|
onChange={setSource}
|
||||||
|
options={[
|
||||||
|
{ value: 'studio', label: 'Estudio', icon: <MdVideocam /> },
|
||||||
|
{ value: 'prerecorded', label: 'Video pregrabado', icon: <MdVideoLibrary /> }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection label="Selecciona los destinos">
|
||||||
|
<div className={styles.destinations}>
|
||||||
|
{destinations.map((dest) => (
|
||||||
|
<div key={dest.id} className={selectedDestination === dest.id ? styles.selectedDestination : ''}>
|
||||||
|
<ModalDestinationButton
|
||||||
|
icon={dest.icon}
|
||||||
|
label=""
|
||||||
|
badge={dest.badge}
|
||||||
|
onClick={() => handleDestinationClick(dest.id)}
|
||||||
|
title={dest.platform}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ModalDestinationButton
|
||||||
|
icon={<MdAdd />}
|
||||||
|
label=""
|
||||||
|
onClick={handleAddDestination}
|
||||||
|
title="Agregar destino"
|
||||||
|
/>
|
||||||
|
{!selectedDestination && (
|
||||||
|
<div className={styles.skipNowContainer}>
|
||||||
|
<ModalLink onClick={handleSkipForNow}>Omitir por ahora</ModalLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
{selectedDestination && selectedDestination !== 'blank' && (
|
||||||
|
<>
|
||||||
|
<ModalSection>
|
||||||
|
<ModalInput label="Título" value={title} onChange={setTitle} maxLength={100} showCounter={true} />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection>
|
||||||
|
<ModalTextarea label="Descripción" value={description} onChange={setDescription} placeholder="Cuéntanos un poco sobre esta transmisión en vivo" maxLength={5000} showCounter={true} rows={3} />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection>
|
||||||
|
<ModalCheckbox checked={addReferral} onChange={setAddReferral} label="Agrega el mensaje de referencia a la descripción" helpIcon={<BsInfoCircle />} subtext="Gana $25 en crédito por cada referencia exitosa." />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection>
|
||||||
|
<ModalSelect label="Privacidad" value={privacy} onChange={setPrivacy} options={[{ value: 'Pública', label: 'Pública' }, { value: 'No listada', label: 'No listada' }, { value: 'Privada', label: 'Privada' }]} />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection>
|
||||||
|
<ModalSelect label="Categoría" value={category} onChange={setCategory} placeholder="Seleccionar categoría" options={[{ value: 'gaming', label: 'Juegos' }, { value: 'education', label: 'Educación' }, { value: 'entertainment', label: 'Entretenimiento' }, { value: 'music', label: 'Música' }, { value: 'sports', label: 'Deportes' }, { value: 'news', label: 'Noticias' }, { value: 'tech', label: 'Tecnología' }]} />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection>
|
||||||
|
<ModalCheckbox checked={scheduleForLater} onChange={setScheduleForLater} label="Programar para más tarde" />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
{scheduleForLater && (
|
||||||
|
<>
|
||||||
|
<ModalSection>
|
||||||
|
<ModalDateTimeGroup label="Hora de inicio programada" helpIcon={<BsInfoCircle />} dateValue={scheduledDate} hourValue={scheduledHour} minuteValue={scheduledMinute} onDateChange={setScheduledDate} onHourChange={setScheduledHour} onMinuteChange={setScheduledMinute} timezone="GMT-7" />
|
||||||
|
</ModalSection>
|
||||||
|
|
||||||
|
<ModalSection>
|
||||||
|
<ModalButtonGroup>
|
||||||
|
<ModalButton variant="secondary" icon={<MdImage />}>Subir imagen en miniatura</ModalButton>
|
||||||
|
<ModalButton variant="primary" icon={<MdAutoAwesome />}>Crear con IA</ModalButton>
|
||||||
|
</ModalButtonGroup>
|
||||||
|
</ModalSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDestination === 'blank' && (
|
||||||
|
<div className={styles.blankStreamForm}>
|
||||||
|
<p className={styles.blankStreamDescription}>Empezarás una transmisión en el estudio sin configurar ningún destino. Podrás agregar destinos más tarde desde el estudio.</p>
|
||||||
|
<ModalInput label="Título de la transmisión (opcional)" value={blankTitle} onChange={setBlankTitle} placeholder="Ej: Mi transmisión en vivo" maxLength={100} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
{selectedDestination && selectedDestination !== 'blank' && (
|
||||||
|
<p className={styles.footerNote}>Esta transmisión no se grabará en StreamYard. Para grabar, tendrás que <ModalLink href="/pricing">pasarte a un plan superior.</ModalLink></p>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={handleCreate} className={styles.createButton} disabled={!selectedDestination}>
|
||||||
|
{isEditMode ? (selectedDestination === 'blank' ? 'Guardar cambios' : 'Actualizar transmisión') : (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>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NewTransmissionModal }
|
||||||
|
export default NewTransmissionModal
|
||||||
4
shared/components/css-modules.d.ts
vendored
Normal file
4
shared/components/css-modules.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: { [className: string]: string }
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
@ -22,5 +22,8 @@ export { default as ModalButton } from './modal-parts/ModalButton';
|
|||||||
export { default as ModalButtonGroup } from './modal-parts/ModalButtonGroup';
|
export { default as ModalButtonGroup } from './modal-parts/ModalButtonGroup';
|
||||||
export { default as ModalPlatformCard } from './modal-parts/ModalPlatformCard';
|
export { default as ModalPlatformCard } from './modal-parts/ModalPlatformCard';
|
||||||
|
|
||||||
|
// Shared NewTransmissionModal (re-export)
|
||||||
|
export { NewTransmissionModal } from './NewTransmissionModal';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,19 @@
|
|||||||
|
export interface Transmission {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
platform: string
|
||||||
|
scheduled: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Destination = {
|
||||||
|
id: string
|
||||||
|
platform: string
|
||||||
|
label?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {} as unknown
|
||||||
// User types
|
// User types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -11,10 +11,15 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["shared/*"],
|
||||||
|
"@shared": ["shared"]
|
||||||
|
},
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src", "packages/*/src", "types"]
|
"include": ["src", "packages/*/src", "types", "shared"]
|
||||||
}
|
}
|
||||||
|
|||||||
13
types/env.d.ts
vendored
13
types/env.d.ts
vendored
@ -1,3 +1,16 @@
|
|||||||
|
// Type declarations for Vite-style environment variables used via import.meta.env
|
||||||
|
// Placing this under `types/` so the compiler picks it up for all packages.
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_TOKEN_SERVER_URL?: string
|
||||||
|
readonly VITE_STUDIO_URL?: string
|
||||||
|
// add other VITE_ variables here as needed
|
||||||
|
[key: string]: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_TOKEN_SERVER_URL?: string
|
readonly VITE_TOKEN_SERVER_URL?: string
|
||||||
readonly VITE_LIVEKIT_URL?: string
|
readonly VITE_LIVEKIT_URL?: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user