feat: add sectioned page layout, switches, segmented controls, paging and neutral avatars

This commit is contained in:
DiheChen committed 2026-11-11 11:05:49 +08:00
1 parent 599f1284ec
commit d8e6116fd6
29 files changed
+1018 -38

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ server {
include /tmp/alisten.d/*.conf;
location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ {
location ~ ^/(?:settings(?:/(?:account|appearance|playback|performance|privacy))?|admin(?:/(?:site|registration|users|rooms|sources|media))?|history|playlists(?:/[^/]+)?)?$ {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
+17 -3
View File
@@ -188,6 +188,20 @@ function RoutedLobby({ onEntered }: { onEntered: (ref: string) => void }) {
return <Lobby filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} onEntered={onEntered} />
}
function RoutedSettings() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'settings')
return null
return <SettingsPage section={route.section} />
}
function RoutedAdmin({ onEntered }: { onEntered: (ref: string) => void }) {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'admin')
return null
return <AdminPage section={route.section} q={route.q} page={route.page} sort={route.sort} onEntered={onEntered} />
}
function RoutedHistory() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'history')
@@ -213,7 +227,7 @@ function RoutedGitHubLogin() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'github-login')
return null
return <GitHubLoginPage fragment={route.fragment} onDone={() => navigationStore.replace({ name: 'settings', panel: null })} />
return <GitHubLoginPage fragment={route.fragment} onDone={() => navigationStore.replace({ name: 'settings', section: 'account', panel: null })} />
}
function RoutedSearch() {
@@ -287,7 +301,7 @@ export function App() {
<ErrorBoundary>
<Suspense fallback={pageLoading}>
{page === 'lobby' && <RoutedLobby onEntered={showRoom} />}
{page === 'admin' && <AdminPage onEntered={showRoom} />}
{page === 'admin' && <RoutedAdmin onEntered={showRoom} />}
{page === 'listen' && <RoomGate onBrowse={browse}><ListenPage /></RoomGate>}
{page === 'queue' && <RoomGate onBrowse={browse}><QueuePage /></RoomGate>}
{page === 'search' && <RoomGate onBrowse={browse}><RoutedSearch /></RoomGate>}
@@ -301,7 +315,7 @@ export function App() {
/>
)}
{page === 'my-playlist' && <RoutedMyPlaylist />}
{page === 'settings' && <SettingsPage />}
{page === 'settings' && <RoutedSettings />}
{page === 'shared-playlist' && <RoutedSharedPlaylist />}
{page === 'pair' && <RoutedPair />}
{page === 'github-login' && <RoutedGitHubLogin />}
+15 -2
View File
@@ -4,7 +4,7 @@ import { roomEntryStore } from '@/features/rooms'
import { searchStore } from '@/features/search'
import { canGoBack, canGoForward, currentEntry, goBack, startHistory, writeEntry } from '@/shared/lib/session-history'
import { Store } from '@/shared/lib/store'
import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route'
import { formatRoute, isNewEntry, leaveStep, parseRoute, upStep } from './route'
interface NavigationState {
route: Route
@@ -65,12 +65,25 @@ class NavigationStore extends Store<NavigationState> {
this.#show(route)
}
// 从设置或后台的分类回到分类列表,不新增记录。
up(list: Route): void {
const { position, from } = currentEntry()
if (upStep(list, from, canGoBack(position)) === 'back')
goBack()
else
this.replace(list)
}
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void {
const { panel } = this.state.route
if (page === 'lobby' || page === 'history')
this.navigate({ name: page, filter: '', panel })
else if (page === 'admin' || page === 'settings' || page === 'playlists')
else if (page === 'settings')
this.navigate({ name: page, section: null, panel })
else if (page === 'admin')
this.navigate({ name: page, section: null, panel, q: '', page: 1, sort: 'stored' })
else if (page === 'playlists')
this.navigate({ name: page, panel })
else
this.#openRoomPage(ref => ({ name: page, ref, panel }))
+65 -6
View File
@@ -1,7 +1,9 @@
import type { Route } from './route'
import type { MediaSort } from '@/core/realtime'
import type { AdminSection } from '@/features/admin'
import type { SearchQuery } from '@/features/search'
import { describe, expect, it } from 'vitest'
import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route'
import { formatRoute, isNewEntry, leaveStep, parseRoute, upStep } from './route'
function parse(url: string): Route | null {
return parseRoute(new URL(url, 'https://alisten.test'))
@@ -11,8 +13,12 @@ describe('parseRoute', () => {
it.each<[string, Route]>([
['/', { name: 'lobby', filter: '', panel: null }],
['/?q=+%E5%A4%9C&panel=chat', { name: 'lobby', filter: ' 夜', panel: 'chat' }],
['/admin', { name: 'admin', panel: null }],
['/settings?panel=queue', { name: 'settings', panel: 'queue' }],
['/admin', { name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }],
['/admin/site', { name: 'admin', section: 'site', panel: null, q: '', page: 1, sort: 'stored' }],
['/admin/rooms?q=jazz&page=2&panel=chat', { name: 'admin', section: 'rooms', panel: 'chat', q: 'jazz', page: 2, sort: 'stored' }],
['/admin/media?q=lemon&sort=size&page=3', { name: 'admin', section: 'media', panel: null, q: 'lemon', page: 3, sort: 'size' }],
['/settings?panel=queue', { name: 'settings', section: null, panel: 'queue' }],
['/settings/privacy', { name: 'settings', section: 'privacy', panel: null }],
['/history', { name: 'history', filter: '', panel: null }],
['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }],
['/playlists', { name: 'playlists', panel: null }],
@@ -36,6 +42,9 @@ describe('parseRoute', () => {
it.each([
['/admin?from=menu', '/admin'],
['/admin/users?q=a&page=2&sort=size', '/admin/users'],
['/admin/rooms?sort=size', '/admin/rooms'],
['/admin/media?sort=idle&page=1', '/admin/media?sort=idle'],
['/settings?panel=lyrics', '/settings'],
['/?q=', '/'],
['/r/my-room?panel=lyrics', '/r/my-room'],
@@ -46,15 +55,39 @@ describe('parseRoute', () => {
['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'],
['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'],
['/r/my-room/playlists/netease/123?page=1.5', '/r/my-room/playlists/netease/123'],
['/admin/media?page=99999999999999999999', '/admin/media'],
['/admin/media?page=1e300', '/admin/media'],
['/admin/media?page=9007199254740991', '/admin/media?page=9007199254740991'],
['/r/my-room/playlists/netease/123?page=99999999999999999999', '/r/my-room/playlists/netease/123'],
])('%s 规范化为 %s', (url, canonical) => {
const route = parse(url)
expect(route && formatRoute(route)).toBe(canonical)
})
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
expect(parse(url)).toBeNull()
})
it('解析设置和后台的分类', () => {
expect(parseRoute({ pathname: '/settings/appearance', search: '', hash: '' })).toEqual({ name: 'settings', section: 'appearance', panel: null })
expect(parseRoute({ pathname: '/settings', search: '', hash: '' })).toEqual({ name: 'settings', section: null, panel: null })
expect(parseRoute({ pathname: '/settings/unknown', search: '', hash: '' })).toBeNull()
expect(parseRoute({ pathname: '/admin/media', search: '?q=lemon&sort=size&page=3', hash: '' })).toEqual({ name: 'admin', section: 'media', panel: null, q: 'lemon', page: 3, sort: 'size' })
expect(parseRoute({ pathname: '/admin/media', search: '?sort=bogus&page=0', hash: '' })).toEqual({ name: 'admin', section: 'media', panel: null, q: '', page: 1, sort: 'stored' })
})
it('媒体的搜索词截到接口允许的 100 个字符,按码点计;房间的搜索词在本地筛选,不截', () => {
const long = `${'a'.repeat(99)}🎵🎵`
expect(parse(`/admin/media?q=${encodeURIComponent(long)}`)).toMatchObject({ section: 'media', q: `${'a'.repeat(99)}🎵` })
expect(parse(`/admin/media?q=${'b'.repeat(100)}`)).toMatchObject({ q: 'b'.repeat(100) })
expect(parse(`/admin/rooms?q=${'c'.repeat(150)}`)).toMatchObject({ section: 'rooms', q: 'c'.repeat(150) })
})
it('写回地址时省略默认值', () => {
expect(formatRoute({ name: 'admin', section: 'media', panel: null, q: '', page: 1, sort: 'stored' })).toBe('/admin/media')
expect(formatRoute({ name: 'admin', section: 'rooms', panel: null, q: 'jazz', page: 2, sort: 'stored' })).toBe('/admin/rooms?q=jazz&page=2')
})
it('配对码从片段中读出,写回的地址不再带它', () => {
const route = parse('/pair?panel=chat#ABCD2345')
expect(route).toEqual({ name: 'pair', code: 'ABCD2345', panel: 'chat' })
@@ -80,10 +113,20 @@ describe('isNewEntry', () => {
it('开关侧栏和输入筛选词只替换当前地址', () => {
expect(isNewEntry({ name: 'queue', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: 'chat' })).toBe(false)
expect(isNewEntry({ name: 'settings', panel: null }, { name: 'settings', panel: 'chat' })).toBe(false)
expect(isNewEntry({ name: 'settings', section: null, panel: null }, { name: 'settings', section: null, panel: 'chat' })).toBe(false)
expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false)
})
it('换分类写入新记录,换搜索词、排序和页码只替换当前地址', () => {
const admin = (section: AdminSection | null, q: string, page: number, sort: MediaSort): Route => ({ name: 'admin', section, panel: null, q, page, sort })
expect(isNewEntry({ name: 'settings', section: null, panel: null }, { name: 'settings', section: 'account', panel: null })).toBe(true)
expect(isNewEntry({ name: 'settings', section: 'account', panel: null }, { name: 'settings', section: 'privacy', panel: null })).toBe(true)
expect(isNewEntry({ name: 'settings', section: 'privacy', panel: null }, { name: 'settings', section: null, panel: null })).toBe(true)
expect(isNewEntry(admin('rooms', '', 1, 'stored'), admin('media', '', 1, 'stored'))).toBe(true)
expect(isNewEntry(admin('media', '', 1, 'stored'), admin('media', 'lemon', 1, 'stored'))).toBe(false)
expect(isNewEntry(admin('media', 'lemon', 1, 'stored'), admin('media', 'lemon', 2, 'size'))).toBe(false)
})
it('打开另一个自己的歌单写入新记录,同一个歌单只换侧栏时替换当前地址', () => {
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'b', panel: null })).toBe(true)
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'a', panel: 'queue' })).toBe(false)
@@ -112,11 +155,27 @@ describe('isNewEntry', () => {
})
})
describe('upStep', () => {
const list: Route = { name: 'settings', section: null, panel: null }
it('上一条记录就是分类列表时后退,不新增记录', () => {
expect(upStep(list, '/settings', true)).toBe('back')
expect(upStep({ name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }, '/admin', true)).toBe('back')
})
it('从别的页面直接打开分类、或者没有可以后退的记录时,把当前记录换成分类列表', () => {
expect(upStep(list, '/playlists', true)).toBe('replace')
expect(upStep(list, '/settings/account', true)).toBe('replace')
expect(upStep(list, undefined, true)).toBe('replace')
expect(upStep(list, '/settings', false)).toBe('replace')
})
})
describe('leaveStep', () => {
const leaving = new Set(['my-room', 'a1b2c3'])
it('不在这个房间的页面上时留在原处', () => {
expect(leaveStep({ name: 'admin', panel: null }, leaving, true)).toBe('stay')
expect(leaveStep({ name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }, leaving, true)).toBe('stay')
expect(leaveStep({ name: 'listen', ref: 'other-room', panel: null }, leaving, true)).toBe('stay')
})
+66 -10
View File
@@ -1,6 +1,8 @@
import type { Source } from '@/core/realtime'
import type { MediaSort, Source } from '@/core/realtime'
import type { AdminSection } from '@/features/admin'
import type { SearchSource } from '@/features/history'
import type { SearchQuery } from '@/features/search'
import type { SettingsSection } from '@/features/settings'
import { canOpenPlaylists, defaultSearchSource, keywordSearch } from '@/features/search'
export type RoomPage = 'listen' | 'queue' | 'chat'
@@ -9,8 +11,10 @@ export type Panel = 'queue' | 'history' | 'chat'
// panel 是宽屏右侧的侧栏,和当前页面相同时不显示。
export type Route
= | { name: 'lobby', filter: string, panel: Panel | null }
| { name: 'admin', panel: Panel | null }
| { name: 'settings', panel: Panel | null }
// section 为 null 时地址是 /admin 或 /settings:宽屏显示第一个分类,窄屏显示分类列表。
// q 和 page 只用于后台的房间和媒体,sort 只用于媒体。
| { name: 'admin', section: AdminSection | null, panel: Panel | null, q: string, page: number, sort: MediaSort }
| { name: 'settings', section: SettingsSection | null, panel: Panel | null }
| { name: 'history', filter: string, panel: Panel | null }
| { name: 'playlists', panel: Panel | null }
| { name: 'my-playlist', id: string, panel: Panel | null }
@@ -25,6 +29,20 @@ export type Route
export type Page = Route['name']
const settingsSections: Record<SettingsSection, true> = { account: true, appearance: true, playback: true, performance: true, privacy: true }
const adminSections: Record<AdminSection, true> = { site: true, registration: true, users: true, rooms: true, sources: true, media: true }
const mediaSorts: Record<MediaSort, true> = { stored: true, idle: true, size: true }
// 与接口对媒体搜索词的长度限制一致,按码点计:手改或截断的链接里更长的搜索词会让每次读取都失败。
const mediaQueryMaxLength = 100
function isOneOf<T extends string>(values: Record<T, true>, value: string): value is T {
return Object.hasOwn(values, value)
}
function isPaged(section: AdminSection | null): boolean {
return section === 'rooms' || section === 'media'
}
function parseSource(value: string | null): SearchSource | null {
return value === 'qq' || value === 'netease' ? value : null
}
@@ -34,9 +52,15 @@ function parsePanel(params: URLSearchParams): Panel | null {
return panel === 'queue' || panel === 'history' || panel === 'chat' ? panel : null
}
// 超出安全整数的页码写回地址时会变成另一个数,接口也不接受:当作第 1 页。
function parsePage(params: URLSearchParams): number {
const page = Number(params.get('page'))
return Number.isInteger(page) && page > 1 ? page : 1
return Number.isSafeInteger(page) && page > 1 ? page : 1
}
function parseAdminQuery(section: AdminSection | null, params: URLSearchParams): string {
const q = isPaged(section) ? params.get('q') ?? '' : ''
return section === 'media' ? [...q].slice(0, mediaQueryMaxLength).join('') : q
}
function parseQuery(params: URLSearchParams): SearchQuery {
@@ -69,10 +93,26 @@ export function parseRoute({ pathname, search, hash }: Pick<Location, 'pathname'
const panel = parsePanel(params)
if (pathname === '/')
return { name: 'lobby', filter: params.get('q') ?? '', panel }
if (pathname === '/admin')
return { name: 'admin', panel }
if (pathname === '/settings')
return { name: 'settings', panel }
const settings = /^\/settings(?:\/([^/]+))?$/.exec(pathname)
if (settings) {
const [, section = null] = settings
return section === null || isOneOf(settingsSections, section) ? { name: 'settings', section, panel } : null
}
const admin = /^\/admin(?:\/([^/]+))?$/.exec(pathname)
if (admin) {
const [, section = null] = admin
if (section !== null && !isOneOf(adminSections, section))
return null
const sort = params.get('sort') ?? ''
return {
name: 'admin',
section,
panel,
q: parseAdminQuery(section, params),
page: isPaged(section) ? parsePage(params) : 1,
sort: section === 'media' && isOneOf(mediaSorts, sort) ? sort : 'stored',
}
}
if (pathname === '/history')
return { name: 'history', filter: params.get('q') ?? '', panel }
if (pathname === '/playlists')
@@ -115,6 +155,7 @@ function pathOf(route: Route): string {
return '/'
case 'admin':
case 'settings':
return route.section ? `/${route.name}/${route.section}` : `/${route.name}`
case 'history':
case 'playlists':
return `/${route.name}`
@@ -143,17 +184,27 @@ export function formatRoute(route: Route): string {
writeQuery(route.query, params)
if (route.name === 'playlist' && route.page > 1)
params.set('page', String(route.page))
if (route.name === 'admin' && isPaged(route.section)) {
if (route.q)
params.set('q', route.q)
if (route.section === 'media' && route.sort !== 'stored')
params.set('sort', route.sort)
if (route.page > 1)
params.set('page', String(route.page))
}
if (route.panel)
params.set('panel', route.panel)
const query = params.toString()
return query ? `${pathOf(route)}?${query}` : pathOf(route)
}
// 换页面、进入另一个房间、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面或上一次搜索;
// 同一个页面里换展示方式、开关侧栏和输入筛选词只替换当前地址。
// 换页面、进入另一个房间、换设置或后台的分类、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面、分类或上一次搜索;
// 同一个页面里换展示方式、开关侧栏、输入筛选词、排序和翻页只替换当前地址。
export function isNewEntry(from: Route, to: Route): boolean {
if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref))
return true
if ((from.name === 'settings' && to.name === 'settings') || (from.name === 'admin' && to.name === 'admin'))
return from.section !== to.section
if (from.name === 'my-playlist' && to.name === 'my-playlist')
return from.id !== to.id
if (from.name === 'search' && to.name === 'search')
@@ -165,6 +216,11 @@ export function isNewEntry(from: Route, to: Route): boolean {
return false
}
// 从设置或后台的分类回到分类列表:上一条记录就是列表时后退,否则把当前记录换成列表。两种都不新增记录,返回键不会再回到刚离开的分类。
export function upStep(list: Route, from: string | undefined, canBack: boolean): 'back' | 'replace' {
return canBack && from === formatRoute(list) ? 'back' : 'replace'
}
// 取消进入房间时离开这个房间的地址:在它的页面上就后退,一直退到进入房间之前的页面;没有可以后退的记录(直接打开的房间链接)时换成发现房间;不在它的页面上就留在原处。
export function leaveStep(route: Route, leaving: ReadonlySet<string>, canBack: boolean): 'back' | 'lobby' | 'stay' {
if (!('ref' in route) || !leaving.has(route.ref))
+4 -1
View File
@@ -1,3 +1,4 @@
import type { MediaSort } from '@/core/realtime'
import { LogOut } from 'lucide-react'
import { useState } from 'react'
import { brandStore } from '@/core/brand'
@@ -11,6 +12,8 @@ import { SourceAccounts } from './source-accounts'
import { StoredMedia } from './stored-media'
import './admin.css'
export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media'
function SignIn() {
const [token, setToken] = useState('')
return (
@@ -31,7 +34,7 @@ function SignIn() {
)
}
export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
export function AdminPage({ onEntered }: { section: AdminSection | null, q: string, page: number, sort: MediaSort, onEntered: (ref: string) => void }) {
const signedIn = useStore(adminStore, s => s.signedIn)
const brandName = useStore(brandStore, s => s.name)
return (
+1
View File
@@ -1,2 +1,3 @@
export { AdminPage } from './admin-page'
export type { AdminSection } from './admin-page'
export { adminStore } from './admin-store'
+1
View File
@@ -3,6 +3,7 @@ import { lazyPage } from '@/shared/ui'
export { appearanceStore } from './appearance-store'
export { performanceStore } from './performance-store'
export { profileStore } from './profile-store'
export type { SettingsSection } from './settings-page'
export const SettingsPage = lazyPage(() => import('./settings-page').then(module => module.SettingsPage))
export const PairPage = lazyPage(() => import('./pair-page').then(module => module.PairPage))
export const GitHubLoginPage = lazyPage(() => import('./github-login-page').then(module => module.GitHubLoginPage))
+3 -1
View File
@@ -32,6 +32,8 @@ interface SettingsDraft {
type DraftKey = keyof SettingsDraft
export type SettingsSection = 'account' | 'appearance' | 'playback' | 'performance' | 'privacy'
const themeNames: Record<Theme, string> = { dark: '深色', light: '浅色', system: '跟随系统' }
const levelNames: Record<PerformanceLevel, { name: string, description: string }> = {
@@ -65,7 +67,7 @@ function SettingRow({ id, title, description, field = false, children }: {
)
}
export function SettingsPage() {
export function SettingsPage(_: { section: SettingsSection | null }) {
const headingRef = useRef<HTMLHeadingElement>(null)
const member = useStore(profileStore, s => s.member)
const email = useStore(profileStore, s => s.email)
+74
View File
@@ -0,0 +1,74 @@
import type { InlineSaveState } from './inline-save'
import { describe, expect, it } from 'vitest'
import { inlineSave } from './inline-save'
const idle: InlineSaveState = { draft: '大猫', saved: '大猫', status: 'idle', error: null }
describe('inlineSave', () => {
it('edit 只改 draft', () => {
expect(inlineSave(idle, { type: 'edit', value: '小猫' })).toEqual({ ...idle, draft: '小猫' })
const failed: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }
expect(inlineSave(failed, { type: 'edit', value: '小狗' })).toEqual({ ...failed, draft: '小狗' })
})
it('submit 时 draft 没变则不进入 saving', () => {
expect(inlineSave(idle, { type: 'submit' })).toBe(idle)
const saved: InlineSaveState = { ...idle, status: 'saved' }
expect(inlineSave(saved, { type: 'submit' })).toBe(saved)
})
it('submit 时 draft 改过则进入 saving,并清掉上次的错误', () => {
expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'submit' })).toEqual({ draft: '小猫', saved: '大猫', status: 'saving', error: null })
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'submit' })).toEqual({ draft: '小猫', saved: '大猫', status: 'saving', error: null })
})
it('改回已保存的值再 submit 时清掉错误', () => {
expect(inlineSave({ draft: '大猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'submit' })).toEqual(idle)
})
it('saving 时忽略再次 submit', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'submit' })).toBe(saving)
})
it('succeeded 后 saved = draft,status = saved', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'succeeded', value: '小猫' })).toEqual({ draft: '小猫', saved: '小猫', status: 'saved', error: null })
})
it('保存期间又改了 draft 时,succeeded 保留新的 draft', () => {
const saving: InlineSaveState = { draft: '小猫咪', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'succeeded', value: '小猫' })).toEqual({ draft: '小猫咪', saved: '小猫', status: 'saved', error: null })
})
it('failed 后 status = error,保留 draft 和 error', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'failed', error: '名字太长' })).toEqual({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' })
})
it('不在 saving 时忽略 succeeded 和 failed', () => {
expect(inlineSave(idle, { type: 'succeeded', value: '小猫' })).toBe(idle)
expect(inlineSave(idle, { type: 'failed', error: '名字太长' })).toBe(idle)
})
it('revert 把 draft 恢复为 saved,清掉 error', () => {
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'revert' })).toEqual(idle)
expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'revert' })).toEqual(idle)
})
it('saving 时忽略 revert', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'revert' })).toBe(saving)
})
it('external 在 idle 或 saved 时更新 saved 和 draft', () => {
expect(inlineSave(idle, { type: 'external', value: '老虎' })).toEqual({ ...idle, draft: '老虎', saved: '老虎' })
expect(inlineSave({ ...idle, status: 'saved' }, { type: 'external', value: '老虎' })).toEqual({ draft: '老虎', saved: '老虎', status: 'saved', error: null })
})
it('external 在 saving 或编辑中时只更新 saved', () => {
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'saving', error: null }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'saving', error: null })
expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'idle', error: null })
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'error', error: '名字太长' })
})
})
+45
View File
@@ -0,0 +1,45 @@
// saved 是已经保存的值,draft 是输入框里的值;两者不同就是“编辑中”。
export interface InlineSaveState {
draft: string
saved: string
status: 'idle' | 'saving' | 'saved' | 'error'
error: string | null
}
// succeeded 的 value 是提交的值;external 是外部传入的新值,比如其他设备改了资料。
export type InlineSaveEvent
= | { type: 'edit', value: string }
| { type: 'submit' }
| { type: 'succeeded', value: string }
| { type: 'failed', error: string }
| { type: 'revert' }
| { type: 'external', value: string }
export function inlineSave(state: InlineSaveState, event: InlineSaveEvent): InlineSaveState {
switch (event.type) {
case 'edit':
return { ...state, draft: event.value }
case 'submit':
if (state.status === 'saving')
return state
if (state.draft === state.saved)
return state.status === 'error' ? { ...state, status: 'idle', error: null } : state
return { ...state, status: 'saving', error: null }
case 'succeeded':
return state.status === 'saving' ? { ...state, saved: event.value, status: 'saved', error: null } : state
case 'failed':
return state.status === 'saving' ? { ...state, status: 'error', error: event.error } : state
case 'revert':
// 已经发出的保存撤不回来,等它的结果。
if (state.status === 'saving')
return state
return { ...state, draft: state.saved, status: 'idle', error: null }
case 'external':
if (event.value === state.saved)
return state
// 保存中或者编辑中时不覆盖输入框,免得冲掉用户正在输入的内容。
if (state.status === 'saving' || state.draft !== state.saved)
return { ...state, saved: event.value }
return { ...state, draft: event.value, saved: event.value }
}
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { pageItems } from './page-items'
describe('pageItems', () => {
it('页数不多时列出全部页码', () => {
expect(pageItems(1, 1)).toEqual([1])
expect(pageItems(1, 5)).toEqual([1, 2, 3, 4, 5])
expect(pageItems(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
})
it('页数多时只列出首页、末页和当前页前后各一页,其余用省略号', () => {
expect(pageItems(2, 26)).toEqual([1, 2, 3, 'gap', 26])
expect(pageItems(13, 26)).toEqual([1, 'gap', 12, 13, 14, 'gap', 26])
expect(pageItems(26, 26)).toEqual([1, 'gap', 25, 26])
})
it('省略号只盖住一页时直接显示那一页', () => {
expect(pageItems(4, 8)).toEqual([1, 2, 3, 4, 5, 'gap', 8])
expect(pageItems(5, 8)).toEqual([1, 'gap', 4, 5, 6, 7, 8])
})
})
+20
View File
@@ -0,0 +1,20 @@
// 页码条最多 7 项:首页、省略号、当前页和前后各一页、省略号、末页。不超过 7 页时全部列出;
// 省略号只盖住一页时,直接显示那一页,占的位置一样。
export function pageItems(current: number, totalPages: number): (number | 'gap')[] {
if (totalPages <= 7)
return Array.from({ length: totalPages }, (_, index) => index + 1)
const pages = [...new Set([1, current - 1, current, current + 1, totalPages])]
.filter(page => page >= 1 && page <= totalPages)
.sort((a, b) => a - b)
const items: (number | 'gap')[] = []
let previous = 0
for (const page of pages) {
if (page - previous === 2)
items.push(previous + 1)
else if (page - previous > 2)
items.push('gap')
items.push(page)
previous = page
}
return items
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { toggleSelection } from './range-selection'
const ids = ['a', 'b', 'c', 'd', 'e']
describe('toggleSelection', () => {
it('普通点击切换单个 id,并把它设为锚点', () => {
expect(toggleSelection(new Set(), ids, 'b', null, false)).toEqual({ selected: new Set(['b']), anchor: 'b' })
expect(toggleSelection(new Set(['b', 'c']), ids, 'b', 'c', false)).toEqual({ selected: new Set(['c']), anchor: 'b' })
})
it('按住 Shift 点击时选中锚点到当前 id 之间的全部,含两端,方向不限', () => {
expect(toggleSelection(new Set(['b']), ids, 'd', 'b', true)).toEqual({ selected: new Set(['b', 'c', 'd']), anchor: 'd' })
expect(toggleSelection(new Set(['d']), ids, 'a', 'd', true)).toEqual({ selected: new Set(['a', 'b', 'c', 'd']), anchor: 'a' })
})
it('按住 Shift 点击时保留范围之外已经选中的 id', () => {
expect(toggleSelection(new Set(['a', 'c']), ids, 'e', 'c', true)).toEqual({ selected: new Set(['a', 'c', 'd', 'e']), anchor: 'e' })
})
it('没有锚点时的 Shift 点击等同于普通点击', () => {
expect(toggleSelection(new Set(), ids, 'c', null, true)).toEqual({ selected: new Set(['c']), anchor: 'c' })
expect(toggleSelection(new Set(['c']), ids, 'c', null, true)).toEqual({ selected: new Set(), anchor: 'c' })
})
it('锚点不在当前列表里时的 Shift 点击等同于普通点击', () => {
expect(toggleSelection(new Set(), ids, 'c', 'z', true)).toEqual({ selected: new Set(['c']), anchor: 'c' })
})
it('不修改传入的集合', () => {
const selected = new Set(['a'])
toggleSelection(selected, ids, 'c', 'a', true)
expect(selected).toEqual(new Set(['a']))
})
})
+18
View File
@@ -0,0 +1,18 @@
// 锚点是上一次点击的 id。按住 Shift 点击时选中锚点到这一项之间的全部,不取消范围外已经选中的项;
// 没有锚点,或者锚点已经不在列表里(比如翻了页)时,和普通点击一样只切换这一项。
export function toggleSelection(selected: ReadonlySet<string>, ids: readonly string[], id: string, anchor: string | null, shift: boolean): { selected: Set<string>, anchor: string } {
const next = new Set(selected)
const from = anchor === null ? -1 : ids.indexOf(anchor)
const to = ids.indexOf(id)
if (shift && from !== -1 && to !== -1) {
for (const item of ids.slice(Math.min(from, to), Math.max(from, to) + 1))
next.add(item)
}
else if (next.has(id)) {
next.delete(id)
}
else {
next.add(id)
}
return { selected: next, anchor: id }
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { rovingIndex } from './roving'
describe('rovingIndex', () => {
it('← 和 ↑ 去上一项,→ 和 ↓ 去下一项,到头之后从另一头接着走', () => {
expect(rovingIndex('ArrowLeft', 1, 3)).toBe(0)
expect(rovingIndex('ArrowUp', 1, 3)).toBe(0)
expect(rovingIndex('ArrowRight', 1, 3)).toBe(2)
expect(rovingIndex('ArrowDown', 1, 3)).toBe(2)
expect(rovingIndex('ArrowUp', 0, 3)).toBe(2)
expect(rovingIndex('ArrowDown', 2, 3)).toBe(0)
})
it('按 Home 和 End 去第一项和最后一项', () => {
expect(rovingIndex('Home', 2, 3)).toBe(0)
expect(rovingIndex('End', 0, 3)).toBe(2)
})
it('其他按键和空的组不处理', () => {
expect(rovingIndex('Enter', 1, 3)).toBeNull()
expect(rovingIndex(' ', 1, 3)).toBeNull()
expect(rovingIndex('ArrowDown', 0, 0)).toBeNull()
})
})
+19
View File
@@ -0,0 +1,19 @@
// 单选组里方向键要去的那一项,和原生的单选按钮一样:←/↑ 和 →/↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。
export function rovingIndex(key: string, index: number, count: number): number | null {
if (!count)
return null
switch (key) {
case 'ArrowLeft':
case 'ArrowUp':
return (index - 1 + count) % count
case 'ArrowRight':
case 'ArrowDown':
return (index + 1) % count
case 'Home':
return 0
case 'End':
return count - 1
default:
return null
}
}
+35 -7
View File
@@ -100,6 +100,12 @@ async function open(state: unknown = null) {
get href() {
return history.current.url
},
get pathname() {
return new URL(history.current.url, 'https://alisten.test').pathname
},
get search() {
return new URL(history.current.url, 'https://alisten.test').search
},
},
addEventListener: (type: string, listener: EventListener) => history.events.addEventListener(type, listener),
})
@@ -125,7 +131,7 @@ describe('打开页面', () => {
const { firstOpen, history, currentEntry } = await open({ index: 3, title: '房间' })
expect(firstOpen).toBe(false)
expect(history.state).toEqual({ index: 3, title: '房间' })
expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间' })
expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间', from: undefined })
})
it('刷新时弹窗已经关闭,标记记录改作页面的记录', async () => {
@@ -152,10 +158,10 @@ describe('写入记录', () => {
writeEntry('/a', false, '甲')
writeEntry('/b', false)
expect(history.urls).toEqual(['/start', '/a', '/b'])
expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined })
expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined, from: '/a' })
history.press()
expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲' })
expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲', from: '/start' })
expect(log).toEqual(['notify'])
})
@@ -165,7 +171,7 @@ describe('写入记录', () => {
expect(history.state).toEqual({ index: 1, title: '房间' })
writeEntry('/b', true, '搜索')
expect(history.urls).toEqual(['/b'])
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索' })
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索', from: undefined })
})
it('浏览器限制调用频率时地址暂时落后,序号不变,页面照常', async () => {
@@ -173,7 +179,7 @@ describe('写入记录', () => {
history.failure = new DOMException('Too many calls', 'SecurityError')
expect(() => writeEntry('/a', false)).not.toThrow()
expect(() => writeEntry('/b', true, '甲')).not.toThrow()
expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined })
expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined, from: undefined })
history.failure = null
writeEntry('/c', false)
@@ -190,6 +196,28 @@ describe('写入记录', () => {
expect(log).toEqual(['notify'])
})
it('新记录记下写入时的地址,也就是它前面那条记录的地址;替换时沿用,刷新之后还在', async () => {
const { writeEntry, currentEntry, history } = await open()
writeEntry('/settings', true)
writeEntry('/settings/appearance', false)
expect(currentEntry().from).toBe('/settings')
writeEntry('/admin/media?q=a', false)
writeEntry('/admin/media?q=ab', true)
expect(currentEntry().from).toBe('/settings/appearance')
expect(history.state).toEqual({ index: 2, from: '/settings/appearance' })
history.press()
expect(currentEntry().from).toBe('/settings')
})
it('刷新时沿用记录里的来处,别的脚本写入的来处不用', async () => {
expect((await open({ index: 2, from: '/settings' })).currentEntry().from).toBe('/settings')
const other = await open({ index: 2, from: 3 })
expect(other.currentEntry().from).toBeUndefined()
expect(other.history.state).toEqual({ index: 2, from: 3 })
expect((await open({ index: 2, from: '/settings', modal: true })).history.state).toEqual({ index: 2, from: '/settings' })
})
it('其他错误照常抛出', async () => {
const { writeEntry, history } = await open()
history.failure = new DOMException('bad', 'DataCloneError')
@@ -275,7 +303,7 @@ describe('弹窗', () => {
history.settle()
expect(history.urls).toEqual(['/start', '/next'])
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页' })
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页', from: '/start' })
})
it('弹窗开着时替换地址:先退到页面的记录再替换,然后压回标记', async () => {
@@ -296,7 +324,7 @@ describe('弹窗', () => {
registerModalHistory(() => {})
writeEntry('/room', false)
expect(history.urls).toEqual(['/start', '/room'])
expect(history.state).toEqual({ index: 1 })
expect(history.state).toEqual({ index: 1, from: '/start' })
expect(currentEntry().position).toEqual({ index: 1, last: 1 })
})
+12 -3
View File
@@ -27,6 +27,8 @@ export function canGoForward(at: Position): boolean {
interface EntryState {
index: number
title?: string
// 写入这条记录时所在的地址,也就是后退会回到的那条记录的地址。
from?: string
modal?: true
}
@@ -47,12 +49,14 @@ const queued: Write[] = []
function readEntry(state: unknown): EntryState | null {
if (typeof state !== 'object' || state === null)
return null
const { index, title, modal } = state as Record<string, unknown>
const { index, title, from, modal } = state as Record<string, unknown>
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0)
return null
const entry: EntryState = { index }
if (typeof title === 'string')
entry.title = title
if (typeof from === 'string')
entry.from = from
if (modal === true)
entry.modal = true
return entry
@@ -116,6 +120,8 @@ export function startHistory(onChange: () => void): boolean {
current = { index: entry?.index ?? 0 }
if (entry?.title !== undefined)
current.title = entry.title
if (entry?.from !== undefined)
current.from = entry.from
// 刷新时弹窗已经关闭,它留下的标记记录改作页面的记录。
if (!entry || entry.modal)
window.history.replaceState(current, '')
@@ -124,8 +130,8 @@ export function startHistory(onChange: () => void): boolean {
return entry === null
}
export function currentEntry(): { position: Position, title: string | undefined } {
return { position, title: current.title }
export function currentEntry(): { position: Position, title: string | undefined, from: string | undefined } {
return { position, title: current.title, from: current.from }
}
// 后退一个页面。弹窗的标记记录压在当前页面上(或者正在退掉它)时只退到页面的记录:落到那里时导航会收到通知,由它决定要不要接着后退。
@@ -153,6 +159,9 @@ export function writeEntry(url: string, replace: boolean, title?: string): void
const entryTitle = replace ? title ?? current.title : title
if (entryTitle !== undefined)
entry.title = entryTitle
const from = replace ? current.from : window.location.pathname + window.location.search
if (from !== undefined)
entry.from = from
// 在大厅的加入、创建房间弹窗里换页面时,弹窗随旧页面一起关闭,新页面的记录直接顶替标记记录。
const written = write(() => replace || current.modal
? window.history.replaceState(entry, '', url)
+7
View File
@@ -1,19 +1,26 @@
import './select.css'
import './ui.css'
import './section-layout.css'
export { Artwork } from './artwork'
export { Avatar } from './avatar'
export { ConfirmDialog } from './confirm-dialog'
export { EmptyState } from './empty-state'
export { AsyncIconButton, IconButton } from './icon-button'
export { InlineTextField } from './inline-text-field'
export { lazyPage } from './lazy-page'
export { Modal } from './modal'
export { notificationStore } from './notification-store'
export { Notifications } from './notifications'
export { Pagination } from './pagination'
export { PlayingBars } from './playing-bars'
export { SectionLayout } from './section-layout'
export type { SectionItem } from './section-layout'
export { SegmentedControl } from './segmented-control'
export { Select } from './select'
export type { SelectOption } from './select'
export { Skeleton } from './skeleton'
export { SongArtwork } from './song-artwork'
export { SongList } from './song-list'
export type { SongAction, SongItem } from './song-list'
export { Switch } from './switch'
+103
View File
@@ -0,0 +1,103 @@
import type { InlineSaveEvent, InlineSaveState } from '@/shared/lib/inline-save'
import { Check } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { inlineSave } from '@/shared/lib/inline-save'
const savedNoticeMs = 1500
// onSave 失败时抛出 Error,它的 message 就地显示在输入框下面。
export function InlineTextField({ label, value, onSave, placeholder, maxLength, type = 'text' }: {
label: string
value: string
onSave: (value: string) => Promise<void>
placeholder?: string
maxLength?: number
type?: 'text' | 'email'
}) {
const errorId = useId()
const [state, setState] = useState<InlineSaveState>({ draft: value, saved: value, status: 'idle', error: null })
const [seenValue, setSeenValue] = useState(value)
const [savedNotice, setSavedNotice] = useState(0)
// state 要到下一次渲染才更新,回车之后紧跟着失焦时,由这个 ref 阻止重复保存。
const savingRef = useRef(false)
const apply = (event: InlineSaveEvent) => setState(current => inlineSave(current, event))
if (value !== seenValue) {
setSeenValue(value)
apply({ type: 'external', value })
}
useEffect(() => {
if (!savedNotice)
return
const timer = setTimeout(setSavedNotice, savedNoticeMs, 0)
return () => clearTimeout(timer)
}, [savedNotice])
const submit = async () => {
if (savingRef.current || inlineSave(state, { type: 'submit' }).status !== 'saving') {
apply({ type: 'submit' })
return
}
savingRef.current = true
const submitted = state.draft
const savedBefore = state.saved
apply({ type: 'submit' })
try {
await onSave(submitted)
setState((current) => {
const done = inlineSave(current, { type: 'succeeded', value: submitted })
// 保存期间外部传入了新值时以它为准:服务端可能改写了提交的值,比如去掉首尾空白。
return current.saved === savedBefore ? done : inlineSave(done, { type: 'external', value: current.saved })
})
setSavedNotice(count => count + 1)
}
catch (error) {
apply({ type: 'failed', error: error instanceof Error && error.message ? error.message : '没有保存,再试一次。' })
}
finally {
savingRef.current = false
}
}
const noticeVisible = state.status === 'saving' || (state.status === 'saved' && savedNotice > 0 && state.draft === state.saved)
return (
<div className="inline-text-field">
<div className="inline-text-field-box">
<input
type={type}
value={state.draft}
aria-label={label}
placeholder={placeholder}
maxLength={maxLength}
aria-invalid={state.status === 'error'}
aria-describedby={state.error ? errorId : undefined}
aria-busy={state.status === 'saving'}
onChange={event => apply({ type: 'edit', value: event.target.value })}
onBlur={() => void submit()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
}
// 没有可恢复的内容时不拦截 Esc,外层的对话框照常关闭。
else if (event.key === 'Escape' && (state.draft !== state.saved || state.error)) {
event.preventDefault()
apply({ type: 'revert' })
}
}}
/>
<span className="inline-text-field-status text-ui-caption" role="status" data-visible={noticeVisible || undefined} data-saving={state.status === 'saving' || undefined}>
{state.status === 'saving' && '保存中…'}
{state.status === 'saved' && (
<>
<Check size={14} strokeWidth={2.2} aria-hidden="true" />
已保存
</>
)}
</span>
</div>
{state.error && <p id={errorId} className="inline-text-field-error text-ui-caption" role="alert">{state.error}</p>}
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { pageItems } from '@/shared/lib/page-items'
// 到头时用 aria-disabled 而不是 disabled:被禁用的按钮会丢掉焦点,连按“下一页”翻到最后一页的键盘用户就找不到自己在哪了。
function StepButton({ label, enabled, onClick, children }: { label: string, enabled: boolean, onClick: () => void, children: ReactNode }) {
return (
<button
type="button"
className="pagination-step"
aria-label={label}
title={label}
aria-disabled={!enabled || undefined}
onClick={() => {
if (enabled)
onClick()
}}
>
{children}
</button>
)
}
export function Pagination({ page, totalPages, onPage, summary }: {
page: number
totalPages: number
onPage: (page: number) => void
summary?: string
}) {
if (totalPages <= 1 && !summary)
return null
return (
<nav className="pagination" aria-label="分页">
{summary && <p className="pagination-summary text-ui-caption">{summary}</p>}
{totalPages > 1 && (
<div className="pagination-pages">
<StepButton label="上一页" enabled={page > 1} onClick={() => onPage(page - 1)}>
<ChevronLeft size={18} aria-hidden="true" />
</StepButton>
{pageItems(page, totalPages).map((item, index, items) => item === 'gap'
? <span className="pagination-gap" aria-hidden="true" key={`gap-after-${items[index - 1]}`}>…</span>
: (
<button
type="button"
key={item}
aria-label={`第 ${item} 页`}
aria-current={item === page ? 'page' : undefined}
onClick={() => {
if (item !== page)
onPage(item)
}}
>
{item}
</button>
))}
<StepButton label="下一页" enabled={page < totalPages} onClick={() => onPage(page + 1)}>
<ChevronRight size={18} aria-hidden="true" />
</StepButton>
</div>
)}
</nav>
)
}
+60
View File
@@ -0,0 +1,60 @@
.section-layout { container: section-layout / inline-size; height: 100%; min-height: 0; background: var(--color-content); }
.section-layout-frame { display: grid; grid-template-columns: 240px minmax(0, 1fr); height: 100%; }
.section-layout-sidebar { display: flex; flex-direction: column; gap: 20px; min-height: 0; overflow-y: auto; scrollbar-width: none; padding: 28px 12px 16px; border-right: 1px solid var(--color-card-hover); }
.section-layout-heading { padding: 0 12px; }
.section-layout-eyebrow { margin-bottom: 2px; overflow-wrap: anywhere; color: var(--color-brand); font-size: var(--text-ui-sm); font-weight: 500; }
.section-layout-heading h1 { font-size: var(--text-ui-xl); line-height: 1.25; }
.section-nav { display: flex; flex-direction: column; gap: 2px; }
.section-nav-item { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: transparent; color: var(--color-foreground-subtle); text-align: left; }
.section-nav-item:hover { background: color-mix(in srgb, var(--color-card-hover) 45%, transparent); color: var(--color-foreground); }
.section-nav-item[aria-current='page'] { background: var(--color-card-hover); color: var(--color-foreground); font-weight: 500; }
.section-nav-item[aria-current='page'] > svg { color: var(--color-brand); }
.section-nav-item:focus-visible { outline-offset: 0; }
.section-nav-label, .section-list-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.section-badge { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--color-destructive); }
.section-layout-footer { margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-card-hover); }
.section-layout-content { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 32px 40px 48px; animation: fade-in 120ms ease both; }
.section-layout-main { max-width: 960px; }
.section-layout-main h2:focus { outline: none; }
.section-layout-list { display: flex; flex-direction: column; gap: 16px; height: 100%; overflow-y: auto; overscroll-behavior: contain; padding: 24px 16px 32px; animation: fade-in 120ms ease both; }
.section-layout-list > .section-layout-heading { padding: 0 4px; margin-bottom: 4px; }
.section-layout-list > .section-layout-footer { margin-top: 0; padding-top: 0; border-top: 0; }
.section-list-item { flex-wrap: nowrap; column-gap: 12px; color: var(--color-foreground); }
.section-list-item > svg:first-child { color: var(--color-foreground-subtle); }
.section-list-summary { max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.section-list-chevron { margin-left: -6px; color: var(--color-foreground-subtlest); }
.section-layout-back { display: inline-flex; align-items: center; gap: 2px; min-height: 36px; margin: 0 0 8px -6px; padding: 4px 12px 4px 2px; border: 0; border-radius: 8px; background: transparent; color: var(--color-brand); font-weight: 500; }
.section-layout-back:hover { background: color-mix(in srgb, var(--color-brand) 10%, transparent); }
.section-layout-back:focus-visible { outline-offset: 0; }
.section-group-label { margin: 28px 4px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-caption); font-weight: 500; }
/* 浅色主题里卡片和输入框的底色相同,卡片里的输入框要换一种底色才分得开。 */
.section-group { --color-input: var(--color-input-on-card); border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-card); }
.section-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; width: 100%; min-height: 56px; padding: 12px 16px; }
/* 分隔线不用 surface:浅色主题里它和 card 同色,线会看不见。card-hover 在深色主题里与 surface 同色。 */
.section-row + .section-row { border-top: 1px solid var(--color-card-hover); }
.section-group > .section-row:first-child { border-top-left-radius: 13px; border-top-right-radius: 13px; }
.section-group > .section-row:last-child { border-bottom-left-radius: 13px; border-bottom-right-radius: 13px; }
.section-row-copy { display: flex; flex: 1 1 12em; flex-direction: column; gap: 2px; min-width: 0; }
.section-row-title { font-weight: 500; }
.section-row-description { overflow-wrap: anywhere; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
:is(button, a).section-row { border: 0; background: transparent; color: inherit; text-align: left; text-decoration: none; }
:is(button, a).section-row:hover { background: var(--color-card-hover); }
:is(button, a).section-row:active { transform: none; background: var(--color-hover); }
:is(button, a).section-row:focus-visible { outline-offset: -2px; }
@container section-layout (max-width: 860px) {
.section-layout-frame[data-layout='split'] { grid-template-columns: 208px minmax(0, 1fr); }
.section-layout-content { padding: 28px 24px 40px; }
}
/* 宽屏上主区域也可能很窄,比如平板竖屏又开着右侧栏:分类栏改为内容上方的一行。 */
@container section-layout (max-width: 599px) {
.section-layout-frame[data-layout='split'] { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.section-layout-sidebar { flex-direction: row; align-items: center; gap: 8px; padding: 8px 12px; overflow-x: auto; scrollbar-width: thin; border-right: 0; border-bottom: 1px solid var(--color-card-hover); }
.section-layout-sidebar > .section-layout-heading { display: none; }
.section-nav { flex-direction: row; }
.section-nav-item { width: auto; white-space: nowrap; }
.section-layout-sidebar > .section-layout-footer { flex-shrink: 0; margin: 0 0 0 auto; padding: 0; border: 0; }
.section-layout-content { padding: 24px 20px 32px; }
}
@media (max-width: 767px) {
.section-layout > .section-layout-content { height: 100%; padding: 12px 16px 32px; }
}
+182
View File
@@ -0,0 +1,182 @@
import type { LucideIcon } from 'lucide-react'
import type { KeyboardEvent, ReactNode } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useEffect, useRef, useSyncExternalStore } from 'react'
export interface SectionItem<T extends string> {
id: T
label: string
icon: LucideIcon
badge?: boolean
// 手机端分类列表里显示在这一行右侧的当前值。
summary?: string
// 手机端分类列表里不单独列出这一项,比如 header 已经是它的入口。
hiddenInList?: boolean
}
let wideQuery: MediaQueryList | undefined
function wide(): MediaQueryList {
wideQuery ??= window.matchMedia('(min-width: 768px)')
return wideQuery
}
function subscribeWide(notify: () => void): () => void {
wide().addEventListener('change', notify)
return () => wide().removeEventListener('change', notify)
}
function focusHeading(container: HTMLElement | null): void {
const heading = container?.querySelector('h2')
if (!heading)
return
if (!heading.hasAttribute('tabindex'))
heading.tabIndex = -1
heading.focus({ preventScroll: true })
}
const keySteps: Partial<Record<string, (index: number, count: number) => number>> = {
ArrowDown: (index, count) => (index + 1) % count,
ArrowUp: (index, count) => (index - 1 + count) % count,
Home: () => 0,
End: (_, count) => count - 1,
}
function moveFocus(event: KeyboardEvent<HTMLElement>): void {
const step = keySteps[event.key]
const items = [...event.currentTarget.querySelectorAll<HTMLElement>('[data-section]')]
const index = items.indexOf(document.activeElement as HTMLElement)
if (!step || index === -1)
return
event.preventDefault()
items[step(index, items.length)].focus()
}
// 宽屏:左侧分类栏加右侧内容,current 为 null 时按 defaultSection 高亮,children 也应当是它的内容。
// 窄屏:current 为 null 时只显示分类列表,header 在列表上方;否则只显示 children,顶部的返回按钮调用 onSelect(null)。
// children 里第一个 h2 是分类的标题:从分类栏切换过去之后,焦点落在它上面。
export function SectionLayout<T extends string>({ title, eyebrow, sections, current, defaultSection, onSelect, header, footer, children }: {
title: string
eyebrow?: string
sections: SectionItem<T>[]
current: T | null
defaultSection: T
onSelect: (id: T | null) => void
header?: ReactNode
footer?: ReactNode
children: ReactNode
}) {
const isWide = useSyncExternalStore(subscribeWide, () => wide().matches)
const navRef = useRef<HTMLElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
// 地址改写之后 current 才会变,所以先记下切换完成后焦点要去哪里。
const focusAfterRef = useRef<{ to: 'content' } | { to: 'item', id: T } | null>(null)
const shown = current ?? defaultSection
useEffect(() => {
const target = focusAfterRef.current
focusAfterRef.current = null
if (target?.to === 'content')
focusHeading(contentRef.current)
else if (target?.to === 'item')
[...navRef.current?.querySelectorAll<HTMLElement>('[data-section]') ?? []].find(item => item.dataset.section === target.id)?.focus()
}, [current])
// 窄的主区域里分类栏是横向滚动的一行,当前项可能在视野之外。
useEffect(() => {
navRef.current?.querySelector('[aria-current="page"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [shown])
// 宽屏上 current 为 null 时显示的就是 defaultSection:点它不改地址,免得多出一条看不出区别的记录。
const select = (id: T) => {
if (id === (isWide ? shown : current)) {
focusHeading(contentRef.current)
return
}
focusAfterRef.current = { to: 'content' }
onSelect(id)
}
const heading = (
<div className="section-layout-heading">
{eyebrow && <p className="section-layout-eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
</div>
)
const badge = <span className="section-badge"><span className="sr-only">需要处理</span></span>
if (isWide) {
return (
<div className="section-layout">
<div className="section-layout-frame" data-layout="split">
<div className="section-layout-sidebar">
{heading}
<nav ref={navRef} className="section-nav" aria-label={title} onKeyDown={moveFocus}>
{sections.map(({ id, label, icon: Icon, badge: flagged }) => (
<button
type="button"
key={id}
data-section={id}
className="section-nav-item"
aria-current={id === shown ? 'page' : undefined}
onClick={() => select(id)}
>
<Icon size={18} strokeWidth={1.8} aria-hidden="true" />
<span className="section-nav-label">{label}</span>
{flagged && badge}
</button>
))}
</nav>
{footer && <div className="section-layout-footer">{footer}</div>}
</div>
<div ref={contentRef} className="section-layout-content" key={shown}>
<div className="section-layout-main">{children}</div>
</div>
</div>
</div>
)
}
if (current === null) {
return (
<div className="section-layout">
<div className="section-layout-list" key="list">
{heading}
{header}
<nav ref={navRef} className="section-group" aria-label={title} onKeyDown={moveFocus}>
{sections.filter(section => !section.hiddenInList).map(({ id, label, icon: Icon, badge: flagged, summary }) => (
<button type="button" key={id} data-section={id} className="section-row section-list-item" onClick={() => select(id)}>
<Icon size={20} strokeWidth={1.8} aria-hidden="true" />
<span className="section-list-label">{label}</span>
{flagged && badge}
{summary && <span className="section-list-summary">{summary}</span>}
<ChevronRight size={18} className="section-list-chevron" aria-hidden="true" />
</button>
))}
</nav>
{footer && <div className="section-layout-footer">{footer}</div>}
</div>
</div>
)
}
return (
<div className="section-layout">
<div ref={contentRef} className="section-layout-content" key={current}>
<button
type="button"
className="section-layout-back"
aria-label={`返回${title}`}
onClick={() => {
focusAfterRef.current = { to: 'item', id: current }
onSelect(null)
}}
>
<ChevronLeft size={20} aria-hidden="true" />
{title}
</button>
<div className="section-layout-main">{children}</div>
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import type { CSSProperties, KeyboardEvent } from 'react'
import { useRef } from 'react'
import { rovingIndex } from '@/shared/lib/roving'
export function SegmentedControl<T extends string>({ value, options, onChange, label }: {
value: T
options: { value: T, label: string }[]
onChange: (value: T) => void
label: string
}) {
const rootRef = useRef<HTMLDivElement>(null)
const selected = options.findIndex(option => option.value === value)
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const next = rovingIndex(event.key, Math.max(selected, 0), options.length)
if (next === null)
return
event.preventDefault()
rootRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus()
if (next !== selected)
onChange(options[next].value)
}
return (
<div
ref={rootRef}
role="radiogroup"
aria-label={label}
className="segmented-control"
style={{ '--segment-count': options.length, '--segment-index': selected } as CSSProperties}
onKeyDown={onKeyDown}
>
{selected >= 0 && <span className="segmented-control-thumb" aria-hidden="true" />}
{options.map((option, index) => (
<button
type="button"
role="radio"
key={option.value}
aria-checked={index === selected}
tabIndex={index === Math.max(selected, 0) ? 0 : -1}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
// 可见的名字由 <label htmlFor={id}> 提供:按钮可以被 label 关联,点击 label 也会切换。
export function Switch({ checked, onChange, disabled = false, id, 'aria-describedby': describedBy }: {
'checked': boolean
'onChange': (checked: boolean) => void
'disabled'?: boolean
'id'?: string
'aria-describedby'?: string
}) {
return (
<button
type="button"
role="switch"
id={id}
className="switch"
aria-checked={checked}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
>
<span className="switch-thumb" aria-hidden="true" />
</button>
)
}
+11 -2
View File
@@ -34,6 +34,7 @@
--color-popover: #242427;
--color-input: #29292d;
--color-input-focused: #303034;
--color-input-on-card: #29292d;
--color-search-box: #1f1f1f;
--color-foreground: #f4f4f5;
--color-foreground-subtle: #a6a6af;
@@ -51,6 +52,8 @@
--color-destructive-foreground: #34151b;
--color-success: #8cc9a5;
--color-toast: #303034;
--color-avatar: #2c2c30;
--color-avatar-foreground: #d4d4d8;
--color-overlay: rgb(0 0 0 / 55%);
--color-shadow: rgb(0 0 0 / 24%);
--glow-opacity: .8;
@@ -70,13 +73,14 @@
--color-popover: #fff;
--color-input: #f3f3f6;
--color-input-focused: #fff;
--color-input-on-card: #fff;
--color-search-box: #fff;
--color-foreground: #202024;
--color-foreground-subtle: #64646e;
--color-foreground-subtlest: #6e6e78;
--color-border: #e6e6eb;
--color-border-hover: #b9b9c2;
--color-input-border: #d4d4dc;
--color-input-border: #84848e;
--color-input-border-focused: #217521;
--color-hover: #e8e8ed;
--color-frame-hover: #d4d4dc;
@@ -87,6 +91,8 @@
--color-destructive-foreground: #fff;
--color-success: #287144;
--color-toast: #fff;
--color-avatar: #dadae0;
--color-avatar-foreground: #4a4a52;
--color-shadow: rgb(30 30 45 / 12%);
--glow-opacity: .5;
color-scheme: light;
@@ -105,13 +111,14 @@
--color-popover: #fff;
--color-input: #f3f3f6;
--color-input-focused: #fff;
--color-input-on-card: #fff;
--color-search-box: #fff;
--color-foreground: #202024;
--color-foreground-subtle: #64646e;
--color-foreground-subtlest: #6e6e78;
--color-border: #e6e6eb;
--color-border-hover: #b9b9c2;
--color-input-border: #d4d4dc;
--color-input-border: #84848e;
--color-input-border-focused: #217521;
--color-hover: #e8e8ed;
--color-frame-hover: #d4d4dc;
@@ -122,6 +129,8 @@
--color-destructive-foreground: #fff;
--color-success: #287144;
--color-toast: #fff;
--color-avatar: #dadae0;
--color-avatar-foreground: #4a4a52;
--color-shadow: rgb(30 30 45 / 12%);
--glow-opacity: .5;
color-scheme: light;
+46 -1
View File
@@ -35,7 +35,7 @@
.artwork { flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--color-surface); color: var(--color-foreground-subtlest); border-radius: 8px; aspect-ratio: 1; }
.artwork img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 240ms ease; }
.artwork img[data-loaded] { opacity: 1; }
.avatar { display: inline-grid; place-items: center; flex-shrink: 0; width: 32px; aspect-ratio: 1; overflow: hidden; border-radius: 50%; background: var(--color-accent); color: var(--color-brand); font-weight: 600; line-height: 1; }
.avatar { display: inline-grid; place-items: center; flex-shrink: 0; width: 32px; aspect-ratio: 1; overflow: hidden; border-radius: 50%; background: var(--color-avatar); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-foreground) 10%, transparent); color: var(--color-avatar-foreground); font-weight: 600; line-height: 1; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.song-artwork { display: block; position: relative; flex-shrink: 0; border-radius: 8px; }
.song-artwork-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--color-brand); opacity: 0; pointer-events: none; }
@@ -86,6 +86,47 @@
.toast-error > svg { color: var(--color-destructive); }
.toast-success > svg { color: var(--color-success); }
.toast-info > svg { color: var(--color-brand); }
.switch { position: relative; flex-shrink: 0; width: 40px; height: 24px; padding: 0; border: 0; border-radius: 999px; background: var(--color-input-border); }
.switch:hover:not(:disabled) { background: color-mix(in srgb, var(--color-input-border), var(--color-foreground) 14%); }
.switch[aria-checked='true'] { background: var(--color-primary); }
.switch[aria-checked='true']:hover:not(:disabled) { background: color-mix(in srgb, var(--color-primary), var(--color-foreground) 12%); }
.switch:active:not(:disabled) { transform: none; }
/* 关闭时的滑块在两种主题下都是白色,靠和轨道的对比度认出位置,不靠阴影:极简模式会去掉所有阴影。 */
.switch-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 999px; background: #fff; box-shadow: 0 1px 3px var(--color-shadow), 0 0 0 .5px var(--color-shadow); transition: transform 200ms cubic-bezier(.2, .8, .2, 1), width 200ms cubic-bezier(.2, .8, .2, 1), background-color 150ms; }
.switch[aria-checked='true'] .switch-thumb { transform: translateX(16px); background: var(--color-primary-foreground); }
/* 按下时滑块向前伸长,松开后再滑过去,和系统开关的手感一致。 */
.switch:active:not(:disabled) .switch-thumb { width: 22px; }
.switch[aria-checked='true']:active:not(:disabled) .switch-thumb { transform: translateX(12px); }
.segmented-control { position: relative; display: inline-grid; grid-auto-flow: column; grid-auto-columns: 1fr; flex-shrink: 0; max-width: 100%; padding: 3px; border-radius: 10px; background: var(--color-card-hover); }
.segmented-control > button { position: relative; min-width: 0; min-height: 30px; padding: 4px 14px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); white-space: nowrap; }
.segmented-control > button:hover, .segmented-control > button[aria-checked='true'] { color: var(--color-foreground); }
.segmented-control > button:active:not(:disabled) { transform: none; background: color-mix(in srgb, var(--color-foreground) 6%, transparent); }
.segmented-control > button:focus-visible { outline-offset: 0; }
/* 各段等宽,滑块按选中的序号平移,切换时滑过去而不是跳过去。 */
.segmented-control-thumb { position: absolute; top: 3px; bottom: 3px; left: 3px; width: calc((100% - 6px) / var(--segment-count)); border-radius: 8px; background: color-mix(in srgb, var(--color-input-focused), #fff 6%); box-shadow: 0 1px 3px var(--color-shadow), inset 0 0 0 1px color-mix(in srgb, var(--color-foreground) 6%, transparent); transform: translateX(calc(var(--segment-index) * 100%)); transition: transform 220ms cubic-bezier(.2, .8, .2, 1); }
.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; margin-top: 16px; }
.pagination-summary { color: var(--color-foreground-subtle); font-variant-numeric: tabular-nums; }
.pagination-pages { display: flex; align-items: center; gap: 4px; margin-left: auto; }
.pagination-pages > button { display: inline-grid; place-items: center; min-width: 32px; height: 32px; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; }
.pagination-pages > button:hover:not(:disabled, [aria-current], [aria-disabled='true']) { background: var(--color-hover); color: var(--color-foreground); }
.pagination-pages > button[aria-current='page'] { background: var(--color-foreground); color: var(--color-content); font-weight: 600; cursor: default; }
.pagination-pages > button:focus-visible { outline-offset: 1px; }
.pagination-pages > .pagination-step { padding: 0; color: var(--color-foreground); }
.pagination-step[aria-disabled='true'] { cursor: default; opacity: .45; }
.pagination-step[aria-disabled='true']:active { transform: none; }
.pagination-gap { min-width: 20px; color: var(--color-foreground-subtlest); text-align: center; user-select: none; }
.inline-text-field { display: flex; flex-direction: column; gap: 6px; flex: 1 1 220px; min-width: 0; max-width: 320px; }
.inline-text-field-box { position: relative; }
.inline-text-field input { padding: 8px 12px; border-radius: 10px; transition: border-color 150ms, background-color 150ms; }
.inline-text-field input:hover:not(:focus) { border-color: var(--color-border-hover); }
.inline-text-field input[aria-invalid='true']:not(:focus) { border-color: var(--color-destructive); }
/* 提示叠在输入框右端,出现和淡出都不改变这一行的高度。 */
.inline-text-field-status { position: absolute; top: 50%; right: 6px; display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px; border-radius: 6px; background: var(--color-input); color: var(--color-success); transform: translateY(-50%); pointer-events: none; opacity: 0; transition: opacity 400ms ease; }
.inline-text-field-box:focus-within .inline-text-field-status { background: var(--color-input-focused); }
.inline-text-field-status[data-visible] { opacity: 1; transition: none; }
.inline-text-field-status[data-saving] { color: var(--color-foreground-subtle); }
.inline-text-field-error { color: var(--color-destructive); overflow-wrap: anywhere; }
:root[data-performance='low'] .switch-thumb, :root[data-performance='low'] .segmented-control-thumb { transition: none; }
@keyframes sheet-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
@@ -107,5 +148,9 @@
.modal-header { padding: 12px 20px; }
.modal-body { padding: 20px 20px calc(20px + env(safe-area-inset-bottom)); }
.notifications { --notification-top: calc(76px + env(safe-area-inset-top)); }
.pagination { justify-content: center; }
.pagination-summary { width: 100%; text-align: center; }
.pagination-pages { gap: 2px; margin: 0 auto; }
.pagination-pages > button { min-width: 36px; height: 36px; }
}
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
+1 -1
View File
@@ -62,7 +62,7 @@ export default defineConfig({
workbox: {
// 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
// Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/, /^\/(?:pair|auth\/github)(?:\?.*)?$/],
navigateFallbackAllowlist: [/^\/(?:settings(?:\/(?:account|appearance|playback|performance|privacy))?|admin(?:\/(?:site|registration|users|rooms|sources|media))?|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/, /^\/(?:pair|auth\/github)(?:\?.*)?$/],
importScripts: ['notification-click.js'],
},
}),