update UI
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 72" aria-hidden="true">
|
||||
<!-- Repository-original transparent lotus silhouette used as a CSS mask. -->
|
||||
<g fill="#000">
|
||||
<path d="M48 39C36 28 35 15 48 3c13 12 12 25 0 36Z" />
|
||||
<path d="M43 43C27 38 19 26 24 10c16 5 23 17 19 33Z" opacity=".9" />
|
||||
<path d="M53 43c16-5 24-17 19-33-16 5-23 17-19 33Z" opacity=".9" />
|
||||
<path d="M39 48C21 49 9 40 7 24c17-1 28 8 32 24Z" opacity=".75" />
|
||||
<path d="M57 48c18 1 30-8 32-24-17-1-28 8-32 24Z" opacity=".75" />
|
||||
<path d="M48 62C29 62 16 56 10 45c13 4 25 4 38-1 13 5 25 5 38 1-6 11-19 17-38 17Z" />
|
||||
<path d="M17 67c20-5 42-5 62 0-21 3-41 3-62 0Z" opacity=".62" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shared Moonlit Water edge ornament.
|
||||
*
|
||||
* Keeping the artwork inline lets the persisted decoration setting control
|
||||
* real SVG stroke width. A raster image or CSS-scaled mask would only distort
|
||||
* the motif and could not make its calligraphic lines genuinely heavier.
|
||||
*/
|
||||
function EdgeArtwork({ position }: { position: 'top' | 'bottom' }) {
|
||||
return (
|
||||
<svg
|
||||
className={`theme-edge-art theme-edge-${position}`}
|
||||
viewBox="0 0 1200 56"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="theme-edge-rule">
|
||||
<path d="M12 30h338c52 0 86-8 116-24-13 13-13 29 0 42-30-16-64-24-116-24H12" />
|
||||
<path d="M1188 30H850c-52 0-86-8-116-24 13 13 13 29 0 42 30-16 64-24 116-24h338" />
|
||||
</g>
|
||||
<g className="theme-edge-water">
|
||||
<path d="M38 38c35-14 70-14 105 0s70 14 105 0" />
|
||||
<path d="M1162 38c-35-14-70-14-105 0s-70 14-105 0" />
|
||||
</g>
|
||||
<g className="theme-edge-cloud">
|
||||
<path d="M192 27c10-14 31-14 40 0 8-10 25-8 29 4-22 8-53 8-76 0 1-2 4-3 7-4Z" />
|
||||
<path d="M1008 27c-10-14-31-14-40 0-8-10-25-8-29 4 22 8 53 8 76 0-1-2-4-3-7-4Z" />
|
||||
</g>
|
||||
<g className="theme-edge-leaves">
|
||||
<path d="M485 28c13-14 27-17 42-9-10 13-24 16-42 9Z" />
|
||||
<path d="M715 28c-13-14-27-17-42-9 10 13 24 16 42 9Z" />
|
||||
</g>
|
||||
<g className="theme-edge-lotus">
|
||||
<path d="M600 34c-9-10-9-22 0-32 9 10 9 22 0 32Z" />
|
||||
<path d="M595 36c-15-5-22-16-19-29 15 5 22 15 19 29Z" />
|
||||
<path d="M605 36c15-5 22-16 19-29-15 5-22 15-19 29Z" />
|
||||
<path d="M590 40c-17 0-28-7-32-20 16 0 27 7 32 20Z" />
|
||||
<path d="M610 40c17 0 28-7 32-20-16 0-27 7-32 20Z" />
|
||||
<path d="M600 49c-18 0-31-5-39-15 14 4 27 3 39-3 12 6 25 7 39 3-8 10-21 15-39 15Z" />
|
||||
</g>
|
||||
<g className="theme-edge-dots">
|
||||
<circle cx="92" cy="27" r="3.2" />
|
||||
<circle cx="1108" cy="27" r="3.2" />
|
||||
<path d="m536 27 4-7 4 7-4 7Z" />
|
||||
<path d="m664 27-4-7-4 7 4 7Z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Decorative chrome is ignored by assistive technology and never captures input. */
|
||||
export function ThemeEdges() {
|
||||
return (
|
||||
<div className="theme-edge-set" aria-hidden="true">
|
||||
<EdgeArtwork position="top" />
|
||||
<EdgeArtwork position="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ function AuthShell({
|
||||
)
|
||||
}
|
||||
|
||||
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
export function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
const { t } = useI18n()
|
||||
const source = useMemo(() => {
|
||||
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
||||
|
||||
@@ -374,7 +374,17 @@ body,
|
||||
}
|
||||
|
||||
.wall {
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wall > .cards {
|
||||
/* Keep the chronological stack pinned to the bottom. Once it is taller
|
||||
than the OBS viewport, its oldest rows overflow above the clipping edge
|
||||
while each newly appended row remains visible at the bottom. */
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -804,6 +814,22 @@ body,
|
||||
background 0.24s ease;
|
||||
}
|
||||
|
||||
.danmaku-lotus {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
right: clamp(8px, 1.6vw, 18px);
|
||||
width: clamp(26px, 7%, 44px);
|
||||
aspect-ratio: 4 / 3;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(145deg, var(--theme-accent), var(--theme-user));
|
||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--theme-accent) 48%, transparent));
|
||||
opacity: 0.62;
|
||||
transform: translateY(-50%);
|
||||
-webkit-mask: var(--theme-pattern-lotus) center / contain no-repeat;
|
||||
mask: var(--theme-pattern-lotus) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.card.danmaku.expanded {
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
@@ -826,7 +852,7 @@ body,
|
||||
inset-inline: -1px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
border-inline: 4px solid rgba(123, 238, 214, 0.72);
|
||||
border-inline: var(--component-decoration-rail, 4px) solid rgba(123, 238, 214, 0.72);
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -901,6 +927,11 @@ body,
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.card.danmaku.expanded .copy,
|
||||
.card.danmaku.compact .copy {
|
||||
padding-inline-end: clamp(38px, 8%, 58px);
|
||||
}
|
||||
|
||||
.short .cards .card:nth-child(n + 4) {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1071,6 +1102,13 @@ body,
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .danmaku-lotus {
|
||||
width: clamp(30px, 8%, 52px);
|
||||
background: linear-gradient(145deg, #e8d59a, #9cb7a5 72%);
|
||||
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.34));
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .card.danmaku .copy > b {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
@@ -1099,7 +1137,7 @@ body,
|
||||
height: clamp(38px, 2.3em, 56px);
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(231, 201, 130, 0.58);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.58);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 36% 28%, rgba(224, 233, 205, 0.32), rgba(22, 72, 76, 0.5));
|
||||
color: #d2b976;
|
||||
@@ -1190,6 +1228,11 @@ body,
|
||||
height: clamp(32px, 1.9em, 44px);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .card.danmaku.expanded .copy,
|
||||
.theme-moonlit-water .card.danmaku.compact .copy {
|
||||
padding-inline-end: clamp(42px, 9%, 66px);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .danmaku-emoticon {
|
||||
filter: drop-shadow(0 1px 3px rgba(10, 34, 38, 0.56));
|
||||
}
|
||||
|
||||
@@ -19,11 +19,14 @@ import {
|
||||
normalizeGiftEffectSettings,
|
||||
normalizeGiftMenuSettings,
|
||||
normalizeInvitations,
|
||||
normalizeEnrollment,
|
||||
normalizeRecoveryCodes,
|
||||
normalizeSettings,
|
||||
normalizeSongRequestPage,
|
||||
normalizeSongRequestSettings,
|
||||
normalizeSource,
|
||||
} from './api'
|
||||
import { TotpQr } from './auth'
|
||||
import { Overlay } from './overlay'
|
||||
import { GiftEffectOverlay } from './giftEffect'
|
||||
import { GiftMenuOverlay } from './giftMenu'
|
||||
@@ -56,6 +59,7 @@ import type {
|
||||
SongRequestItem,
|
||||
SongRequestPage,
|
||||
SongRequestSettings,
|
||||
TotpEnrollment,
|
||||
} from './types'
|
||||
|
||||
const previewPresets = [
|
||||
@@ -115,6 +119,32 @@ function TypographySettingsFields({
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared percentage control for theme borders, dividers, and edge ornaments. */
|
||||
function DecorationLineWeightField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
}) {
|
||||
return (
|
||||
<label>
|
||||
<span>
|
||||
{translate('settings.decoration_line_weight')} <output>{value}%</output>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="300"
|
||||
step="10"
|
||||
value={value}
|
||||
onChange={event => onChange(+event.target.value)}
|
||||
/>
|
||||
<small>{translate('settings.decoration_line_weight_description')}</small>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
type TextColorField = {
|
||||
labelKey: string
|
||||
color: string | null
|
||||
@@ -341,6 +371,10 @@ function SettingsEditor({
|
||||
]}
|
||||
/>
|
||||
<div className="slider-grid">
|
||||
<DecorationLineWeightField
|
||||
value={settings.decorationLineWeight}
|
||||
onChange={value => edit('decorationLineWeight', value)}
|
||||
/>
|
||||
<label>
|
||||
<span>
|
||||
{translate('settings.font_size')} <output>{settings.fontScale}%</output>
|
||||
@@ -571,6 +605,10 @@ function SongRequestSettingsEditor({
|
||||
]}
|
||||
/>
|
||||
<div className="slider-grid">
|
||||
<DecorationLineWeightField
|
||||
value={settings.decorationLineWeight}
|
||||
onChange={value => edit('decorationLineWeight', value)}
|
||||
/>
|
||||
<label>
|
||||
<span>
|
||||
{translate('settings.font_size')} <output>{settings.fontScale}%</output>
|
||||
@@ -1196,6 +1234,10 @@ function GiftMenuSettingsEditor({
|
||||
</div>
|
||||
|
||||
<div className="slider-grid gift-menu-renderer-settings">
|
||||
<DecorationLineWeightField
|
||||
value={settings.decorationLineWeight}
|
||||
onChange={value => edit('decorationLineWeight', value)}
|
||||
/>
|
||||
<label>
|
||||
<span>
|
||||
{translate(
|
||||
@@ -1490,6 +1532,204 @@ function SourceEditor({
|
||||
)
|
||||
}
|
||||
|
||||
/** Session-protected TOTP replacement with one-time QR and recovery material. */
|
||||
function TotpResetPanel() {
|
||||
const [proof, setProof] = useState('')
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
||||
const [enrollment, setEnrollment] = useState<TotpEnrollment>()
|
||||
const [newCode, setNewCode] = useState('')
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>()
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
usePwaUpdateBlocker(
|
||||
'totp-reset',
|
||||
translate('security.totp_reset_blocker'),
|
||||
busy || Boolean(proof || enrollment || newCode || recoveryCodes),
|
||||
)
|
||||
|
||||
const start = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
'/api/v1/auth/totp/reset/start',
|
||||
json('POST', { code: proof }),
|
||||
)
|
||||
const next = normalizeEnrollment(payload)
|
||||
if (!next.enrollmentToken) throw new Error(translate('security.totp_reset_missing_id'))
|
||||
setProof('')
|
||||
setEnrollment(next)
|
||||
} catch (reason) {
|
||||
setFlash({
|
||||
kind: 'error',
|
||||
text: errorMessage(reason, translate('security.totp_reset_start_failed')),
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!enrollment) return
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
'/api/v1/auth/totp/reset/confirm',
|
||||
json('POST', {
|
||||
enrollmentToken: enrollment.enrollmentToken,
|
||||
code: newCode,
|
||||
}),
|
||||
)
|
||||
setNewCode('')
|
||||
setEnrollment(undefined)
|
||||
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
||||
} catch (reason) {
|
||||
setFlash({
|
||||
kind: 'error',
|
||||
text: errorMessage(reason, translate('security.totp_reset_confirm_failed')),
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
setEnrollment(undefined)
|
||||
setNewCode('')
|
||||
setFlash({ kind: 'success', text: translate('security.totp_reset_cancelled') })
|
||||
}
|
||||
|
||||
const recoveryText = recoveryCodes?.join('\n') ?? ''
|
||||
const downloadRecoveryCodes = () => {
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${translate('auth.recovery_file_title')}\n`,
|
||||
`${translate('auth.recovery_file_warning')}\n\n`,
|
||||
recoveryText,
|
||||
'\n',
|
||||
],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'live-component-recovery-codes.txt'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={translate('security.title')} description={translate('security.description')}>
|
||||
<FlashMessage flash={flash} />
|
||||
{recoveryCodes ? (
|
||||
<div>
|
||||
<div className="notice warning">{translate('security.recovery_replaced')}</div>
|
||||
<div className="recovery-grid">
|
||||
{recoveryCodes.map(code => (
|
||||
<code key={code}>{code}</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => void copyToClipboard(recoveryText)}
|
||||
>
|
||||
{translate('auth.copy_all')}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={downloadRecoveryCodes}>
|
||||
{translate('auth.download_text')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRecoveryCodes(undefined)
|
||||
setFlash({ kind: 'success', text: translate('security.totp_reset_complete') })
|
||||
}}
|
||||
>
|
||||
{translate('auth.recovery_saved')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : enrollment ? (
|
||||
<div>
|
||||
<div className="notice warning">{translate('security.old_totp_still_active')}</div>
|
||||
<TotpQr enrollment={enrollment} />
|
||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||
<label>
|
||||
{translate('security.new_totp_code')}
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
className="otp-input"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={newCode}
|
||||
onChange={event => setNewCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button disabled={busy || newCode.length !== 6}>
|
||||
{busy ? translate('auth.confirming') : translate('security.confirm_totp_reset')}
|
||||
</button>
|
||||
<button type="button" className="secondary" disabled={busy} onClick={cancel}>
|
||||
{translate('security.cancel_totp_reset')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<form className="stack-form compact-form" onSubmit={start}>
|
||||
<div className="notice warning">{translate('security.totp_reset_warning')}</div>
|
||||
<label>
|
||||
{useRecoveryCode
|
||||
? translate('auth.recovery_code')
|
||||
: translate('security.current_totp_code')}
|
||||
<input
|
||||
required
|
||||
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
||||
inputMode={useRecoveryCode ? 'text' : 'numeric'}
|
||||
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
||||
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
||||
maxLength={useRecoveryCode ? 64 : 6}
|
||||
placeholder={useRecoveryCode ? translate('auth.recovery_placeholder') : '000000'}
|
||||
value={proof}
|
||||
onChange={event =>
|
||||
setProof(
|
||||
useRecoveryCode
|
||||
? event.target.value.trimStart().slice(0, 64)
|
||||
: event.target.value.replace(/\D/g, '').slice(0, 6),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-link"
|
||||
onClick={() => {
|
||||
setUseRecoveryCode(current => !current)
|
||||
setProof('')
|
||||
}}
|
||||
>
|
||||
{useRecoveryCode
|
||||
? translate('auth.use_totp')
|
||||
: translate('security.use_recovery_for_reset')}
|
||||
</button>
|
||||
<button disabled={busy || !proof.trim()}>
|
||||
{busy ? translate('security.preparing_totp') : translate('security.start_totp_reset')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
type TokenState = {
|
||||
publicId: string
|
||||
configured: boolean
|
||||
@@ -2239,6 +2479,7 @@ export function AccountLiveSourcePage({
|
||||
<FlashMessage flash={flash} />
|
||||
{loading && <div className="loading-panel jade-panel">{translate('account.loading')}</div>}
|
||||
{source && <SourceEditor source={source} onSaved={setSource} />}
|
||||
<TotpResetPanel />
|
||||
</div>
|
||||
</ControlLayout>
|
||||
)
|
||||
|
||||
@@ -24,7 +24,8 @@ body,
|
||||
height: min(100%, var(--menu-panel-height));
|
||||
min-height: min(100%, var(--menu-row-height));
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--menu-jade) 38%, transparent);
|
||||
border: var(--component-decoration-line, 1px) solid
|
||||
color-mix(in srgb, var(--menu-jade) 38%, transparent);
|
||||
border-radius: clamp(10px, 1.5vmin, 22px);
|
||||
outline: 1px solid rgba(255, 227, 142, 0.07);
|
||||
outline-offset: -4px;
|
||||
@@ -71,7 +72,8 @@ body,
|
||||
gap: calc(var(--menu-row-height) * 0.13);
|
||||
overflow: hidden;
|
||||
padding: calc(var(--menu-row-height) * 0.1) calc(var(--menu-row-height) * 0.22);
|
||||
border: 1px solid color-mix(in srgb, var(--menu-jade) 36%, transparent);
|
||||
border: var(--component-decoration-line, 1px) solid
|
||||
color-mix(in srgb, var(--menu-jade) 36%, transparent);
|
||||
border-radius: calc(var(--menu-row-height) * 0.17);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)),
|
||||
@@ -438,7 +440,7 @@ body,
|
||||
gap: calc(var(--menu-row-height) * 0.12);
|
||||
padding-inline: calc(var(--menu-row-height) * 0.12);
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(231, 201, 130, 0.17);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.17);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
@@ -498,7 +500,7 @@ body,
|
||||
inset: 0;
|
||||
padding: 0.28em 1em;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(231, 201, 130, 0.82);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.82);
|
||||
border-radius: 0;
|
||||
color: #eadfc3;
|
||||
background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent);
|
||||
@@ -529,7 +531,7 @@ body,
|
||||
|
||||
.gift-menu-theme-moonlit-water .gift-menu-empty {
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(231, 201, 130, 0.25);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.25);
|
||||
border-radius: 0;
|
||||
color: #afc2ad;
|
||||
background: transparent;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { defaultGiftMenuSettings } from './types'
|
||||
import type { GiftMenuItem, GiftMenuSettings } from './types'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
import { typographyVariables } from './typography'
|
||||
|
||||
type MenuEnvelope = {
|
||||
@@ -365,6 +366,8 @@ export function GiftMenuOverlay({
|
||||
{
|
||||
...giftMenuThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
|
||||
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--menu-row-height' as string]: `${settings.rowHeight}px`,
|
||||
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
|
||||
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
|
||||
@@ -379,6 +382,7 @@ export function GiftMenuOverlay({
|
||||
}
|
||||
data-connection={stream?.connection ?? 'idle'}
|
||||
>
|
||||
<ThemeEdges />
|
||||
<section
|
||||
className="gift-menu-viewport"
|
||||
ref={viewport}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { OverlayThemeDefinition } from './themes'
|
||||
import { defaultOverlaySettings } from './types'
|
||||
import type { OverlaySettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
|
||||
type Envelope = {
|
||||
id: string
|
||||
@@ -170,18 +171,19 @@ function useEvents(preview: boolean, stream?: ComponentStream) {
|
||||
chooseDecorVariant(
|
||||
`${envelope.type}:${key}`,
|
||||
theme.ornaments.variantCount,
|
||||
old[0]?.decorVariant,
|
||||
old[old.length - 1]?.decorVariant,
|
||||
)
|
||||
return [
|
||||
{ ...envelope, key, received: Date.now(), decorVariant },
|
||||
const next = [
|
||||
...old.filter(item => item.key !== key),
|
||||
].slice(0, current.maxVisible)
|
||||
{ ...envelope, key, received: Date.now(), decorVariant },
|
||||
]
|
||||
return next.slice(Math.max(0, next.length - current.maxVisible))
|
||||
})
|
||||
}
|
||||
}, [preview, stream, stream?.messages])
|
||||
|
||||
useEffect(() => {
|
||||
setItems(current => current.slice(0, settings.maxVisible))
|
||||
setItems(current => current.slice(Math.max(0, current.length - settings.maxVisible)))
|
||||
}, [settings.maxVisible])
|
||||
|
||||
return { settings, items, setItems }
|
||||
@@ -324,6 +326,7 @@ function Card({
|
||||
<em>¥ {gift.priceCny.toFixed(2)}</em>
|
||||
)}
|
||||
</div>
|
||||
{isDanmaku && <i className="danmaku-lotus" aria-hidden="true" />}
|
||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||
</article>
|
||||
)
|
||||
@@ -353,17 +356,6 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
useEffect(() => {
|
||||
if (preview) {
|
||||
setItems([
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: {
|
||||
viewer: { name: translate('overlay.preview_viewer') },
|
||||
text: translate('overlay.preview_message'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gift-preview',
|
||||
key: 'gift-preview',
|
||||
@@ -382,12 +374,23 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: {
|
||||
viewer: { name: translate('overlay.preview_viewer') },
|
||||
text: translate('overlay.preview_message'),
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [items.length, language, preview, setItems])
|
||||
|
||||
useEffect(() => {
|
||||
const newest = items[0]
|
||||
const newest = items[items.length - 1]
|
||||
if (!newest) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
@@ -413,6 +416,9 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-viewer-color' as string]: settings.viewerColor || undefined,
|
||||
['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
|
||||
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
|
||||
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--component-decoration-rail' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--motion' as string]: `${settings.motionIntensity / 100}`,
|
||||
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
|
||||
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
|
||||
@@ -422,6 +428,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ThemeEdges />
|
||||
<section className="wall">
|
||||
<div className="cards">
|
||||
{items.map(item => (
|
||||
|
||||
@@ -26,7 +26,7 @@ body,
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
border: var(--component-decoration-line, 1px) solid var(--theme-card-border);
|
||||
border-radius: clamp(8px, 1.5vmin, 15px);
|
||||
background: var(--theme-card-background);
|
||||
box-shadow:
|
||||
@@ -60,7 +60,7 @@ body,
|
||||
place-items: center;
|
||||
width: clamp(30px, 3em, 46px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(133, 255, 232, 0.5);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.5);
|
||||
border-radius: 50%;
|
||||
color: var(--theme-accent);
|
||||
background: rgba(6, 48, 57, 0.72);
|
||||
@@ -127,7 +127,7 @@ body,
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.38em 0.72em;
|
||||
border-bottom: 1px solid rgba(133, 255, 232, 0.16);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.16);
|
||||
color: var(--theme-compact-user);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ body,
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
padding: 0.4em 0.62em;
|
||||
border: 1px solid rgba(128, 238, 218, 0.13);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(128, 238, 218, 0.13);
|
||||
border-radius: 0.62em;
|
||||
background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46));
|
||||
overflow: hidden;
|
||||
@@ -419,14 +419,14 @@ body,
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: clamp(10px, 1.5vmin, 18px);
|
||||
padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px);
|
||||
border-bottom: 1px solid rgba(198, 174, 122, 0.26);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.26);
|
||||
animation: moonlit-song-current 0.68s ease-out both;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-current-mark {
|
||||
position: relative;
|
||||
width: clamp(46px, 3.7em, 68px);
|
||||
border: 1px solid rgba(198, 174, 122, 0.62);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.62);
|
||||
border-radius: 50%;
|
||||
color: #d2b468;
|
||||
background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
|
||||
@@ -443,8 +443,8 @@ body,
|
||||
right: -0.18em;
|
||||
width: 0.72em;
|
||||
height: 0.42em;
|
||||
border: 1px solid rgba(116, 166, 157, 0.52);
|
||||
border-width: 1px 0 0 1px;
|
||||
border: var(--component-decoration-line, 1px) solid rgba(116, 166, 157, 0.52);
|
||||
border-width: var(--component-decoration-line, 1px) 0 0 var(--component-decoration-line, 1px);
|
||||
border-radius: 100% 0;
|
||||
transform: rotate(-24deg);
|
||||
}
|
||||
@@ -510,7 +510,7 @@ body,
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-waveform i {
|
||||
width: 2px;
|
||||
width: var(--component-decoration-line, 2px);
|
||||
height: 25%;
|
||||
background: #6a9f99;
|
||||
animation: moonlit-song-wave 1.8s ease-in-out infinite alternate;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { getOverlayTheme, themeCssVariables } from './themes'
|
||||
import type { OverlayThemeDefinition } from './themes'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
import { defaultSongRequestSettings } from './types'
|
||||
import type { SongRequestItem, SongRequestSettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
@@ -324,6 +325,8 @@ export function SongRequestOverlay({
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-song-requester-color' as string]: settings.requesterColor || undefined,
|
||||
['--component-song-title-color' as string]: settings.songTitleColor || undefined,
|
||||
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
|
||||
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--song-font-size' as string]: `${Math.min(22, Math.max(11, Math.min(bounds.width, bounds.height) * 0.024)) * (settings.fontScale / 100)}px`,
|
||||
['--song-current-min' as string]: `${Math.min(72, Math.max(48, bounds.height * 0.08))}px`,
|
||||
['--song-moonlit-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
|
||||
@@ -331,6 +334,7 @@ export function SongRequestOverlay({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ThemeEdges />
|
||||
<section
|
||||
className="song-current"
|
||||
aria-label={translate('song.overlay.current_aria')}
|
||||
|
||||
@@ -39,7 +39,8 @@ body {
|
||||
position: relative;
|
||||
min-height: 58px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--theme-card-border, rgba(101, 226, 211, 0.28));
|
||||
border: var(--component-decoration-line, 1px) solid
|
||||
var(--theme-card-border, rgba(101, 226, 211, 0.28));
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
@@ -1,70 +1,101 @@
|
||||
/* Shared upper/lower ornament for the three text-led Moonlit Water widgets.
|
||||
The SVG remains monochrome and transparent; this gradient owns its tint so
|
||||
future themes can reuse the mask with a different palette. */
|
||||
It uses inline SVG so the control setting changes actual stroke width rather
|
||||
than vertically stretching a thin bitmap/mask. */
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
) {
|
||||
--moonlit-edge-height: clamp(12px, 2.2vmin, 24px);
|
||||
--moonlit-edge-height: clamp(22px, 3.6vmin, 44px);
|
||||
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
|
||||
--moonlit-edge-space: clamp(10px, 1.8vmin, 20px);
|
||||
--moonlit-edge-space: clamp(16px, 2.5vmin, 30px);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.theme-edge-set {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::before,
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::after {
|
||||
content: '';
|
||||
)
|
||||
> .theme-edge-set {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: clamp(8px, 1.4vmin, 18px);
|
||||
right: clamp(8px, 1.4vmin, 18px);
|
||||
height: var(--moonlit-edge-height);
|
||||
inset: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(138, 174, 158, 0.7) 13%,
|
||||
rgba(211, 188, 127, 0.82) 39%,
|
||||
#ead79f 50%,
|
||||
rgba(211, 188, 127, 0.82) 61%,
|
||||
rgba(138, 174, 158, 0.7) 87%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
|
||||
mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 3px rgba(226, 204, 146, 0.2));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.theme-edge-art {
|
||||
position: absolute;
|
||||
left: clamp(8px, 1.4vmin, 18px);
|
||||
width: calc(100% - clamp(16px, 2.8vmin, 36px));
|
||||
height: var(--moonlit-edge-height);
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 3px rgba(205, 172, 88, 0.28));
|
||||
animation: moonlit-edge-breathe 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::before {
|
||||
.theme-edge-top {
|
||||
top: var(--moonlit-edge-offset);
|
||||
}
|
||||
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::after {
|
||||
.theme-edge-bottom {
|
||||
bottom: var(--moonlit-edge-offset);
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -3.5s;
|
||||
}
|
||||
|
||||
.theme-edge-rule,
|
||||
.theme-edge-water {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: var(--component-decoration-edge-stroke, 3.2px);
|
||||
}
|
||||
|
||||
.theme-edge-rule path,
|
||||
.theme-edge-water path {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.theme-edge-rule {
|
||||
stroke: #d2b35f;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.theme-edge-water {
|
||||
stroke: #5f978b;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.theme-edge-cloud {
|
||||
fill: #568b80;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.theme-edge-leaves {
|
||||
fill: #86aa92;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.theme-edge-lotus {
|
||||
fill: #d4b45d;
|
||||
opacity: 0.96;
|
||||
filter: drop-shadow(0 0 2px rgba(221, 190, 104, 0.36));
|
||||
}
|
||||
|
||||
.theme-edge-dots {
|
||||
fill: #e4c978;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.overlay.theme-moonlit-water {
|
||||
padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space));
|
||||
}
|
||||
@@ -80,24 +111,15 @@
|
||||
@keyframes moonlit-edge-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.56;
|
||||
opacity: 0.78;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.78;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::before,
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::after {
|
||||
.theme-edge-art {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function themeCssVariables(
|
||||
'--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`,
|
||||
'--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`,
|
||||
'--theme-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
|
||||
'--theme-pattern-lotus': `url("${theme.ornaments.patterns.lotus}")`,
|
||||
'--theme-motion-arrive': theme.motion.arrive,
|
||||
'--theme-motion-sheen': theme.motion.sheen,
|
||||
'--theme-motion-featured': theme.motion.featured,
|
||||
|
||||
@@ -4,6 +4,7 @@ const divider = '/assets/floral-divider.svg'
|
||||
const cluster = '/assets/floral-cluster.svg'
|
||||
const vine = '/assets/floral-vine.svg'
|
||||
const edge = '/assets/moonlit-edge.svg'
|
||||
const lotus = '/assets/lotus-mark.svg'
|
||||
|
||||
/** The original ancient-style glass renderer, captured as the first theme. */
|
||||
export const jadeScrollTheme: OverlayThemeDefinition = {
|
||||
@@ -38,7 +39,7 @@ export const jadeScrollTheme: OverlayThemeDefinition = {
|
||||
'star',
|
||||
'floret',
|
||||
],
|
||||
patterns: { divider, cluster, vine, edge },
|
||||
patterns: { divider, cluster, vine, edge, lotus },
|
||||
},
|
||||
motion: {
|
||||
arrive: 'arrive',
|
||||
|
||||
@@ -4,6 +4,7 @@ const divider = '/assets/floral-divider.svg'
|
||||
const cluster = '/assets/floral-cluster.svg'
|
||||
const vine = '/assets/floral-vine.svg'
|
||||
const edge = '/assets/moonlit-edge.svg'
|
||||
const lotus = '/assets/lotus-mark.svg'
|
||||
|
||||
/**
|
||||
* A deliberately sparse, text-led theme inspired by moonlight on still water.
|
||||
@@ -31,7 +32,7 @@ export const moonlitWaterTheme: OverlayThemeDefinition = {
|
||||
ornaments: {
|
||||
variantCount: 4,
|
||||
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
|
||||
patterns: { divider, cluster, vine, edge },
|
||||
patterns: { divider, cluster, vine, edge, lotus },
|
||||
},
|
||||
motion: {
|
||||
arrive: 'moonlit-line-arrive',
|
||||
|
||||
@@ -35,6 +35,7 @@ export type OverlayThemeDefinition = {
|
||||
cluster: string
|
||||
vine: string
|
||||
edge: string
|
||||
lotus: string
|
||||
}
|
||||
}
|
||||
motion: {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type OverlaySettings = {
|
||||
viewerColor: string | null
|
||||
danmakuColor: string | null
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
showDanmaku: boolean
|
||||
showEnter: boolean
|
||||
showGift: boolean
|
||||
@@ -40,6 +41,7 @@ export const defaultOverlaySettings: OverlaySettings = {
|
||||
viewerColor: null,
|
||||
danmakuColor: null,
|
||||
fontScale: 140,
|
||||
decorationLineWeight: 160,
|
||||
showDanmaku: true,
|
||||
showEnter: true,
|
||||
showGift: true,
|
||||
@@ -66,6 +68,7 @@ export type SongRequestSettings = {
|
||||
requesterColor: string | null
|
||||
songTitleColor: string | null
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
scrollSpeedPixelsPerSecond: number
|
||||
edgePauseSeconds: number
|
||||
/** Zero keeps the corresponding business limit disabled. */
|
||||
@@ -81,6 +84,7 @@ export const defaultSongRequestSettings: SongRequestSettings = {
|
||||
requesterColor: null,
|
||||
songTitleColor: null,
|
||||
fontScale: 100,
|
||||
decorationLineWeight: 160,
|
||||
scrollSpeedPixelsPerSecond: 28,
|
||||
edgePauseSeconds: 2,
|
||||
maxQueueSize: 0,
|
||||
@@ -169,6 +173,7 @@ export type GiftMenuSettings = {
|
||||
pageIntervalMs: number
|
||||
highlightDurationMs: number
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
motionIntensity: number
|
||||
lowPerformanceMode: boolean
|
||||
}
|
||||
@@ -184,6 +189,7 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
|
||||
pageIntervalMs: 6_000,
|
||||
highlightDurationMs: 3_800,
|
||||
fontScale: 100,
|
||||
decorationLineWeight: 160,
|
||||
motionIntensity: 78,
|
||||
lowPerformanceMode: false,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Short-lived, tenant-owned TOTP replacement enrollments.
|
||||
--
|
||||
-- The current TOTP secret remains authoritative until the replacement secret
|
||||
-- has produced a valid code. Raw enrollment tokens are never persisted, and
|
||||
-- pending secrets use the same authenticated encryption boundary as account
|
||||
-- TOTP secrets.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_totp_resets (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
enrollment_token_digest BYTEA NOT NULL UNIQUE
|
||||
CHECK (octet_length(enrollment_token_digest) = 32),
|
||||
totp_secret_ciphertext BYTEA NOT NULL
|
||||
CHECK (octet_length(totp_secret_ciphertext) >= 16),
|
||||
totp_secret_nonce BYTEA NOT NULL
|
||||
CHECK (octet_length(totp_secret_nonce) = 24),
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (user_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pending_totp_resets_expiry_idx
|
||||
ON pending_totp_resets(expires_at);
|
||||
|
||||
ALTER TABLE pending_totp_resets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE pending_totp_resets FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS pending_totp_resets_owner ON pending_totp_resets;
|
||||
CREATE POLICY pending_totp_resets_owner ON pending_totp_resets
|
||||
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
|
||||
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
|
||||
@@ -301,6 +301,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
|
||||
),
|
||||
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
|
||||
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
|
||||
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
|
||||
] {
|
||||
let applied = transaction
|
||||
.query_one(
|
||||
|
||||
@@ -891,6 +891,251 @@ impl AuthService {
|
||||
Ok(codes)
|
||||
}
|
||||
|
||||
/// Begin replacement of the current account's TOTP secret after explicit
|
||||
/// step-up authentication. The existing secret remains valid until
|
||||
/// `totp_reset_confirm` commits the replacement.
|
||||
pub async fn totp_reset_start(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
current_code: &str,
|
||||
) -> Result<TotpResetStart, AuthError> {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT username,room_id,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \
|
||||
FROM users WHERE id=$1 AND status='active' FOR UPDATE",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
let username: String = row.get(0);
|
||||
let room_id: String = row.get(1);
|
||||
let proof_kind = if is_totp_code(current_code) {
|
||||
let secret = self.cipher.decrypt(
|
||||
&EncryptedSecret {
|
||||
ciphertext: row.get(2),
|
||||
nonce: row.get(3),
|
||||
},
|
||||
format!("user-totp:{user_id}").as_bytes(),
|
||||
)?;
|
||||
let accepted_step = accepted_totp_step(
|
||||
&secret,
|
||||
&self.issuer,
|
||||
&username,
|
||||
current_code,
|
||||
Utc::now().timestamp(),
|
||||
row.get(4),
|
||||
)?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE users SET last_totp_step=$1,updated_at=now() \
|
||||
WHERE id=$2 AND (last_totp_step IS NULL OR last_totp_step<$1)",
|
||||
&[&accepted_step, &user_id],
|
||||
)
|
||||
.await?;
|
||||
if changed != 1 {
|
||||
return Err(AuthError::TotpReplay);
|
||||
}
|
||||
"totp"
|
||||
} else {
|
||||
let recovery_digest = token_digest(current_code.trim());
|
||||
let recovery_id = transaction
|
||||
.query_opt(
|
||||
"SELECT id FROM recovery_codes \
|
||||
WHERE user_id=$1 AND code_digest=$2 AND consumed_at IS NULL FOR UPDATE",
|
||||
&[&user_id, &recovery_digest],
|
||||
)
|
||||
.await?
|
||||
.ok_or(AuthError::InvalidCredentials)?
|
||||
.get::<_, Uuid>(0);
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE recovery_codes SET consumed_at=now() WHERE id=$1",
|
||||
&[&recovery_id],
|
||||
)
|
||||
.await?;
|
||||
"recovery"
|
||||
};
|
||||
|
||||
let reset_id = Uuid::new_v4();
|
||||
let enrollment_token = random_token("totp-reset", 32);
|
||||
let enrollment_digest = token_digest(&enrollment_token);
|
||||
let secret = Secret::generate_secret();
|
||||
let secret_bytes = secret
|
||||
.to_bytes()
|
||||
.map_err(|error| AuthError::Totp(error.to_string()))?;
|
||||
let encoded = secret.to_encoded();
|
||||
let encoded_secret = match &encoded {
|
||||
Secret::Encoded(value) => value.clone(),
|
||||
Secret::Raw(_) => unreachable!("to_encoded always returns Secret::Encoded"),
|
||||
};
|
||||
let totp = build_totp(&secret_bytes, &self.issuer, &username)?;
|
||||
let otpauth_uri = totp.get_url();
|
||||
let qr = totp
|
||||
.get_qr_base64()
|
||||
.map_err(|error| AuthError::Totp(error.to_string()))?;
|
||||
let qr_data_url = if qr.starts_with("data:") {
|
||||
qr
|
||||
} else {
|
||||
format!("data:image/png;base64,{qr}")
|
||||
};
|
||||
let encrypted = self.cipher.encrypt(
|
||||
&secret_bytes,
|
||||
format!("pending-totp-reset:{reset_id}").as_bytes(),
|
||||
)?;
|
||||
let expires_at = future_time(self.enrollment_ttl)?;
|
||||
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
transaction
|
||||
.execute(
|
||||
"DELETE FROM pending_totp_resets WHERE user_id=$1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO pending_totp_resets \
|
||||
(id,user_id,enrollment_token_digest,totp_secret_ciphertext,totp_secret_nonce,expires_at) \
|
||||
VALUES($1,$2,$3,$4,$5,$6)",
|
||||
&[
|
||||
&reset_id,
|
||||
&user_id,
|
||||
&enrollment_digest,
|
||||
&encrypted.ciphertext,
|
||||
&encrypted.nonce,
|
||||
&expires_at,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"auth.totp_reset.started",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({"proof":proof_kind}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(TotpResetStart {
|
||||
enrollment_token,
|
||||
username,
|
||||
room_id,
|
||||
secret: encoded_secret,
|
||||
otpauth_uri,
|
||||
qr_data_url,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Confirm a pending replacement, rotate recovery codes, and revoke every
|
||||
/// other browser session atomically. The session performing the reset is
|
||||
/// retained so it can display the one-time recovery codes.
|
||||
pub async fn totp_reset_confirm(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
current_session_id: Uuid,
|
||||
enrollment_token: &str,
|
||||
new_totp_code: &str,
|
||||
) -> Result<Vec<String>, AuthError> {
|
||||
let digest = token_digest(enrollment_token.trim());
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT r.id,r.totp_secret_ciphertext,r.totp_secret_nonce,r.expires_at,\
|
||||
r.failed_attempts,u.username \
|
||||
FROM pending_totp_resets r JOIN users u ON u.id=r.user_id \
|
||||
WHERE r.user_id=$1 AND r.enrollment_token_digest=$2 FOR UPDATE OF r,u",
|
||||
&[&user_id, &digest],
|
||||
)
|
||||
.await?
|
||||
.ok_or(AuthError::TotpResetUnavailable)?;
|
||||
let reset_id: Uuid = row.get(0);
|
||||
let expires_at: DateTime<Utc> = row.get(3);
|
||||
let failed_attempts: i32 = row.get(4);
|
||||
if expires_at <= Utc::now() {
|
||||
return Err(AuthError::TotpResetUnavailable);
|
||||
}
|
||||
let username: String = row.get(5);
|
||||
let secret = self.cipher.decrypt(
|
||||
&EncryptedSecret {
|
||||
ciphertext: row.get(1),
|
||||
nonce: row.get(2),
|
||||
},
|
||||
format!("pending-totp-reset:{reset_id}").as_bytes(),
|
||||
)?;
|
||||
let accepted_step = accepted_totp_step(
|
||||
&secret,
|
||||
&self.issuer,
|
||||
&username,
|
||||
new_totp_code,
|
||||
Utc::now().timestamp(),
|
||||
None,
|
||||
)?;
|
||||
let Some(accepted_step) = accepted_step else {
|
||||
if failed_attempts + 1 >= MAX_PENDING_TOTP_FAILURES {
|
||||
transaction
|
||||
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
|
||||
.await?;
|
||||
} else {
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE pending_totp_resets SET failed_attempts=failed_attempts+1 WHERE id=$1",
|
||||
&[&reset_id],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
return Err(AuthError::InvalidTotp);
|
||||
};
|
||||
|
||||
let encrypted = self
|
||||
.cipher
|
||||
.encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?;
|
||||
let updated = transaction
|
||||
.execute(
|
||||
"UPDATE users SET totp_secret_ciphertext=$1,totp_secret_nonce=$2,\
|
||||
last_totp_step=$3,totp_enrolled_at=now(),auth_version=auth_version+1,updated_at=now() \
|
||||
WHERE id=$4 AND status='active'",
|
||||
&[
|
||||
&encrypted.ciphertext,
|
||||
&encrypted.nonce,
|
||||
&accepted_step,
|
||||
&user_id,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if updated != 1 {
|
||||
return Err(AuthError::TotpResetUnavailable);
|
||||
}
|
||||
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
||||
let revoked_sessions = transaction
|
||||
.execute(
|
||||
"UPDATE user_sessions SET revoked_at=now() \
|
||||
WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL",
|
||||
&[&user_id, ¤t_session_id],
|
||||
)
|
||||
.await?;
|
||||
transaction
|
||||
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
|
||||
.await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"auth.totp_reset.completed",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({"revokedSessions":revoked_sessions}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(recovery_codes)
|
||||
}
|
||||
|
||||
pub async fn authenticate_session(
|
||||
&self,
|
||||
raw_session_token: &str,
|
||||
@@ -1510,6 +1755,11 @@ fn accepted_totp_step(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_totp_code(code: &str) -> bool {
|
||||
let code = code.trim();
|
||||
code.len() == TOTP_DIGITS && code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
async fn require_system_admin(
|
||||
transaction: &Transaction<'_>,
|
||||
user_id: Uuid,
|
||||
@@ -1790,6 +2040,18 @@ pub struct RegistrationStart {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TotpResetStart {
|
||||
pub enrollment_token: String,
|
||||
pub username: String,
|
||||
pub room_id: String,
|
||||
pub secret: String,
|
||||
pub otpauth_uri: String,
|
||||
pub qr_data_url: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegistrationComplete {
|
||||
@@ -1847,6 +2109,7 @@ pub enum AuthError {
|
||||
Forbidden,
|
||||
InvitationUnavailable,
|
||||
EnrollmentUnavailable,
|
||||
TotpResetUnavailable,
|
||||
AccountUnavailable,
|
||||
RoomUnavailable,
|
||||
InvalidTotp,
|
||||
@@ -1879,6 +2142,9 @@ impl fmt::Display for AuthError {
|
||||
formatter,
|
||||
"registration enrollment is invalid or unavailable"
|
||||
),
|
||||
Self::TotpResetUnavailable => {
|
||||
write!(formatter, "TOTP reset is invalid or unavailable")
|
||||
}
|
||||
Self::AccountUnavailable => write!(formatter, "username or room is unavailable"),
|
||||
Self::RoomUnavailable => write!(formatter, "room is already assigned"),
|
||||
Self::InvalidTotp => write!(formatter, "invalid TOTP code"),
|
||||
@@ -1968,6 +2234,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_up_proof_only_classifies_exact_six_digit_totp_codes() {
|
||||
assert!(is_totp_code(" 012345 "));
|
||||
assert!(!is_totp_code("12345"));
|
||||
assert!(!is_totp_code("1234567"));
|
||||
assert!(!is_totp_code("12a456"));
|
||||
assert!(!is_totp_code("recovery-example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_and_room_validation_are_canonical() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -133,6 +133,7 @@ struct OverlayFileConfig {
|
||||
viewer_color: Option<String>,
|
||||
danmaku_color: Option<String>,
|
||||
font_scale: Option<u16>,
|
||||
decoration_line_weight: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
unfold_duration_ms: Option<u16>,
|
||||
@@ -333,6 +334,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
viewer_color: file.viewer_color.or(default.viewer_color),
|
||||
danmaku_color: file.danmaku_color.or(default.danmaku_color),
|
||||
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
||||
decoration_line_weight: file
|
||||
.decoration_line_weight
|
||||
.unwrap_or(default.decoration_line_weight),
|
||||
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
||||
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
||||
show_gift: file.events.gift.unwrap_or(default.show_gift),
|
||||
|
||||
@@ -93,6 +93,8 @@ pub struct GiftMenuSettings {
|
||||
pub page_interval_ms: u16,
|
||||
pub highlight_duration_ms: u16,
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_decoration_line_weight")]
|
||||
pub decoration_line_weight: u16,
|
||||
pub motion_intensity: u8,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
@@ -110,6 +112,7 @@ impl Default for GiftMenuSettings {
|
||||
page_interval_ms: default_page_interval_ms(),
|
||||
highlight_duration_ms: 3_800,
|
||||
font_scale: 100,
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
motion_intensity: 78,
|
||||
low_performance_mode: false,
|
||||
}
|
||||
@@ -140,11 +143,16 @@ impl GiftMenuSettings {
|
||||
self.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000);
|
||||
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
|
||||
self.font_scale = self.font_scale.clamp(50, 220);
|
||||
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.motion_intensity = self.motion_intensity.min(100);
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let count = normalized.chars().count();
|
||||
@@ -385,6 +393,7 @@ mod tests {
|
||||
page_interval_ms: u16::MAX,
|
||||
font_scale: 1,
|
||||
font_brightness: 1,
|
||||
decoration_line_weight: 1,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
let sanitized = definition
|
||||
@@ -396,6 +405,7 @@ mod tests {
|
||||
assert_eq!(sanitized["pageIntervalMs"], 30_000);
|
||||
assert_eq!(sanitized["fontScale"], 50);
|
||||
assert_eq!(sanitized["fontBrightness"], 70);
|
||||
assert_eq!(sanitized["decorationLineWeight"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -403,10 +413,15 @@ mod tests {
|
||||
let definition = GiftMenuDefinition;
|
||||
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
|
||||
settings.as_object_mut().unwrap().remove("pageIntervalMs");
|
||||
settings
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("decorationLineWeight");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
|
||||
assert_eq!(sanitized["decorationLineWeight"], 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,6 +54,8 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/v1/auth/register/confirm", post(enrollment_confirm))
|
||||
.route("/api/v1/auth/login", post(login))
|
||||
.route("/api/v1/auth/logout", post(logout))
|
||||
.route("/api/v1/auth/totp/reset/start", post(totp_reset_start))
|
||||
.route("/api/v1/auth/totp/reset/confirm", post(totp_reset_confirm))
|
||||
.route(
|
||||
"/api/v1/invitations",
|
||||
get(list_invitations).post(create_invitation),
|
||||
@@ -433,6 +435,56 @@ async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Res
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TotpResetStartRequest {
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn totp_reset_start(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<TotpResetStartRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
consume_enrollment_budget(&state, &headers).await?;
|
||||
let enrollment = state
|
||||
.auth
|
||||
.totp_reset_start(session.user.id, &body.code)
|
||||
.await?;
|
||||
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TotpResetConfirmRequest {
|
||||
enrollment_token: String,
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn totp_reset_confirm(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<TotpResetConfirmRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
consume_enrollment_budget(&state, &headers).await?;
|
||||
let recovery_codes = state
|
||||
.auth
|
||||
.totp_reset_confirm(
|
||||
session.user.id,
|
||||
session.session_id,
|
||||
&body.enrollment_token,
|
||||
&body.code,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"recoveryCodes": recovery_codes,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateInvitationRequest {
|
||||
@@ -1564,6 +1616,11 @@ impl From<AuthError> for ApiError {
|
||||
"enrollment_unavailable",
|
||||
error.to_string(),
|
||||
),
|
||||
AuthError::TotpResetUnavailable => Self::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"totp_reset_unavailable",
|
||||
error.to_string(),
|
||||
),
|
||||
AuthError::AccountUnavailable => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
"account_unavailable",
|
||||
|
||||
@@ -46,6 +46,9 @@ pub struct OverlaySettings {
|
||||
pub danmaku_color: Option<String>,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
/// Relative weight of theme borders, rules, and ornamental edge artwork.
|
||||
#[serde(default = "default_decoration_line_weight")]
|
||||
pub decoration_line_weight: u16,
|
||||
pub show_danmaku: bool,
|
||||
pub show_enter: bool,
|
||||
pub show_gift: bool,
|
||||
@@ -77,6 +80,7 @@ impl Default for OverlaySettings {
|
||||
viewer_color: None,
|
||||
danmaku_color: None,
|
||||
font_scale: default_font_scale(),
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
show_gift: true,
|
||||
@@ -104,6 +108,7 @@ impl OverlaySettings {
|
||||
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
|
||||
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
|
||||
self.font_scale = self.font_scale.clamp(50, 300);
|
||||
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
||||
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
||||
self.motion_intensity = self.motion_intensity.min(100);
|
||||
@@ -128,6 +133,10 @@ fn default_font_scale() -> u16 {
|
||||
140
|
||||
}
|
||||
|
||||
fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
fn default_unfold_duration_ms() -> u16 {
|
||||
1_000
|
||||
}
|
||||
@@ -577,6 +586,7 @@ mod tests {
|
||||
font_brightness: 1,
|
||||
viewer_color: Some(" #a1b2c3 ".into()),
|
||||
danmaku_color: Some("not-css".into()),
|
||||
decoration_line_weight: 999,
|
||||
max_visible: 99,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
@@ -592,6 +602,7 @@ mod tests {
|
||||
assert_eq!(settings.font_brightness, 70);
|
||||
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.decoration_line_weight, 300);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
@@ -618,6 +629,7 @@ mod tests {
|
||||
object.remove("fontBrightness");
|
||||
object.remove("viewerColor");
|
||||
object.remove("danmakuColor");
|
||||
object.remove("decorationLineWeight");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
@@ -629,6 +641,7 @@ mod tests {
|
||||
assert_eq!(settings.viewer_color, None);
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.font_scale, 140);
|
||||
assert_eq!(settings.decoration_line_weight, 160);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
assert_eq!(settings.particle_speed, 100);
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct SongRequestSettings {
|
||||
pub song_title_color: Option<String>,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_decoration_line_weight")]
|
||||
pub decoration_line_weight: u16,
|
||||
#[serde(default = "default_scroll_speed")]
|
||||
pub scroll_speed_pixels_per_second: u16,
|
||||
#[serde(default = "default_edge_pause")]
|
||||
@@ -70,6 +72,7 @@ impl Default for SongRequestSettings {
|
||||
requester_color: None,
|
||||
song_title_color: None,
|
||||
font_scale: default_font_scale(),
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
scroll_speed_pixels_per_second: default_scroll_speed(),
|
||||
edge_pause_seconds: default_edge_pause(),
|
||||
max_queue_size: 0,
|
||||
@@ -82,6 +85,7 @@ impl Default for SongRequestSettings {
|
||||
impl SongRequestSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.font_scale = self.font_scale.clamp(50, 250);
|
||||
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.requester_color = sanitize_optional_hex_color(self.requester_color);
|
||||
self.song_title_color = sanitize_optional_hex_color(self.song_title_color);
|
||||
@@ -106,6 +110,10 @@ const fn default_font_scale() -> u16 {
|
||||
100
|
||||
}
|
||||
|
||||
const fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
const fn default_scroll_speed() -> u16 {
|
||||
28
|
||||
}
|
||||
@@ -1098,6 +1106,7 @@ mod tests {
|
||||
font_brightness: u16::MAX,
|
||||
requester_color: Some(" #a1b2c3 ".into()),
|
||||
song_title_color: Some("transparent".into()),
|
||||
decoration_line_weight: 999,
|
||||
scroll_speed_pixels_per_second: 0,
|
||||
edge_pause_seconds: 200,
|
||||
max_queue_size: u32::MAX,
|
||||
@@ -1110,6 +1119,7 @@ mod tests {
|
||||
assert_eq!(bounded.font_brightness, 180);
|
||||
assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3"));
|
||||
assert_eq!(bounded.song_title_color, None);
|
||||
assert_eq!(bounded.decoration_line_weight, 300);
|
||||
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
|
||||
assert_eq!(bounded.edge_pause_seconds, 15);
|
||||
assert_eq!(bounded.max_queue_size, 10_000);
|
||||
|
||||
@@ -100,6 +100,8 @@ font_brightness = 130
|
||||
# danmaku_color = "#C6AE7A"
|
||||
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
|
||||
font_scale = 140
|
||||
# 主题装饰线、边框与上下古风边缘的相对粗细;允许范围 50-300。
|
||||
decoration_line_weight = 160
|
||||
max_visible = 5
|
||||
collapse_after_seconds = 12
|
||||
# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 |
|
||||
| `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 |
|
||||
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||
| `decorationLineWeight` | 50–300% | 边框、分隔线与古风边缘粗细 |
|
||||
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
||||
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
|
||||
@@ -51,13 +52,13 @@ input 只改善交互,不能取代服务端校验。字体 ID 只映射到前
|
||||
|
||||
## 卡片生命周期
|
||||
|
||||
1. 新事件插入队首,以卷轴动画横向展开。
|
||||
1. 新事件追加在可视区域底部,以卷轴动画横向展开;旧事件被向上顶出并裁切。
|
||||
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||
5. 超过 `maxVisible` 的最旧卡才会离开队列。
|
||||
|
||||
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。
|
||||
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。每条普通弹幕右端还会显示主题着色的内置透明莲花图案;渲染时预留文字空间,不会遮挡昵称、正文或表情。
|
||||
|
||||
## 礼物展示
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电
|
||||
|
||||
## OBS 行为
|
||||
|
||||
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet`
|
||||
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、装饰线粗细、高亮时长和动效强度均可调整。`jade-banquet`
|
||||
仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
|
||||
|
||||
命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 设置
|
||||
|
||||
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`scrollSpeedPixelsPerSecond`
|
||||
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`decorationLineWeight`、`scrollSpeedPixelsPerSecond`
|
||||
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
|
||||
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ token。以下规则是实现约束,而不是可选部署建议。
|
||||
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
|
||||
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
|
||||
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
|
||||
- 已登录账户可用当前 TOTP 或未消费恢复码开始短期 TOTP replacement
|
||||
enrollment。旧密钥在新动态码确认前保持有效;确认事务会更换密钥、轮换全部恢复码并撤销除当前会话外的其他网页登录会话。
|
||||
|
||||
## CookieCloud 与 SSRF
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ name = "简体中文"
|
||||
"api.error.enrollment_rate_limited" = "注册尝试过于频繁,请稍后重试。"
|
||||
"api.error.invitation_unavailable" = "邀请码无效、已过期或已使用。"
|
||||
"api.error.enrollment_unavailable" = "注册流程无效或已过期。"
|
||||
"api.error.totp_reset_unavailable" = "TOTP 重置流程无效或已过期,请重新开始。"
|
||||
"api.error.account_unavailable" = "账户不可用或用户名已被使用。"
|
||||
"api.error.song_request_not_found" = "点歌记录不存在。"
|
||||
"api.error.song_request_conflict" = "点歌队列已变化,请刷新后重试。"
|
||||
@@ -152,6 +153,8 @@ name = "简体中文"
|
||||
"settings.use_theme_color" = "跟随主题"
|
||||
"settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。"
|
||||
"settings.font_size" = "字号"
|
||||
"settings.decoration_line_weight" = "装饰线粗细"
|
||||
"settings.decoration_line_weight_description" = "统一调整主题边框、分隔线和上下古风装饰边缘的视觉粗细。"
|
||||
"settings.max_visible" = "最大可见条数"
|
||||
"settings.auto_collapse" = "自动收缩"
|
||||
"settings.unfold_duration" = "卷轴展开时长"
|
||||
@@ -284,6 +287,24 @@ name = "简体中文"
|
||||
"account.eyebrow" = "账户直播源"
|
||||
"account.description" = "配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。"
|
||||
"account.loading" = "正在读取账户直播源…"
|
||||
"security.title" = "账户安全"
|
||||
"security.description" = "为当前登录账户更换 TOTP 验证器;旧密钥会保持有效,直到新动态码确认成功。"
|
||||
"security.totp_reset_warning" = "需要使用当前动态验证码或一个未使用的恢复码再次验证身份。重置成功后,其他网页登录会话将被撤销。"
|
||||
"security.current_totp_code" = "当前 6 位动态验证码"
|
||||
"security.use_recovery_for_reset" = "验证器不可用?使用恢复码重置"
|
||||
"security.start_totp_reset" = "开始更换验证器"
|
||||
"security.preparing_totp" = "正在生成新 TOTP…"
|
||||
"security.totp_reset_start_failed" = "无法开始 TOTP 重置"
|
||||
"security.totp_reset_missing_id" = "服务端没有返回有效的 TOTP 重置标识"
|
||||
"security.old_totp_still_active" = "扫描并确认新二维码前,原有 TOTP 仍然有效;此时取消不会锁定账户。"
|
||||
"security.new_totp_code" = "新验证器中的 6 位动态验证码"
|
||||
"security.confirm_totp_reset" = "确认并启用新 TOTP"
|
||||
"security.cancel_totp_reset" = "取消本次重置"
|
||||
"security.totp_reset_cancelled" = "已取消;原有 TOTP 未被修改。"
|
||||
"security.totp_reset_confirm_failed" = "新动态验证码无效,或重置流程已经过期"
|
||||
"security.recovery_replaced" = "TOTP 已更换。原恢复码全部失效;以下新恢复码仅显示一次,请立即离线保存。"
|
||||
"security.totp_reset_complete" = "新 TOTP 和恢复码已启用。"
|
||||
"security.totp_reset_blocker" = "完成或取消 TOTP 重置,并保存新的一次性恢复码"
|
||||
"song.queue_changing" = "点歌队列正在频繁变化,请稍后重试"
|
||||
"song.queue_load_failed" = "点歌队列读取失败"
|
||||
"song.cancel_confirm" = "确定取消「{title}」吗?"
|
||||
@@ -535,6 +556,7 @@ name = "English"
|
||||
"api.error.enrollment_rate_limited" = "Too many registration attempts. Try again later."
|
||||
"api.error.invitation_unavailable" = "The invitation is invalid, expired, or already used."
|
||||
"api.error.enrollment_unavailable" = "The registration flow is invalid or expired."
|
||||
"api.error.totp_reset_unavailable" = "The TOTP reset is invalid or expired. Start again."
|
||||
"api.error.account_unavailable" = "The account is unavailable or the username is already used."
|
||||
"api.error.song_request_not_found" = "The song request does not exist."
|
||||
"api.error.song_request_conflict" = "The song queue changed. Refresh and try again."
|
||||
@@ -631,6 +653,8 @@ name = "English"
|
||||
"settings.use_theme_color" = "Use theme color"
|
||||
"settings.text_color_description" = "Overrides the theme when selected; reset to follow future theme changes."
|
||||
"settings.font_size" = "Font size"
|
||||
"settings.decoration_line_weight" = "Decoration line weight"
|
||||
"settings.decoration_line_weight_description" = "Adjusts theme borders, dividers, and the upper and lower ornamental edges together."
|
||||
"settings.max_visible" = "Maximum visible items"
|
||||
"settings.auto_collapse" = "Auto-collapse"
|
||||
"settings.unfold_duration" = "Scroll-open duration"
|
||||
@@ -763,6 +787,24 @@ name = "English"
|
||||
"account.eyebrow" = "ACCOUNT LIVE SOURCE"
|
||||
"account.description" = "Configure the room and CookieCloud once for every current and future component in this account."
|
||||
"account.loading" = "Loading account live source…"
|
||||
"security.title" = "Account security"
|
||||
"security.description" = "Replace the TOTP authenticator for the current account. The old secret remains active until the new code is confirmed."
|
||||
"security.totp_reset_warning" = "Verify again with the current TOTP or an unused recovery code. A successful reset revokes every other web login session."
|
||||
"security.current_totp_code" = "Current six-digit TOTP code"
|
||||
"security.use_recovery_for_reset" = "Authenticator unavailable? Reset with a recovery code"
|
||||
"security.start_totp_reset" = "Replace authenticator"
|
||||
"security.preparing_totp" = "Generating new TOTP…"
|
||||
"security.totp_reset_start_failed" = "Could not start the TOTP reset"
|
||||
"security.totp_reset_missing_id" = "The server did not return a valid TOTP reset identifier"
|
||||
"security.old_totp_still_active" = "Your old TOTP remains active until this QR code is confirmed. Cancelling now cannot lock the account."
|
||||
"security.new_totp_code" = "Six-digit code from the new authenticator"
|
||||
"security.confirm_totp_reset" = "Confirm and enable new TOTP"
|
||||
"security.cancel_totp_reset" = "Cancel this reset"
|
||||
"security.totp_reset_cancelled" = "Reset cancelled. The existing TOTP was not changed."
|
||||
"security.totp_reset_confirm_failed" = "The new code is invalid or the reset has expired"
|
||||
"security.recovery_replaced" = "TOTP was replaced and every old recovery code is invalid. Save these new one-time codes offline now; they are shown only once."
|
||||
"security.totp_reset_complete" = "The new TOTP and recovery codes are active."
|
||||
"security.totp_reset_blocker" = "finish or cancel TOTP reset and save the new one-time recovery codes"
|
||||
"song.queue_changing" = "The song queue is changing rapidly. Try again shortly."
|
||||
"song.queue_load_failed" = "Could not load the song queue"
|
||||
"song.cancel_confirm" = "Cancel “{title}”?"
|
||||
|
||||
Reference in New Issue
Block a user