feat: add PreJoin UI components, styles, platform utils, and build config; update token validation and refactor PreJoin logic

This commit is contained in:
Cesar Mendivil 2025-11-23 21:33:52 -07:00
parent 1e67f1ca36
commit f8516a5330
15 changed files with 333 additions and 201 deletions

View File

@ -0,0 +1,29 @@
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import postcss from 'rollup-plugin-postcss';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true,
},
{
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true,
},
],
plugins: [
peerDepsExternal(),
resolve({ extensions: ['.js', '.ts', '.tsx'] }),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
postcss({ extract: true, minimize: true }),
],
};

View File

@ -8,7 +8,7 @@ export interface ControlBarProps {
export const ControlBar: React.FC<ControlBarProps> = ({ children, className }) => {
return (
<div style={{display:'flex',justifyContent:'center',padding:'8px'}} className={className}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }} className={className}>
<ControlGroup className="controls-inner">
{children}
</ControlGroup>

View File

@ -0,0 +1,30 @@
/* ...existing code... */
.root {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
color: #1f2937;
font-size: 14px;
}
.row { flex-direction: row; }
.column { flex-direction: column; }
.default { background: rgba(0,0,0,0.03); }
.studio { background: #fff; box-shadow: 0 1px 0 rgba(0,0,0,0.02); }
.active { box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
.danger { color: #ef4444; }
.icon { display: inline-flex; align-items: center; justify-content: center; }
.label { display: inline-block; }
.sm { padding: 6px 8px; font-size: 12px; }
.md { padding: 8px 12px; font-size: 14px; }
.lg { padding: 10px 14px; font-size: 16px; }

View File

@ -0,0 +1,50 @@
// ...existing code...
import React from 'react'
import cx from 'clsx'
import styles from './ControlButton.module.css'
export type ControlButtonProps = {
className?: string
icon?: React.ReactNode
label?: string
active?: boolean
danger?: boolean
layout?: 'row' | 'column'
variant?: 'studio' | 'default'
onClick?: () => void
hint?: string
size?: 'sm' | 'md' | 'lg'
}
export const ControlButton: React.FC<ControlButtonProps> = ({
className,
icon,
label,
active = false,
danger = false,
layout = 'row',
variant = 'default',
onClick,
hint,
size = 'md'
}) => {
return (
<button
type="button"
onClick={onClick}
className={cx(styles.root, className, styles[layout], styles[variant], styles[size], {
[styles.active]: active,
[styles.danger]: danger
})}
aria-pressed={active}
title={hint}
>
<span className={styles.icon}>{icon}</span>
{label && <span className={styles.label}>{label}</span>}
</button>
)
}
// default export for convenience
export default ControlButton

View File

@ -0,0 +1,15 @@
// ...existing code...
import React from 'react'
export type MicrophoneMeterProps = { level?: number }
export const MicrophoneMeter: React.FC<MicrophoneMeterProps> = ({ level = 0 }) => {
return (
<div style={{ width: 48, height: 24, background: '#eee', borderRadius: 6 }} aria-hidden>
<div style={{ width: `${Math.min(100, Math.max(0, level * 100))}%`, height: '100%', background: '#10b981', borderRadius: 6 }} />
</div>
)
}
export default MicrophoneMeter

View File

@ -67,10 +67,13 @@ export type { StudioHeaderProps } from './StudioHeader';
export { ControlButton } from './ControlButton';
export type { ControlButtonProps } from './ControlButton';
export { ControlBar } from './ControlBar';
export { ControlGroup } from './ControlGroup';
export { IconButton } from './IconButton';
export { MicrophoneMeter } from './MicrophoneMeter';
export { SceneCard } from './SceneCard';
export type { SceneCardProps } from './SceneCard';
export { VideoTile } from './VideoTile';
export type { VideoTileProps, ConnectionQuality } from './VideoTile';

View File

@ -0,0 +1,3 @@
export * from './components'
export * from './types'
export * from './utils/platform'

View File

@ -0,0 +1,14 @@
/* Placeholder globals used by components during build */
:root {
--au-primary: #4f46e5;
--au-primary-hover: #4338ca;
--au-gray-900: #0f172a;
--au-gray-950: #0b1220;
--au-radius-md: 8px;
--au-font-bold: 700;
--au-text-primary: #f1f5f9;
--au-text-secondary: #cbd5e1;
}
body { font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }

View File

@ -0,0 +1,10 @@
// ...existing code...
export function isMacPlatform() {
if (typeof navigator === 'undefined') return false
return /Mac|iPhone|iPad|iPod/.test(navigator.platform)
}
export function modifierKeyLabel() {
return isMacPlatform() ? { key: 'Meta', display: '⌘' } : { key: 'Control', display: 'Ctrl' }
}

View File

@ -8,10 +8,10 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noEmit": false,
"jsx": "react-jsx",
/* Linting */
@ -26,9 +26,10 @@
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"sourceMap": true,
"outDir": "dist",
"declarationDir": "dist/types"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@ -301,15 +301,15 @@ async function createLivekitTokenFor(room: string, username: string) {
const header = token.split('.')[0];
const padded = header + '='.repeat((4 - (header.length % 4)) % 4);
const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
let h = decoded;
try { h = JSON.parse(decoded); }
catch (e) { /* keep raw */ }
// `decoded` may be a string or JSON; use `any` to avoid strict TS errors when inspecting header fields
let h: any = decoded;
try { h = JSON.parse(decoded); } catch (e) { /* keep raw string if parsing fails */ }
console.log('[LIVEKIT] token header:', h);
// If SDK produced a token with non-HS256 alg, and we have LIVEKIT_API_SECRET, re-sign payload with HS256
try {
const secretForResign = LIVEKIT_API_SECRET || process.env.LIVEKIT_API_SECRET;
if (secretForResign && typeof h === 'object' && h.alg && String(h.alg).toUpperCase() !== 'HS256') {
if (secretForResign && typeof h === 'object' && (h as any).alg && String((h as any).alg).toUpperCase() !== 'HS256') {
try {
const parts = (token as string).split('.');
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
@ -419,8 +419,10 @@ app.all('/api/session/validate', async (req, res) => {
const token = (req.method === 'GET' ? req.query.token : req.body?.token) || req.query.token || req.body?.token;
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' });
const result = await validateTokenWithLiveKit(token);
return res.status(result.status).json({ ok: result.ok, status: result.status, body: result.body });
const result: any = await validateTokenWithLiveKit(token);
// validateTokenWithLiveKit may return { ok: false, error: '...' } without numeric status
const statusCode: number = (result && typeof result.status === 'number') ? result.status : (result && result.ok === false ? 502 : 200);
return res.status(statusCode).json({ ok: !!result.ok, status: result.status ?? statusCode, body: result.body ?? result.error });
} catch (err) {
console.error('[backend-api] validate proxy failed', err);
return res.status(500).json({ error: 'validate_failed', details: String(err) });

View File

@ -1,11 +1,10 @@
/* filepath: /home/xesar/Documentos/Nextream/AvanzaCast/packages/broadcast-panel/src/features/studio/PreJoin.module.css */
:root{
--card-bg: var(--studio-bg-elevated, #ffffff);
--muted: var(--studio-text-secondary, #6b7280);
--accent: var(--studio-accent, #4f46e5);
--badge-bg: rgba(99,102,241,0.9); /* keep template purple */
--danger: var(--studio-danger, #ef4444);
--danger-700: #b91c1c;
--card-bg: #ffffff;
--muted: #666666;
--accent: #6366f1;
--badge-bg: rgba(99,102,241,0.9);
--danger: #dc2626;
}
.prejoinContainer{
@ -29,41 +28,37 @@
.header > div:first-child{
font-size: 28px;
font-weight: 600;
color: var(--studio-text-primary, #1a1a1a);
color: #1a1a1a;
margin-bottom: 8px;
}
.note{
font-size: 14px;
color: var(--studio-text-secondary, #666666);
color: var(--muted);
line-height: 1.5;
}
/* layout: video + mic-status side panel like template */
.contentRow{
display: flex;
gap: 16px;
margin-bottom: 24px;
align-items: flex-start;
}
.previewColumn{
flex: 1;
}
.previewColumn{ flex: 1 }
.previewCard{
border-radius: 12px;
overflow: hidden;
background: var(--studio-bg-tertiary, #0a0a1a);
background: #0a0a1a;
position: relative;
aspect-ratio: 16/9;
}
.videoEl{
width: 100%;
height: 100%;
object-fit: cover;
background: #0b0b0b;
width:100%;
height:100%;
object-fit:cover;
background:#0b0b0b;
}
.badge{
@ -76,184 +71,95 @@
font-size: 14px;
font-weight: 500;
border-radius: 20px;
box-shadow: none;
}
.micPanel{
background-color: var(--studio-bg-secondary, #f8f9fa);
background-color: #f8f9fa;
border-radius: 12px;
padding: 20px;
min-width: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.mic-icon{
width: 48px;
height: 48px;
background-color: #e8e8e8;
border-radius: 50%;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
margin-bottom:12px;
}
.mic-meter{
width: 32px;
height: 80px;
background-color: #e8e8e8;
border-radius: 16px;
margin-bottom: 12px;
position: relative;
overflow: hidden;
.micPanelInner{
display:flex;
flex-direction:column;
align-items:center;
gap:8px;
}
.mic-level{
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 20%;
background: linear-gradient(to top, #22c55e, #86efac);
border-radius: 16px;
transition: height 0.1s ease-out;
}
.mic-icon{ width:48px; height:48px; border-radius:50%; background:#e8e8e8; display:flex; align-items:center; justify-content:center; margin-bottom:12px }
.micStatus{
color: #22c55e;
font-weight: 500;
font-size: 14px;
text-align: center;
margin-bottom: 4px;
}
.mic-meter{ width:32px; height:80px; background:#e8e8e8; border-radius:16px; margin-bottom:12px; position:relative; overflow:hidden }
.mic-level{ position:absolute; bottom:0; left:0; right:0; height:20%; background: linear-gradient(to top, #22c55e, #86efac); border-radius:16px; transition:height 0.1s ease-out }
.mic-device{
font-size: 11px;
color: var(--studio-text-disabled, #999999);
text-align: center;
}
.micStatus{ color: #22c55e; font-weight:500; font-size:14px; text-align:center; margin-bottom:4px }
.mic-device{ font-size:11px; color:#999999; text-align:center }
/* Controls wrapper (segmented) - copiar exactamente del template */
.controlsRow{
display: inline-flex;
justify-content: center;
gap: 8px;
padding: 12px;
background-color: var(--card-bg);
border: 1px solid var(--studio-border, #e5e5e5);
border-radius: 12px;
margin-bottom: 24px;
margin-left: auto;
margin-right: auto;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.controlButtonLocal{
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
background: transparent;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 13px;
transition: all 0.2s;
padding: 12px 20px;
border-radius: 8px;
position: relative;
}
/* hover and active states like template */
.controlButtonLocal:hover{
color: var(--studio-text-primary, #1a1a1a);
background-color: rgba(254,226,226,1); /* template fee2e2 */
}
/* disabled / error */
.controlsRow > button[data-active="false"],
.controlButtonLocal.disabled{
color: var(--danger, #dc2626);
background-color: rgba(254,202,202,1);
}
.controlButtonLocal.disabled:hover,
.controlsRow > button[data-active="false"]:hover{
color: var(--danger-700, #b91c1c);
background-color: rgba(252,165,165,1);
}
.controlButtonLocal > span:first-child{
width:24px;
height:24px;
display:inline-flex;
align-items:center;
justify-content:center;
gap:8px;
padding:12px;
background-color:var(--card-bg);
border:1px solid #e5e5e5;
border-radius:12px;
margin-bottom:24px;
margin-left:auto;
margin-right:auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
}
.controlButtonLocal > span:first-child svg{
width:24px;
height:24px;
}
.controlButtonLocal{ display:flex; flex-direction:column; align-items:center; gap:8px; background:transparent; border:none; cursor:pointer; color:var(--muted); font-size:13px; transition:all .2s; padding:12px 20px; border-radius:8px }
.controlButtonLocal:hover{ color:#1a1a1a; background-color:#fee2e2 }
.controlButtonLocal .controlButtonLabel{
font-size:13px;
margin:0;
}
.controlsRow > button[data-active="false"], .controlButtonLocal.disabled{ color:var(--danger); background-color:#fecaca }
.controlButtonLocal.disabled:hover, .controlsRow > button[data-active="false"]:hover{ color:#b91c1c; background-color:#fca5a5 }
/* hints (tooltip) */
.control-hint{
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: var(--studio-text-primary, #1a1a1a);
color: white;
padding:6px 12px;
border-radius:6px;
font-size:12px;
white-space:nowrap;
opacity:0;
pointer-events:none;
transition: opacity 0.2s;
margin-bottom:8px;
}
.controlButtonLocal > span:first-child{ width:24px; height:24px; display:inline-flex; align-items:center; justify-content:center }
.controlButtonLocal > span:first-child svg{ width:24px; height:24px }
.control-hint{ position:absolute; bottom:100%; left:50%; transform:translateX(-50%); background-color:#1a1a1a; color:white; padding:6px 12px; border-radius:6px; font-size:12px; white-space:nowrap; opacity:0; pointer-events:none; transition:opacity .2s; margin-bottom:8px }
.controlButtonLocal:hover .control-hint{ opacity:1 }
/* Form elements */
.roomTitle{
margin-top: 8px;
margin-bottom: 8px;
font-weight:500;
color: var(--studio-text-primary, #1a1a1a);
}
.roomTitle{ margin-top:8px; margin-bottom:8px; font-weight:500; color:#1a1a1a }
.input{ width:100%; padding:12px 16px; border-radius:8px; border:1px solid #d1d5db; font-size:14px; margin-bottom:16px }
.input{
width:100%;
padding:12px 16px;
.shortcutsLegend{ text-align:center; margin-top:12px; color:var(--muted) }
.kbd{ background-color:#374151; padding:2px 6px; border-radius:3px; font-family:monospace; font-size:11px; color:#fff }
.checkboxRow{ margin-top:12px; margin-bottom:12px; display:flex; align-items:center; gap:8px }
.actions{ display:flex; gap:12px; margin-top:16px }
.cancelBtn{ background:transparent; border:1px solid #e5e7eb; padding:10px 14px; border-radius:8px; cursor:pointer }
.primaryBtn{ background:#2563eb; color:#fff; border:none; padding:12px 18px; border-radius:8px; cursor:pointer }
.primaryBtn:disabled{ opacity:0.7; cursor:not-allowed }
/* small error box */
.error{
background:#fff5f5;
border:1px solid #fecaca;
color:#b91c1c;
padding:10px 12px;
border-radius:8px;
border:1px solid var(--studio-border, #d1d5db);
font-size:14px;
margin-bottom:16px;
margin-bottom:12px;
font-size:13px;
}
.input:focus{ outline:none; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); border-color:var(--studio-accent, #3b82f6) }
.actions{ display:flex; gap:12px; margin-top:8px }
.cancelBtn{ background:transparent; border: none; padding:10px 14px; border-radius:8px }
.primaryBtn{ background:var(--studio-accent, #2563eb); color:#fff; padding:12px 18px; border-radius:8px; border:none }
.shortcutsLegend{ text-align:center; color: var(--studio-text-muted, #9ca3af); margin-top: 8px }
.kbd{ background-color: #374151; padding: 2px 6px; border-radius:3px; font-family: monospace; font-size:11px; margin: 0 2px }
@media (max-width: 768px){
.contentRow{ flex-direction: column; }
.controlsRow{ display:flex; flex-direction: column; gap:8px; padding:0; background:transparent; box-shadow:none; border:none }
.controlButtonLocal{ width:100%; padding:12px 16px }
.previewCard{ aspect-ratio: 16/9; }
.micPanel{ min-width: auto; width: 100%; }
/* Side column (form & actions) */
.sideColumn{
width: 320px;
display: flex;
flex-direction: column;
}
/* ensure controls row centers on small screens */
@media (max-width:800px){
.contentRow{ flex-direction:column }
.micPanel{ min-width:unset; width:100% }
.sideColumn{ width:100% }
.controlsRow{ width:100%; justify-content:space-around }
}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import styles from './PreJoin.module.css'
import { ControlBar, ControlButton, MicrophoneMeter, modifierKeyLabel, isMacPlatform } from 'avanza-ui'
import { ControlButton, MicrophoneMeter, modifierKeyLabel, isMacPlatform } from 'avanza-ui'
import { FiMic, FiVideo, FiSettings } from 'react-icons/fi'
type Props = {
@ -11,7 +11,7 @@ type Props = {
token?: string
}
export default function PreJoin({ roomName, onProceed, onCancel }: Props) {
export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Props) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [name, setName] = useState(() => {
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
@ -42,7 +42,10 @@ export default function PreJoin({ roomName, onProceed, onCancel }: Props) {
if (!navigator?.mediaDevices?.getUserMedia) return
localStream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: camEnabled })
if (!mounted) {
try { localStream.getTracks().forEach(t => t.stop()) } catch (e) {}
try {
const tracks = localStream?.getTracks?.()
if (tracks && tracks.length) tracks.forEach(t => t.stop())
} catch (e) {}
return
}
setPreviewStream(localStream)
@ -87,7 +90,10 @@ export default function PreJoin({ roomName, onProceed, onCancel }: Props) {
return () => {
mounted = false
if (localStream) {
try { localStream.getTracks().forEach(t => t.stop()) } catch (e) {}
try {
const tracks = localStream?.getTracks?.()
if (tracks && tracks.length) tracks.forEach(t => t.stop())
} catch (e) {}
}
// clear previewStream state
setPreviewStream(null)

View File

@ -0,0 +1,20 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import PreJoin from '../../../../packages/broadcast-panel/src/features/studio/PreJoin';
const meta: Meta<typeof PreJoin> = {
title: 'Broadcast/PreJoin',
component: PreJoin,
};
export default meta;
type Story = StoryObj<typeof PreJoin>;
export const Default: Story = {
args: {
roomName: 'Sala de prueba',
onProceed: () => alert('Proceed clicked'),
onCancel: () => alert('Cancel clicked'),
},
};

43
scripts/tsc-runner.js Normal file
View File

@ -0,0 +1,43 @@
const ts = require('typescript');
const path = require('path');
const fs = require('fs');
function report(d) {
const file = d.file ? path.relative(process.cwd(), d.file.fileName) : null;
const msg = ts.flattenDiagnosticMessageText(d.messageText, '\n');
const line = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : null;
const col = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).character + 1 : null;
console.log([file ? file : '', line ? ':' + line : '', col ? ':' + col : '', '-', msg].join(''));
}
async function run(tsconfigPath) {
const abs = path.resolve(tsconfigPath);
if (!fs.existsSync(abs)) {
console.error('tsconfig not found:', abs);
process.exit(2);
}
const configFile = ts.readConfigFile(abs, ts.sys.readFile);
if (configFile.error) {
report(configFile.error);
process.exit(1);
}
const parseConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(abs));
if (parseConfig.errors && parseConfig.errors.length) {
parseConfig.errors.forEach(report);
process.exit(1);
}
const program = ts.createProgram(parseConfig.fileNames, parseConfig.options);
const emitResult = program.emit();
const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
if (allDiagnostics.length === 0) {
console.log('TSC: no errors');
process.exit(0);
}
allDiagnostics.forEach(report);
console.log('TSC: errors=', allDiagnostics.length);
process.exit(1);
}
const arg = process.argv[2] || 'packages/backend-api/tsconfig.json';
run(arg).catch(e => { console.error('runner failed', e); process.exit(3); });