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: '';
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)
|
||||
> .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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user