formatting and comments

This commit is contained in:
2026-07-16 00:12:26 -07:00
parent edb6d2b5b4
commit 994854d104
45 changed files with 2514 additions and 628 deletions
+120 -71
View File
@@ -1,3 +1,12 @@
/**
* Same-origin API client and defensive wire-format normalizers.
*
* The backend is authoritative and may evolve response wrappers independently
* from a deployed frontend. Normalizers accept those compatible wrappers while
* producing strict UI models. Mutations are refused while offline and are never
* queued for background replay, which avoids applying an old user's action
* after logout or tenant switching.
*/
import type {
AuthUser,
ComponentSummary,
@@ -13,7 +22,12 @@ export class ApiError extends Error {
readonly code?: string
readonly fieldErrors?: Record<string, string>
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
constructor(
status: number,
message: string,
code?: string,
fieldErrors?: Record<string, string>,
) {
super(message)
this.name = 'ApiError'
this.status = status
@@ -36,7 +50,8 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
}
const headers = new Headers(init.headers)
if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json')
if (init.body != null && !headers.has('content-type'))
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json')
const response = await fetch(path, {
@@ -49,13 +64,13 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
window.dispatchEvent(new Event('lxc:session-expired'))
}
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
throw new ApiError(
response.status,
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
typeof error.code === 'string' ? error.code : undefined,
error.fieldErrors && typeof error.fieldErrors === 'object'
? error.fieldErrors as Record<string, string>
? (error.fieldErrors as Record<string, string>)
: undefined,
)
}
@@ -71,24 +86,29 @@ export function json(method: string, body?: unknown): RequestInit {
function object(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
? (value as Record<string, unknown>)
: {}
}
export function normalizeSession(value: unknown): Session {
const root = object(value)
const candidate = root.user
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
const candidate =
root.user ??
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
? root
: null)
const raw = object(candidate)
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
const user: AuthUser | null = hasUser ? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
} : null
const user: AuthUser | null = hasUser
? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
}
: null
return { user, setupRequired: root.setupRequired === true }
}
@@ -98,19 +118,26 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
return {
enrollmentToken: String(enrollmentToken ?? ''),
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
qrDataUrl: typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
qrSvg:
typeof totp.qrSvg === 'string'
? totp.qrSvg
: typeof root.qrSvg === 'string'
? root.qrSvg
: undefined,
qrDataUrl:
typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
: undefined,
otpauthUri:
typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
otpauthUri: typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
}
@@ -119,24 +146,28 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
export function normalizeRecoveryCodes(value: unknown): string[] {
const root = object(value)
const codes = root.recoveryCodes ?? object(root.recovery).codes
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : []
return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === 'string')
: []
}
export function normalizeComponents(value: unknown): ComponentSummary[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
}).filter(item => item.id)
return list
.map(entry => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
})
.filter(item => item.id)
}
export function normalizeSettings(value: unknown): OverlaySettings {
@@ -147,50 +178,68 @@ export function normalizeSettings(value: unknown): OverlaySettings {
export function normalizeSource(value: unknown): CookieCloudSource {
const root = object(value)
const source = object(root.source ?? root)
const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud)
const cookieCloud = object(
source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud,
)
const status = object(source.status)
return {
roomId: String(source.roomId ?? root.roomId ?? ''),
cookieCloud: {
host: String(cookieCloud.host ?? ''),
key: '',
keyConfigured: cookieCloud.keyConfigured === true
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured: cookieCloud.passwordConfigured === true
|| cookieCloud.configured === true
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
keyConfigured:
cookieCloud.keyConfigured === true ||
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured:
cookieCloud.passwordConfigured === true ||
cookieCloud.configured === true ||
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
},
connected: typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail: typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
connected:
typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail:
typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
}
}
export function normalizeInvitations(value: unknown): Invitation[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined,
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined,
}
}).filter(item => item.id)
const list = Array.isArray(value)
? value
: Array.isArray(root.invitations)
? root.invitations
: []
return list
.map(entry => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt:
typeof item.consumedAt === 'string' || item.consumedAt === null
? item.consumedAt
: undefined,
revokedAt:
typeof item.revokedAt === 'string' || item.revokedAt === null
? item.revokedAt
: undefined,
}
})
.filter(item => item.id)
}
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
+113 -37
View File
@@ -1,3 +1,11 @@
/**
* Passwordless login and one-time TOTP enrollment screens.
*
* QR material, manual keys and recovery codes exist only in React memory and
* are dropped as soon as their enrollment phase completes. PWA activation is
* blocked while those values or partially completed forms are visible so an
* update cannot erase information that the server will not reveal again.
*/
import { useMemo, useState } from 'react'
import type { FormEvent, ReactNode } from 'react'
import {
@@ -11,7 +19,12 @@ import {
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
import type { TotpEnrollment } from './types'
function AuthShell({ eyebrow, title, children, footer }: {
function AuthShell({
eyebrow,
title,
children,
footer,
}: {
eyebrow: string
title: string
children: ReactNode
@@ -21,7 +34,9 @@ function AuthShell({ eyebrow, title, children, footer }: {
<main className="auth-page">
<section className="auth-card jade-panel">
<PwaControls />
<div className="auth-mark" aria-hidden="true">星</div>
<div className="auth-mark" aria-hidden="true">
星
</div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{children}
@@ -34,16 +49,19 @@ function AuthShell({ eyebrow, title, children, footer }: {
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
if (enrollment.qrSvg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
return undefined
}, [enrollment.qrDataUrl, enrollment.qrSvg])
return (
<div className="totp-enrollment">
<div className="totp-qr">
{source
? <img src={source} alt="TOTP 验证器绑定二维码" />
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
{source ? (
<img src={source} alt="TOTP 验证器绑定二维码" />
) : (
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
)}
</div>
<div className="totp-copy">
<h2>绑定动态验证器</h2>
@@ -57,7 +75,11 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
<div className="secret-row">
<code>{enrollment.manualKey || '未提供'}</code>
{enrollment.manualKey && (
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
<button
type="button"
className="text-button"
onClick={() => void copyToClipboard(enrollment.manualKey)}
>
复制
</button>
)}
@@ -72,12 +94,10 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
const [copied, setCopied] = useState(false)
const text = codes.join('\n')
const download = () => {
const blob = new Blob([
'洛星瓷直播组件恢复码\n',
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
text,
'\n',
], { type: 'text/plain;charset=utf-8' })
const blob = new Blob(
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
{ type: 'text/plain;charset=utf-8' },
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
@@ -88,26 +108,45 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
return (
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
{codes.length > 0
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
<p className="auth-lead">
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
</p>
{codes.length > 0 ? (
<div className="recovery-grid">
{codes.map(code => (
<code key={code}>{code}</code>
))}
</div>
) : (
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
)}
<div className="form-actions">
{codes.length > 0 && (
<>
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
<button
type="button"
className="secondary"
onClick={() => void copyToClipboard(text).then(setCopied)}
>
{copied ? '已复制' : '复制全部'}
</button>
<button type="button" className="secondary" onClick={download}>下载文本</button>
<button type="button" className="secondary" onClick={download}>
下载文本
</button>
</>
)}
<button type="button" onClick={onContinue}>我已妥善保存</button>
<button type="button" onClick={onContinue}>
我已妥善保存
</button>
</div>
</AuthShell>
)
}
export function LoginPage({ onAuthenticated, setupRequired }: {
export function LoginPage({
onAuthenticated,
setupRequired,
}: {
onAuthenticated: () => Promise<void>
setupRequired: boolean
}) {
@@ -142,13 +181,19 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
<AuthShell
eyebrow="洛星瓷直播组件"
title="回到你的云台"
footer={(
footer={
<p>
{setupRequired
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
{setupRequired ? (
<>
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
</>
) : (
<>
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
</>
)}
</p>
)}
}
>
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
<form className="stack-form" onSubmit={submit}>
@@ -173,9 +218,13 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
value={totpCode}
onChange={event => setTotpCode(useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6))}
onChange={event =>
setTotpCode(
useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6),
)
}
/>
</label>
<button
@@ -188,14 +237,21 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
>
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
</button>
{error && <div className="notice error" role="alert">{error}</div>}
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
</form>
</AuthShell>
)
}
export function EnrollmentPage({ mode, onAuthenticated }: {
export function EnrollmentPage({
mode,
onAuthenticated,
}: {
mode: 'setup' | 'register'
onAuthenticated: () => Promise<void>
}) {
@@ -214,7 +270,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
enrollment || recoveryCodes
? '完成 TOTP 绑定并保存一次性恢复码'
: '完成或清空正在填写的注册表单',
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
busy ||
Boolean(
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
),
)
const start = async (event: FormEvent) => {
@@ -276,7 +335,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
if (enrollment) {
return (
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
<AuthShell
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
title="强制绑定 TOTP"
>
<TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}>
<label>
@@ -294,8 +356,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</label>
{error && <div className="notice error" role="alert">{error}</div>}
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy || totpCode.length !== 6}>
{busy ? '正在确认…' : '确认绑定并创建账户'}
</button>
</form>
</AuthShell>
)
@@ -305,7 +373,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
<AuthShell
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
title={isSetup ? '创建系统管理员' : '创建你的账户'}
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
footer={
<p>
已有账户?<a href={authRoute('login')}>返回登录</a>
</p>
}
>
<p className="auth-lead">
{isSetup
@@ -350,7 +422,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
</label>
)}
{error && <div className="notice error" role="alert">{error}</div>}
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
</form>
</AuthShell>
+299 -179
View File
@@ -7,7 +7,9 @@ html,
body,
#root,
.copy {
font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif;
font-family:
'Noto Serif SC', 'Microsoft YaHei', 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji',
serif;
}
.wall {
@@ -19,19 +21,33 @@ body,
}
.card-decor {
--decor-primary-image: url("/assets/floral-divider.svg");
--decor-primary-image: url('/assets/floral-divider.svg');
--decor-primary-position: center 44%;
--decor-primary-size: 92% auto;
--decor-primary-transform: none;
--decor-primary-opacity: .11;
--decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%);
--decor-secondary-image: url("/assets/floral-cluster.svg");
--decor-primary-opacity: 0.11;
--decor-primary-color: linear-gradient(
90deg,
#e7d0f4 4%,
#75e6cc 35%,
#d5fff1 50%,
#6adcc6 68%,
#f4bfd4 96%
);
--decor-secondary-image: url('/assets/floral-cluster.svg');
--decor-secondary-position: right -10px bottom -22px;
--decor-secondary-size: 34% auto;
--decor-secondary-transform: none;
--decor-secondary-opacity: .065;
--decor-secondary-opacity: 0.065;
--decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc);
--decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04));
--decor-surface:
radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, 0.09), transparent 58%),
linear-gradient(
90deg,
rgba(230, 203, 241, 0.04),
transparent 18% 82%,
rgba(244, 190, 214, 0.04)
);
position: absolute;
inset: 0;
z-index: 0;
@@ -43,7 +59,7 @@ body,
.card-decor::before,
.card-decor::after {
content: "";
content: '';
position: absolute;
inset: 0;
z-index: 1;
@@ -59,7 +75,7 @@ body,
opacity: var(--decor-primary-opacity);
transform: var(--decor-primary-transform);
transform-origin: center;
filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28));
filter: drop-shadow(0 0 6px rgba(105, 255, 224, 0.28));
}
.card-decor::after {
@@ -78,12 +94,12 @@ body,
position: absolute;
inset: 2px;
z-index: 0;
border: 1px solid rgba(157, 255, 232, .14);
border: 1px solid rgba(157, 255, 232, 0.14);
border-radius: inherit;
background: var(--decor-surface);
box-shadow:
inset 0 1px rgba(225, 255, 247, .1),
inset 0 -1px rgba(82, 224, 198, .08);
inset 0 1px rgba(225, 255, 247, 0.1),
inset 0 -1px rgba(82, 224, 198, 0.08);
}
.decor-v1 {
@@ -94,67 +110,82 @@ body,
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 31% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .075;
--decor-secondary-opacity: 0.075;
--decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5);
--decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04));
--decor-surface:
radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, 0.08), transparent 56%),
linear-gradient(
90deg,
rgba(112, 232, 208, 0.045),
transparent 34% 76%,
rgba(236, 200, 243, 0.04)
);
}
.decor-v2 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: left -12px bottom -13px;
--decor-primary-size: 73% auto;
--decor-primary-opacity: .105;
--decor-primary-opacity: 0.105;
--decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-position: right -22px top -18px;
--decor-secondary-size: 62% auto;
--decor-secondary-opacity: .055;
--decor-secondary-opacity: 0.055;
--decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd);
--decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%);
--decor-surface:
radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, 0.09), transparent 54%),
linear-gradient(105deg, rgba(217, 198, 241, 0.045), transparent 58%);
}
.decor-v3 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: left -15px bottom -15px;
--decor-primary-size: 77% auto;
--decor-primary-transform: scaleX(-1);
--decor-primary-opacity: .115;
--decor-primary-opacity: 0.115;
--decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0);
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 32% auto;
--decor-secondary-opacity: .07;
--decor-secondary-opacity: 0.07;
--decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad);
--decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%);
--decor-surface:
radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, 0.075), transparent 54%),
linear-gradient(270deg, rgba(103, 228, 204, 0.05), transparent 64%);
}
.decor-v4 {
--decor-primary-image: url("/assets/floral-cluster.svg");
--decor-primary-image: url('/assets/floral-cluster.svg');
--decor-primary-position: left -18px bottom -28px;
--decor-primary-size: 45% auto;
--decor-primary-transform: rotate(-5deg);
--decor-primary-opacity: .09;
--decor-primary-opacity: 0.09;
--decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4);
--decor-secondary-position: left -15px bottom -25px;
--decor-secondary-size: 37% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .065;
--decor-secondary-opacity: 0.065;
--decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6);
--decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%);
--decor-surface:
radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, 0.085), transparent 47%),
radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, 0.06), transparent 44%);
}
.decor-v5 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: center top -17px;
--decor-primary-size: 90% auto;
--decor-primary-transform: scaleY(-1);
--decor-primary-opacity: .095;
--decor-primary-opacity: 0.095;
--decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-position: left 18% top -15px;
--decor-secondary-size: 55% auto;
--decor-secondary-opacity: .06;
--decor-secondary-opacity: 0.06;
--decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0);
--decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035));
--decor-surface:
radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, 0.065), transparent 56%),
linear-gradient(90deg, rgba(232, 199, 239, 0.04), transparent 52%, rgba(255, 229, 168, 0.035));
}
.card::before {
@@ -173,8 +204,8 @@ body,
position: absolute;
inset: 0;
z-index: 2;
opacity: .5;
transition: opacity .3s ease;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.decor-v1 .card-particle-layer,
@@ -208,7 +239,7 @@ body,
.card-particle.star {
background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9));
filter: drop-shadow(0 0 4px rgba(190, 255, 240, 0.9));
}
.card-particle.floret {
@@ -219,30 +250,99 @@ body,
radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72));
filter: drop-shadow(0 0 4px rgba(255, 202, 226, 0.72));
}
.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; }
.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; }
.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; }
.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; }
.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; }
.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; }
.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; }
.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; }
.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; }
.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; }
.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; }
.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; }
.card-particle:nth-child(1) {
left: 2%;
top: 14%;
animation-delay: -0.35s;
}
.card-particle:nth-child(2) {
right: 3%;
top: 16%;
--particle-size: 10px;
animation-delay: -1.15s;
}
.card-particle:nth-child(3) {
left: 4%;
bottom: 13%;
--particle-size: 7px;
animation-delay: -2.4s;
}
.card-particle:nth-child(4) {
right: 2%;
bottom: 15%;
--particle-size: 8px;
animation-delay: -0.75s;
}
.card-particle:nth-child(5) {
left: 22%;
top: 5%;
--particle-size: 9px;
animation-delay: -3.15s;
}
.card-particle:nth-child(6) {
right: 24%;
top: 8%;
--particle-size: 6px;
animation-delay: -1.65s;
}
.card-particle:nth-child(7) {
left: 47%;
bottom: 4%;
--particle-size: 8px;
animation-delay: -2.75s;
}
.card-particle:nth-child(8) {
right: 43%;
top: 4%;
--particle-size: 7px;
animation-delay: -0.15s;
}
.card-particle:nth-child(9) {
left: 34%;
top: 46%;
--particle-size: 6px;
animation-delay: -1.95s;
}
.card-particle:nth-child(10) {
right: 32%;
bottom: 30%;
--particle-size: 9px;
animation-delay: -3.55s;
}
.card-particle:nth-child(11) {
left: 12%;
top: 48%;
--particle-size: 7px;
animation-delay: -0.95s;
}
.card-particle:nth-child(12) {
right: 13%;
top: 52%;
--particle-size: 6px;
animation-delay: -2.2s;
}
@keyframes card-sparkle {
0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); }
38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); }
68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); }
0%,
100% {
opacity: 0.04;
transform: translate3d(0, 4px, 0) rotate(0) scale(0.45);
}
38% {
opacity: 0.82;
transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08);
}
68% {
opacity: 0.22;
transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(0.7);
}
}
.narrow .card-particle-layer > :nth-child(n+9),
.short .card-particle-layer > :nth-child(n+7) {
.narrow .card-particle-layer > :nth-child(n + 9),
.short .card-particle-layer > :nth-child(n + 7) {
display: none;
}
@@ -279,9 +379,9 @@ body,
width: auto;
height: 1.4em;
max-width: 6em;
margin-inline: .08em;
margin-inline: 0.08em;
object-fit: contain;
vertical-align: -.32em;
vertical-align: -0.32em;
opacity: 1;
filter: none;
}
@@ -292,7 +392,7 @@ body,
height: auto;
max-width: 100%;
max-height: 4.8em;
margin: .12em 0;
margin: 0.12em 0;
vertical-align: top;
}
@@ -300,8 +400,8 @@ body,
display: inline-block;
max-width: 5em;
max-height: 2.2em;
margin: 0 .1em;
vertical-align: -.55em;
margin: 0 0.1em;
vertical-align: -0.55em;
}
.copy b,
@@ -316,46 +416,52 @@ body,
.card.danmaku {
isolation: isolate;
transform-origin: center center;
transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease;
transition:
min-height 0.24s ease,
padding 0.24s ease,
border-radius 0.24s ease,
background 0.24s ease;
}
.card.danmaku.expanded {
min-height: 92px;
padding: 14px;
border-color: rgba(133, 255, 232, .58);
border-color: rgba(133, 255, 232, 0.58);
background:
linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)),
linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78));
linear-gradient(90deg, rgba(91, 224, 199, 0.13), transparent 14% 86%, rgba(91, 224, 199, 0.13)),
linear-gradient(115deg, rgba(4, 43, 59, 0.94), rgba(9, 82, 81, 0.78));
box-shadow:
inset 12px 0 18px -15px rgba(142, 255, 230, .95),
inset -12px 0 18px -15px rgba(142, 255, 230, .95),
0 10px 28px rgba(0, 15, 25, .32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
inset 12px 0 18px -15px rgba(142, 255, 230, 0.95),
inset -12px 0 18px -15px rgba(142, 255, 230, 0.95),
0 10px 28px rgba(0, 15, 25, 0.32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
}
.card.danmaku.expanded::after {
content: "";
content: '';
position: absolute;
inset-block: 4px;
inset-inline: -1px;
z-index: 2;
pointer-events: none;
border-inline: 4px solid rgba(123, 238, 214, .72);
border-inline: 4px solid rgba(123, 238, 214, 0.72);
border-radius: 11px;
background:
linear-gradient(90deg,
rgba(201, 255, 240, .32) 0,
rgba(63, 180, 165, .26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, .26) calc(100% - 5px),
rgba(201, 255, 240, .32) 100%);
background: linear-gradient(
90deg,
rgba(201, 255, 240, 0.32) 0,
rgba(63, 180, 165, 0.26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, 0.26) calc(100% - 5px),
rgba(201, 255, 240, 0.32) 100%
);
box-shadow:
inset 6px 0 7px -7px rgba(216, 255, 246, .72),
inset -6px 0 7px -7px rgba(216, 255, 246, .72),
-2px 0 0 rgba(7, 46, 53, .72),
2px 0 0 rgba(7, 46, 53, .72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
inset 6px 0 7px -7px rgba(216, 255, 246, 0.72),
inset -6px 0 7px -7px rgba(216, 255, 246, 0.72),
-2px 0 0 rgba(7, 46, 53, 0.72),
2px 0 0 rgba(7, 46, 53, 0.72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95)
both;
}
.card.danmaku.expanded .copy {
@@ -370,8 +476,8 @@ body,
.card.danmaku.expanded .copy b {
color: #f0fffb;
font-size: .78em;
letter-spacing: .06em;
font-size: 0.78em;
letter-spacing: 0.06em;
}
.card.danmaku.expanded .copy span {
@@ -386,7 +492,7 @@ body,
min-height: 38px;
padding: 5px 10px;
border-radius: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48));
background: linear-gradient(115deg, rgba(4, 34, 49, 0.72), rgba(8, 69, 72, 0.48));
}
.card.danmaku.compact .copy {
@@ -413,7 +519,7 @@ body,
white-space: normal;
}
.short .cards .card:nth-child(n+4) {
.short .cards .card:nth-child(n + 4) {
display: flex;
}
@@ -428,7 +534,7 @@ body,
@keyframes scroll-rails-open {
from {
transform: scaleX(.025);
transform: scaleX(0.025);
}
to {
transform: scaleX(1);
@@ -436,11 +542,17 @@ body,
}
@keyframes arrive {
from { transform: translateX(38px) scale(.96); }
to { transform: none; }
from {
transform: translateX(38px) scale(0.96);
}
to {
transform: none;
}
}
.overlay.narrow { padding: 6px; }
.overlay.narrow {
padding: 6px;
}
.narrow .card.danmaku.expanded {
min-height: 82px;
@@ -478,11 +590,11 @@ body,
.preset-buttons small {
font-weight: normal;
opacity: .72;
opacity: 0.72;
}
.preset-buttons .active {
box-shadow: 0 0 0 2px rgba(137, 255, 226, .28);
box-shadow: 0 0 0 2px rgba(137, 255, 226, 0.28);
}
.control .obs-address {
@@ -503,7 +615,7 @@ body,
max-height: 75vh;
overflow: auto;
margin-top: 14px;
border: 1px solid rgba(89, 183, 173, .25);
border: 1px solid rgba(89, 183, 173, 0.25);
background: #04131b;
}
@@ -515,7 +627,9 @@ body,
}
@media (max-width: 720px) {
.preview-viewport { max-height: 560px; }
.preview-viewport {
max-height: 560px;
}
}
/* Multi-user application shell ------------------------------------------------ */
@@ -523,10 +637,10 @@ body,
:root {
color-scheme: dark;
--app-bg: #041219;
--app-panel: rgba(7, 32, 42, .88);
--app-panel-strong: rgba(7, 39, 48, .96);
--app-line: rgba(110, 224, 205, .22);
--app-line-strong: rgba(117, 244, 218, .48);
--app-panel: rgba(7, 32, 42, 0.88);
--app-panel-strong: rgba(7, 39, 48, 0.96);
--app-line: rgba(110, 224, 205, 0.22);
--app-line-strong: rgba(117, 244, 218, 0.48);
--app-text: #dcfff7;
--app-muted: #83b8b0;
--app-accent: #67e6c5;
@@ -544,20 +658,20 @@ select {
position: relative;
border: 1px solid var(--app-line);
background:
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, .08), transparent 48%),
linear-gradient(135deg, rgba(8, 39, 50, .94), rgba(5, 25, 35, .91));
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, 0.08), transparent 48%),
linear-gradient(135deg, rgba(8, 39, 50, 0.94), rgba(5, 25, 35, 0.91));
box-shadow:
inset 0 1px rgba(210, 255, 245, .055),
0 18px 48px rgba(0, 8, 15, .22);
inset 0 1px rgba(210, 255, 245, 0.055),
0 18px 48px rgba(0, 8, 15, 0.22);
backdrop-filter: blur(18px);
}
.jade-panel::after {
content: "";
content: '';
position: absolute;
inset: 5px;
z-index: 0;
border: 1px solid rgba(122, 237, 216, .06);
border: 1px solid rgba(122, 237, 216, 0.06);
border-radius: inherit;
pointer-events: none;
}
@@ -573,7 +687,7 @@ select {
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .19em;
letter-spacing: 0.19em;
text-transform: uppercase;
}
@@ -585,15 +699,15 @@ select {
place-items: center;
overflow: auto;
background:
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, .2), transparent 30%),
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, .18), transparent 33%),
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, 0.2), transparent 30%),
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, 0.18), transparent 33%),
linear-gradient(145deg, #04131b, #082b31 55%, #041119);
color: var(--app-text);
}
.route-loading {
color: #93cfc4;
letter-spacing: .12em;
letter-spacing: 0.12em;
}
.auth-card {
@@ -607,13 +721,13 @@ select {
color: #dffff7;
font-size: clamp(28px, 5vw, 44px);
font-weight: 600;
letter-spacing: .04em;
letter-spacing: 0.04em;
}
.auth-card footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(109, 212, 196, .14);
border-top: 1px solid rgba(109, 212, 196, 0.14);
}
.auth-card footer p {
@@ -634,11 +748,11 @@ select {
height: 54px;
display: grid;
place-items: center;
border: 1px solid rgba(124, 239, 216, .32);
border: 1px solid rgba(124, 239, 216, 0.32);
border-radius: 50%;
color: rgba(190, 255, 241, .8);
background: radial-gradient(circle, rgba(62, 173, 155, .23), transparent 72%);
box-shadow: 0 0 30px rgba(80, 216, 191, .14);
color: rgba(190, 255, 241, 0.8);
background: radial-gradient(circle, rgba(62, 173, 155, 0.23), transparent 72%);
box-shadow: 0 0 30px rgba(80, 216, 191, 0.14);
}
.auth-lead {
@@ -676,12 +790,14 @@ select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid rgba(75, 157, 151, .55);
border: 1px solid rgba(75, 157, 151, 0.55);
border-radius: 10px;
outline: none;
color: #edfffb;
background: rgba(3, 18, 26, .82);
transition: border-color .18s ease, box-shadow .18s ease;
background: rgba(3, 18, 26, 0.82);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.stack-form input:focus,
@@ -690,14 +806,14 @@ select {
.secret-address input:focus,
.one-time-secret input:focus {
border-color: #6ee8ce;
box-shadow: 0 0 0 3px rgba(92, 225, 198, .12);
box-shadow: 0 0 0 3px rgba(92, 225, 198, 0.12);
}
.otp-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
font-size: 22px !important;
font-weight: 700;
letter-spacing: .35em;
letter-spacing: 0.35em;
text-align: center;
}
@@ -710,12 +826,15 @@ select {
border-radius: 9px;
color: #05241f;
background: linear-gradient(135deg, #86f2d8, #50cdb2);
box-shadow: 0 5px 18px rgba(40, 168, 145, .16);
box-shadow: 0 5px 18px rgba(40, 168, 145, 0.16);
font-weight: 700;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: filter .16s ease, transform .16s ease, border-color .16s ease;
transition:
filter 0.16s ease,
transform 0.16s ease,
border-color 0.16s ease;
}
.auth-page button:hover:not(:disabled),
@@ -728,23 +847,23 @@ select {
.auth-page button:disabled,
.dashboard-shell button:disabled {
cursor: wait;
filter: saturate(.45);
opacity: .65;
filter: saturate(0.45);
opacity: 0.65;
}
.auth-page button.secondary,
.dashboard-shell button.secondary,
.dashboard-shell .ghost-button {
border-color: rgba(91, 189, 176, .26);
border-color: rgba(91, 189, 176, 0.26);
color: #cffff4;
background: rgba(16, 67, 76, .62);
background: rgba(16, 67, 76, 0.62);
box-shadow: none;
}
.dashboard-shell button.danger {
border-color: rgba(255, 138, 156, .3);
border-color: rgba(255, 138, 156, 0.3);
color: #ffd7dd;
background: rgba(102, 32, 48, .58);
background: rgba(102, 32, 48, 0.58);
box-shadow: none;
}
@@ -773,32 +892,32 @@ select {
.recovery-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
letter-spacing: .08em;
letter-spacing: 0.08em;
}
.notice {
padding: 11px 13px;
border: 1px solid rgba(121, 222, 205, .24);
border: 1px solid rgba(121, 222, 205, 0.24);
border-radius: 10px;
color: #c9f8ee;
background: rgba(17, 73, 76, .42);
background: rgba(17, 73, 76, 0.42);
line-height: 1.45;
}
.notice.error {
border-color: rgba(255, 133, 151, .36);
border-color: rgba(255, 133, 151, 0.36);
color: #ffd6dc;
background: rgba(103, 31, 47, .45);
background: rgba(103, 31, 47, 0.45);
}
.notice.warning {
border-color: rgba(255, 213, 127, .34);
border-color: rgba(255, 213, 127, 0.34);
color: #ffe4aa;
background: rgba(93, 67, 24, .4);
background: rgba(93, 67, 24, 0.4);
}
.notice.success {
border-color: rgba(105, 239, 196, .34);
border-color: rgba(105, 239, 196, 0.34);
color: #baffea;
}
@@ -849,10 +968,10 @@ select {
min-width: 0;
padding: 8px 10px;
overflow-wrap: anywhere;
border: 1px solid rgba(106, 216, 199, .2);
border: 1px solid rgba(106, 216, 199, 0.2);
border-radius: 8px;
color: #e8fff9;
background: rgba(0, 12, 19, .66);
background: rgba(0, 12, 19, 0.66);
}
.compact-form {
@@ -869,12 +988,12 @@ select {
.recovery-grid code {
padding: 11px;
border: 1px solid rgba(104, 219, 200, .2);
border: 1px solid rgba(104, 219, 200, 0.2);
border-radius: 8px;
color: #e8fff9;
background: rgba(0, 13, 20, .7);
background: rgba(0, 13, 20, 0.7);
text-align: center;
letter-spacing: .08em;
letter-spacing: 0.08em;
}
.form-actions {
@@ -899,9 +1018,8 @@ select {
overflow: auto;
color: var(--app-text);
background:
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, .17), transparent 28%),
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, .12), transparent 32%),
var(--app-bg);
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, 0.17), transparent 28%),
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, 0.12), transparent 32%), var(--app-bg);
}
.dashboard-topbar {
@@ -914,8 +1032,8 @@ select {
grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr);
gap: 20px;
align-items: center;
border-bottom: 1px solid rgba(84, 178, 166, .16);
background: rgba(3, 18, 26, .84);
border-bottom: 1px solid rgba(84, 178, 166, 0.16);
background: rgba(3, 18, 26, 0.84);
backdrop-filter: blur(18px);
}
@@ -933,9 +1051,9 @@ select {
height: 38px;
display: grid;
place-items: center;
border: 1px solid rgba(111, 232, 210, .35);
border: 1px solid rgba(111, 232, 210, 0.35);
border-radius: 50%;
background: rgba(34, 116, 107, .22);
background: rgba(34, 116, 107, 0.22);
}
.brand div,
@@ -949,16 +1067,16 @@ select {
color: #689b94;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 9px;
letter-spacing: .12em;
letter-spacing: 0.12em;
}
.dashboard-topbar nav {
display: flex;
gap: 5px;
padding: 4px;
border: 1px solid rgba(89, 181, 169, .14);
border: 1px solid rgba(89, 181, 169, 0.14);
border-radius: 11px;
background: rgba(3, 20, 28, .54);
background: rgba(3, 20, 28, 0.54);
}
.dashboard-topbar nav a {
@@ -970,7 +1088,7 @@ select {
.dashboard-topbar nav a.active {
color: #e4fff9;
background: rgba(49, 132, 120, .35);
background: rgba(49, 132, 120, 0.35);
}
.account-menu {
@@ -999,9 +1117,9 @@ select {
.auth-page .pwa-action {
min-height: 34px;
padding: 6px 11px;
border-color: rgba(95, 215, 193, .28);
border-color: rgba(95, 215, 193, 0.28);
color: #cffff4;
background: rgba(16, 67, 76, .72);
background: rgba(16, 67, 76, 0.72);
box-shadow: none;
font-size: 12px;
}
@@ -1012,10 +1130,10 @@ select {
display: inline-flex;
gap: 6px;
align-items: center;
border: 1px solid rgba(255, 202, 112, .3);
border: 1px solid rgba(255, 202, 112, 0.3);
border-radius: 999px;
color: #ffe2a7;
background: rgba(83, 55, 20, .48);
background: rgba(83, 55, 20, 0.48);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
white-space: nowrap;
@@ -1026,29 +1144,31 @@ select {
height: 7px;
border-radius: 50%;
background: #ffca70;
box-shadow: 0 0 9px rgba(255, 202, 112, .68);
box-shadow: 0 0 9px rgba(255, 202, 112, 0.68);
}
.dashboard-shell .pwa-action {
min-height: 30px;
padding: 5px 10px;
border-color: rgba(95, 215, 193, .28);
border-color: rgba(95, 215, 193, 0.28);
color: #cffff4;
background: rgba(16, 67, 76, .72);
background: rgba(16, 67, 76, 0.72);
box-shadow: none;
font-size: 12px;
white-space: nowrap;
}
.dashboard-shell .pwa-action.update {
border-color: rgba(255, 222, 143, .4);
border-color: rgba(255, 222, 143, 0.4);
color: #ffe8b7;
background: rgba(91, 65, 25, .58);
background: rgba(91, 65, 25, 0.58);
animation: pwa-update-glow 2.2s ease-in-out infinite;
}
@keyframes pwa-update-glow {
50% { box-shadow: 0 0 15px rgba(255, 215, 123, .16); }
50% {
box-shadow: 0 0 15px rgba(255, 215, 123, 0.16);
}
}
@media (display-mode: standalone) {
@@ -1110,8 +1230,8 @@ select {
}
.component-list button.selected {
border-color: rgba(91, 211, 190, .23);
background: linear-gradient(110deg, rgba(38, 126, 114, .35), rgba(17, 69, 76, .3));
border-color: rgba(91, 211, 190, 0.23);
background: linear-gradient(110deg, rgba(38, 126, 114, 0.35), rgba(17, 69, 76, 0.3));
}
.component-list button > span:nth-child(2) {
@@ -1137,10 +1257,10 @@ select {
height: 38px;
display: grid;
place-items: center;
border: 1px solid rgba(106, 225, 203, .2);
border: 1px solid rgba(106, 225, 203, 0.2);
border-radius: 10px;
color: #a8f0df;
background: rgba(45, 135, 121, .2);
background: rgba(45, 135, 121, 0.2);
}
.component-list i {
@@ -1152,7 +1272,7 @@ select {
.component-list i.enabled {
background: #72ebc9;
box-shadow: 0 0 9px rgba(86, 232, 197, .7);
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
}
.future-components {
@@ -1160,7 +1280,7 @@ select {
padding-top: 15px;
display: grid;
gap: 4px;
border-top: 1px solid rgba(94, 187, 173, .12);
border-top: 1px solid rgba(94, 187, 173, 0.12);
color: #567d78;
}
@@ -1188,24 +1308,24 @@ select {
.status-chip {
width: max-content;
padding: 5px 9px;
border: 1px solid rgba(112, 197, 186, .2);
border: 1px solid rgba(112, 197, 186, 0.2);
border-radius: 99px;
color: #91bcb6;
background: rgba(17, 60, 66, .44);
background: rgba(17, 60, 66, 0.44);
font-size: 12px;
white-space: nowrap;
}
.status-chip.online {
border-color: rgba(99, 236, 199, .3);
border-color: rgba(99, 236, 199, 0.3);
color: #99f3d8;
background: rgba(31, 105, 87, .35);
background: rgba(31, 105, 87, 0.35);
}
.status-chip.offline {
border-color: rgba(240, 151, 159, .25);
border-color: rgba(240, 151, 159, 0.25);
color: #e9aeb4;
background: rgba(91, 39, 48, .34);
background: rgba(91, 39, 48, 0.34);
}
.dashboard-panel {
@@ -1251,7 +1371,7 @@ select {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.slider-grid input[type="range"] {
.slider-grid input[type='range'] {
width: 100%;
accent-color: #63ddc1;
}
@@ -1262,7 +1382,7 @@ select {
display: flex;
flex-wrap: wrap;
gap: 9px;
border: 1px solid rgba(89, 179, 167, .18);
border: 1px solid rgba(89, 179, 167, 0.18);
border-radius: 12px;
}
@@ -1381,9 +1501,9 @@ select {
padding: 17px;
display: grid;
gap: 11px;
border: 1px solid rgba(255, 215, 127, .28);
border: 1px solid rgba(255, 215, 127, 0.28);
border-radius: 12px;
background: rgba(86, 64, 24, .24);
background: rgba(86, 64, 24, 0.24);
}
.one-time-secret > b {
@@ -1408,7 +1528,7 @@ table {
th,
td {
padding: 12px 10px;
border-bottom: 1px solid rgba(85, 169, 158, .12);
border-bottom: 1px solid rgba(85, 169, 158, 0.12);
color: #a9d3cc;
text-align: left;
white-space: nowrap;
@@ -1426,10 +1546,10 @@ td:last-child {
.obs-configuration-error {
padding: 10px 12px;
border: 1px solid rgba(255, 143, 157, .42);
border: 1px solid rgba(255, 143, 157, 0.42);
border-radius: 10px;
color: #ffe1e5;
background: rgba(72, 24, 37, .82);
background: rgba(72, 24, 37, 0.82);
font-size: clamp(13px, 3vw, 17px);
}
File diff suppressed because it is too large Load Diff
+24 -5
View File
@@ -1,3 +1,11 @@
/**
* 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'
@@ -20,11 +28,15 @@ function NotFoundPage() {
return (
<main className="auth-page">
<section className="auth-card jade-panel not-found">
<div className="auth-mark" aria-hidden="true">云</div>
<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>
<a className="button-link" href="/control/">
返回控制台
</a>
</section>
</main>
)
@@ -85,9 +97,15 @@ function App() {
<PwaControls />
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
{offline && (
<p className="auth-lead">
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
</p>
)}
<div className="notice error">{loadError}</div>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
重新连接
</button>
</section>
</main>
)
@@ -124,7 +142,8 @@ function App() {
}
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} />
if (session.user.role !== 'system_admin')
return <ForbiddenPage user={session.user} onLogout={logout} />
return <InvitationsPage user={session.user} onLogout={logout} />
}
return <NotFoundPage />
+124 -47
View File
@@ -1,3 +1,12 @@
/**
* Transparent OBS renderer and component WebSocket client.
*
* The bearer token is read from the URL fragment, then sent as the first
* WebSocket frame; fragments never reach Nginx access logs or the initial HTTP
* request. Incoming events are treated as untrusted display data, bounded by
* component settings, and malformed frames are ignored without terminating a
* long-running browser source.
*/
import { useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { defaultOverlaySettings } from './types'
@@ -55,7 +64,20 @@ type OverlayProps = {
accessToken?: string
}
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
const cardParticles = [
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
] as const
const decorVariantCount = 6
function stableHash(value: string) {
@@ -95,21 +117,27 @@ function streamUrl(publicId: string) {
}
function enabled(type: string, settings: OverlaySettings) {
return (type === 'live.danmaku' && settings.showDanmaku)
|| (type === 'live.enter' && settings.showEnter)
|| (type.startsWith('live.gift') && settings.showGift)
|| (type === 'live.superchat' && settings.showSuperchat)
|| (type === 'live.guard.buy' && settings.showGuard)
|| (type === 'live.like' && settings.showLike)
|| (type === 'live.share' && settings.showShare)
return (
(type === 'live.danmaku' && settings.showDanmaku) ||
(type === 'live.enter' && settings.showEnter) ||
(type.startsWith('live.gift') && settings.showGift) ||
(type === 'live.superchat' && settings.showSuperchat) ||
(type === 'live.guard.buy' && settings.showGuard) ||
(type === 'live.like' && settings.showLike) ||
(type === 'live.share' && settings.showShare)
)
}
function parseSettings(value: unknown): OverlaySettings {
if (!value || typeof value !== 'object') return defaultOverlaySettings
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
return { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
}
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
function useEvents(
disabled: boolean,
publicId?: string,
accessToken?: string,
): {
settings: OverlaySettings
items: Item[]
setItems: Dispatch<SetStateAction<Item[]>>
@@ -117,7 +145,9 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
} {
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
const [items, setItems] = useState<Item[]>([])
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>(
'idle',
)
const settingsRef = useRef(settings)
useEffect(() => {
@@ -142,7 +172,7 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
retries = 0
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
}
socket.onclose = (event) => {
socket.onclose = event => {
if (dead) return
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
setConnection('denied')
@@ -166,7 +196,10 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
return
}
setConnection('connected')
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
if (
envelope.type === 'overlay.settings.snapshot' ||
envelope.type === 'overlay.settings.updated'
) {
setSettings(parseSettings(envelope.payload?.settings))
return
}
@@ -176,10 +209,13 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
const key = combo ? `combo:${combo}` : envelope.id
const existing = old.find(item => item.key === key)
const decorVariant = existing?.decorVariant
?? chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [{ ...envelope, key, received: Date.now(), decorVariant }, ...old.filter(item => item.key !== key)]
.slice(0, current.maxVisible)
const decorVariant =
existing?.decorVariant ??
chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [
{ ...envelope, key, received: Date.now(), decorVariant },
...old.filter(item => item.key !== key),
].slice(0, current.maxVisible)
})
} catch {
// A malformed upstream event must not break a long-running OBS source.
@@ -239,49 +275,75 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
}
function DanmakuBody({ payload }: { payload: LivePayload }) {
const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined
const segments = Array.isArray(payload.segments)
? (payload.segments as DanmakuSegment[])
: undefined
if (!segments?.length) return <>{payload.text || ''}</>
return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url
? <DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
: <span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</>
return (
<>
{segments.map((segment, index) =>
segment.type === 'emoticon' && segment.url ? (
<DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
) : (
<span className="danmaku-text" key={`text:${index}`}>
{segment.text}
</span>
),
)}
</>
)
}
function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) {
function Card({
item,
settings,
expanded,
}: {
item: Item
settings: OverlaySettings
expanded: boolean
}) {
const payload = item.payload || {}
const viewer = payload.viewer || {}
const gift = payload.gift
const isDanmaku = item.type === 'live.danmaku'
const tier = gift ? giftTier(item, settings) : ''
const body = gift
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
: isDanmaku
? <DanmakuBody payload={payload} />
: eventRenderers[item.type]?.(payload) || '送来了一份互动'
const body = gift ? (
`献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
) : isDanmaku ? (
<DanmakuBody payload={payload} />
) : (
eventRenderers[item.type]?.(payload) || '送来了一份互动'
)
return (
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}>
<article
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
>
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
{gift && (
<div className="gift-art">
{gift.animationUrl || gift.imageUrl
? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
else image.style.display = 'none'
}}
/>
)
: <span>✦</span>}
{gift.animationUrl || gift.imageUrl ? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
else image.style.display = 'none'
}}
/>
) : (
<span>✦</span>
)}
</div>
)}
<div className="copy">
<b>{viewer.name || '直播间观众'}</b>
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && <em>¥ {gift.priceCny.toFixed(2)}</em>}
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
<em>¥ {gift.priceCny.toFixed(2)}</em>
)}
</div>
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
</article>
@@ -327,7 +389,13 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
payload: {
viewer: { name: '星光旅人' },
quantity: 1,
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' },
gift: {
name: '甜蜜告白',
totalPrice: 12_000,
priceCny: 12,
imageUrl: '',
animationUrl: '',
},
},
},
])
@@ -343,7 +411,7 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
setExpandedKey(newest.key)
const densityFactor = shape === 'short' ? 0.6 : 1
const timer = window.setTimeout(
() => setExpandedKey(key => key === newest.key ? undefined : key),
() => setExpandedKey(key => (key === newest.key ? undefined : key)),
settings.collapseAfterSeconds * 1000 * densityFactor,
)
return () => window.clearTimeout(timer)
@@ -365,11 +433,20 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
}}
>
<section className="wall">
{missingAccess && <div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>}
{!missingAccess && events.connection === 'denied' && <div className="obs-configuration-error">OBS 访问令牌已失效。</div>}
{missingAccess && (
<div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>
)}
{!missingAccess && events.connection === 'denied' && (
<div className="obs-configuration-error">OBS 访问令牌已失效。</div>
)}
<div className="cards">
{items.map(item => (
<Card item={item} settings={settings} expanded={item.key === expandedKey} key={item.key} />
<Card
item={item}
settings={settings}
expanded={item.key === expandedKey}
key={item.key}
/>
))}
</div>
</section>
+64 -27
View File
@@ -1,3 +1,12 @@
/**
* Install, offline and explicit-update lifecycle for the control-console PWA.
*
* Registration is restricted to `/control/`; `/obs/*` is deliberately outside
* the scope. The worker caches only the public application shell and static
* assets. API responses, WebSockets and secrets remain network-only. A waiting
* worker activates only after user confirmation and after every registered
* one-time-secret or dirty-form blocker has cleared.
*/
import { useEffect, useSyncExternalStore } from 'react'
interface InstallChoice {
@@ -58,9 +67,10 @@ function addManifest() {
function observeRegistration(registration: ServiceWorkerRegistration) {
emit({
registration,
waitingWorker: registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
waitingWorker:
registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
})
registration.addEventListener('updatefound', () => {
@@ -76,18 +86,23 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
async function removeLegacyRootWorker() {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy = scopePath === '/'
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}))
await Promise.all(
registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy =
scopePath === '/' &&
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}),
)
const cacheNames = await caches.keys()
await Promise.all(cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)))
await Promise.all(
cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)),
)
}
export function cleanupLegacyPwa() {
@@ -121,18 +136,25 @@ export function initializePwa() {
const updateDisplayMode = () => emit({ standalone: isStandalone() })
window.addEventListener('online', updateConnection)
window.addEventListener('offline', updateConnection)
const modernListener = (displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}).addEventListener
const modernListener = (
displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}
).addEventListener
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
else (displayMode as unknown as { addListener?: (listener: () => void) => void })
.addListener?.call(displayMode, updateDisplayMode)
else
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
displayMode,
updateDisplayMode,
)
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault()
emit({ installPrompt: event as BeforeInstallPromptEvent })
})
window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true }))
window.addEventListener('appinstalled', () =>
emit({ installPrompt: undefined, standalone: true }),
)
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
@@ -151,13 +173,17 @@ export function initializePwa() {
void snapshot.registration?.update()
}
})
window.setInterval(() => {
if (navigator.onLine) void snapshot.registration?.update()
}, 60 * 60 * 1000)
window.setInterval(
() => {
if (navigator.onLine) void snapshot.registration?.update()
},
60 * 60 * 1000,
)
}
export function authRoute(name: 'login' | 'register' | 'setup'): string {
const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/')
const inControlScope =
location.pathname === '/control' || location.pathname.startsWith('/control/')
return inControlScope ? `/control/${name}` : `/${name}`
}
@@ -165,7 +191,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
useEffect(() => {
if (active) updateBlockers.set(key, reason)
else updateBlockers.delete(key)
return () => { updateBlockers.delete(key) }
return () => {
updateBlockers.delete(key)
}
}, [active, key, reason])
}
@@ -182,10 +210,14 @@ function applyUpdate() {
if (!worker) return
const reasons = [...new Set(updateBlockers.values())]
if (reasons.length > 0) {
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
window.alert(
`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`,
)
return
}
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
const confirmed = window.confirm(
'更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?',
)
if (!confirmed) return
reloadForUpdate = true
worker.postMessage({ type: 'SKIP_WAITING' })
@@ -198,7 +230,12 @@ export function PwaControls() {
return (
<div className="pwa-controls" aria-live="polite">
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
{!state.online && (
<span className="pwa-state offline">
<i aria-hidden="true" />
离线
</span>
)}
{state.waitingWorker && (
<button type="button" className="pwa-action update" onClick={applyUpdate}>
更新可用
+274 -1
View File
@@ -1 +1,274 @@
*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
font-family: 'Noto Serif SC', 'Microsoft YaHei', serif;
}
body {
background: transparent;
color: #dcfffa;
}
.overlay {
width: 100%;
height: 100%;
padding: clamp(8px, 2vw, 22px);
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
background: radial-gradient(ellipse at 100% 50%, rgba(21, 94, 96, 0.2), transparent 62%);
}
.wall {
width: min(100%, 480px);
display: flex;
flex-direction: column;
gap: 9px;
filter: drop-shadow(0 10px 28px rgba(0, 11, 19, 0.36));
}
.cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.card {
position: relative;
min-height: 58px;
padding: 11px 14px;
border: 1px solid rgba(101, 226, 211, 0.28);
border-radius: 14px;
overflow: hidden;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61));
backdrop-filter: blur(15px);
animation: arrive calc(0.35s + 0.35s * var(--motion)) cubic-bezier(0.19, 0.9, 0.3, 1) both;
}
.card:before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 25%, rgba(155, 255, 232, 0.14), transparent 65%);
transform: translateX(-120%);
animation: sheen calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s both;
}
.copy {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: clamp(12px, 2.7vw, 17px);
}
.copy b {
color: #e6fff9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy span {
color: #a9dbd4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy em {
font-style: normal;
color: #ffdc89;
font-size: 0.84em;
}
.gift-art {
z-index: 1;
width: 48px;
height: 48px;
flex: none;
border-radius: 12px;
background: radial-gradient(circle, #2e9f9e, transparent 66%);
display: grid;
place-items: center;
}
.gift-art img {
width: 100%;
height: 100%;
object-fit: contain;
}
.gift.high {
min-height: 72px;
border-color: rgba(102, 255, 229, 0.65);
background: linear-gradient(105deg, rgba(5, 59, 78, 0.92), rgba(14, 119, 103, 0.7));
}
.gift.featured {
min-height: 128px;
border-color: #b6ffdc;
background:
radial-gradient(circle at 20% 50%, rgba(69, 236, 190, 0.4), transparent 35%),
linear-gradient(118deg, rgba(7, 63, 84, 0.97), rgba(12, 105, 89, 0.85));
animation: featured calc(2.8s - 1.5s * var(--motion)) ease-in-out infinite;
}
.gift.featured .gift-art {
width: 92px;
height: 92px;
}
.particles {
position: absolute;
right: 9px;
top: 6px;
color: #ceffe4;
letter-spacing: 9px;
animation: float 2.2s ease-in-out infinite;
}
.narrow .overlay {
padding: 6px;
}
.narrow .wall {
gap: 5px;
}
.narrow .card {
min-height: 45px;
padding: 7px 9px;
border-radius: 10px;
}
.narrow .gift-art {
width: 34px;
height: 34px;
}
.narrow .gift.featured {
min-height: 90px;
align-items: flex-start;
}
.narrow .gift.featured .gift-art {
width: 58px;
height: 58px;
}
.short .cards .card:nth-child(n + 4) {
display: none;
}
.low-motion * {
animation: none !important;
}
.control {
min-height: 100%;
padding: clamp(20px, 5vw, 56px);
display: grid;
grid-template-columns: minmax(300px, 520px) minmax(280px, 1fr);
gap: 36px;
background: linear-gradient(135deg, #061923, #092c32 55%, #06151f);
color: #d9fff6;
}
.control h1 {
margin: 0;
color: #bffff0;
}
.control h2 {
margin: 0;
}
.control p {
color: #88bdb5;
}
.control label {
display: block;
margin: 14px 0;
color: #bcebe3;
}
.control input:not([type='checkbox']):not([type='range']) {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #357e79;
border-radius: 8px;
background: #071d28;
color: #e6fff9;
}
.control input[type='range'] {
margin-left: 10px;
accent-color: #5ae7c6;
}
.control output {
margin-left: 8px;
}
.control fieldset {
border: 1px solid #28645f;
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 3px 12px;
}
.control fieldset label {
margin: 8px 0;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.control button {
padding: 10px 14px;
border: 0;
border-radius: 8px;
background: #55dcb9;
color: #06211e;
font-weight: bold;
cursor: pointer;
}
.control button.secondary {
background: #173e4b;
color: #d4fff7;
}
.preview-frame {
height: 600px;
resize: both;
overflow: auto;
border: 1px dashed #4dafa4;
background: linear-gradient(135deg, rgba(71, 190, 172, 0.12), transparent);
}
.login {
display: grid;
place-content: center;
grid-template-columns: 360px;
background: #071923;
}
.login form {
display: flex;
flex-direction: column;
gap: 12px;
}
@keyframes arrive {
from {
opacity: 0;
transform: translateX(38px) scale(0.96);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheen {
to {
transform: translateX(135%);
}
}
@keyframes featured {
50% {
filter: brightness(1.18);
transform: scale(1.015);
}
}
@keyframes float {
50% {
transform: translateY(-8px);
opacity: 0.5;
}
}
@media (max-width: 720px) {
.control {
grid-template-columns: 1fr;
}
.preview-frame {
height: 480px;
}
}
+7
View File
@@ -1,3 +1,10 @@
/**
* Shared control-console view models.
*
* These types describe sanitized API responses and editable settings, not raw
* database rows or Bilibili packets. Secret fields are optional because the
* server normally returns only `*Configured` flags after initial submission.
*/
export type OverlaySettings = {
fontScale: number
showDanmaku: boolean