feat: add sectioned page layout, switches, segmented controls, paging and neutral avatars
This commit is contained in:
1 parent
fb751ca57f
commit
c7a5c44b1c
29 files changed
+1018
-38
No files matched your search
@@ -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
@@ -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 />}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export { AdminPage } from './admin-page'
|
||||
export type { AdminSection } from './admin-page'
|
||||
export { adminStore } from './admin-store'
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '名字太长' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user