Files
lxc-streamutils/apps/overlay/src/main.tsx
T
2026-07-16 00:12:26 -07:00

169 lines
6.0 KiB
TypeScript

/**
* Browser entry point and intentionally small client-side router.
*
* `/obs/:publicId` is selected before the control application is initialized.
* That early split is a security and reliability boundary: OBS sources never
* register the control-console PWA or execute authenticated dashboard requests.
* All other routes share the session bootstrap and passwordless auth flow.
*/
import { useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
import { EnrollmentPage, LoginPage } from './auth'
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import type { Session } from './types'
import './style.css'
import './control.css'
function Redirect({ to }: { to: string }) {
useEffect(() => {
location.replace(to)
}, [to])
return <main className="route-loading">正在前往云台…</main>
}
function NotFoundPage() {
return (
<main className="auth-page">
<section className="auth-card jade-panel not-found">
<div className="auth-mark" aria-hidden="true">
云
</div>
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
<h1>这里没有组件</h1>
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
<a className="button-link" href="/control/">
返回控制台
</a>
</section>
</main>
)
}
function App() {
const [session, setSession] = useState<Session>()
const [loadError, setLoadError] = useState('')
const [online, setOnline] = useState(navigator.onLine)
const path = location.pathname.replace(/\/+$/, '') || '/'
const refreshSession = useCallback(async () => {
setLoadError('')
try {
const payload = await api<unknown>('/api/v1/auth/me')
setSession(normalizeSession(payload))
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setSession({ user: null, setupRequired: false })
return
}
setLoadError(errorMessage(error, '无法连接认证服务'))
}
}, [])
useEffect(() => {
void refreshSession()
}, [refreshSession])
useEffect(() => {
const wentOnline = () => {
setOnline(true)
if (loadError) void refreshSession()
}
const wentOffline = () => setOnline(false)
window.addEventListener('online', wentOnline)
window.addEventListener('offline', wentOffline)
return () => {
window.removeEventListener('online', wentOnline)
window.removeEventListener('offline', wentOffline)
}
}, [loadError, refreshSession])
useEffect(() => {
const expired = () => {
setSession({ user: null, setupRequired: false })
if (location.pathname.startsWith('/control')) location.assign('/control/login')
}
window.addEventListener('lxc:session-expired', expired)
return () => window.removeEventListener('lxc:session-expired', expired)
}, [])
if (loadError) {
const offline = !online
return (
<main className="auth-page">
<section className="auth-card jade-panel">
<PwaControls />
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
{offline && (
<p className="auth-lead">
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
</p>
)}
<div className="notice error">{loadError}</div>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
重新连接
</button>
</section>
</main>
)
}
if (!session) return <main className="route-loading">正在验证安全会话…</main>
const logout = async () => {
try {
await api('/api/v1/auth/logout', json('POST'))
} finally {
setSession({ user: null, setupRequired: false })
location.assign(authRoute('login'))
}
}
if (path === '/') return <Redirect to={session.user ? '/control/' : '/login'} />
if (path === '/login' || path === '/control/login') {
if (session.user) return <Redirect to="/control/" />
return <LoginPage onAuthenticated={refreshSession} setupRequired={session.setupRequired} />
}
if (path === '/setup' || path === '/control/setup') {
if (session.user) return <Redirect to="/control/" />
if (!session.setupRequired) return <Redirect to={authRoute('login')} />
return <EnrollmentPage mode="setup" onAuthenticated={refreshSession} />
}
if (path === '/register' || path === '/control/register') {
if (session.user) return <Redirect to="/control/" />
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
}
if (path === '/control') {
if (location.pathname === '/control') return <Redirect to="/control/" />
if (!session.user) return <Redirect to="/control/login" />
return <ComponentsPage user={session.user} onLogout={logout} />
}
if (path === '/control/invitations') {
if (!session.user) return <Redirect to="/control/login" />
if (session.user.role !== 'system_admin')
return <ForbiddenPage user={session.user} onLogout={logout} />
return <InvitationsPage user={session.user} onLogout={logout} />
}
return <NotFoundPage />
}
const obsMatch = location.pathname.match(/^\/obs\/([^/]+)\/?$/)
const root = createRoot(document.getElementById('root')!)
if (obsMatch) {
// A short-lived migration only: remove the root-scoped worker from early
// development builds so it cannot keep controlling an OBS browser source.
cleanupLegacyPwa()
let publicId = ''
try {
publicId = decodeURIComponent(obsMatch[1])
} catch {
publicId = ''
}
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
} else {
initializePwa()
root.render(<App />)
}