feat: browse my playlists as a sortable list or a glowing cover grid and open each one
This commit is contained in:
1 parent
ffd2b390f0
commit
d73a161e84
14 files changed
+881
-20
No files matched your search
+41
-20
@@ -1,13 +1,14 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { Page, Panel, RoomPage } from './route'
|
||||
import type { SearchQuery } from '@/features/search'
|
||||
import { ChevronLeft, ChevronRight, Headphones, History, House, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Headphones, History, House, Library, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
|
||||
import { Suspense } from 'react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { ChatPanel } from '@/features/chat'
|
||||
import { HistoryPage } from '@/features/history'
|
||||
import { ListenPage } from '@/features/lyrics'
|
||||
import { AudioEngine, ManualStartPrompt, PlayerBar, playerStore } from '@/features/playback'
|
||||
import { MyPlaylistPage, PlaylistsPage } from '@/features/playlists'
|
||||
import { QueuePage, voteSkip } from '@/features/queue'
|
||||
import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms'
|
||||
import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search'
|
||||
@@ -29,11 +30,12 @@ const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
|
||||
|
||||
const pageLoading = <div className="page-content"><Skeleton rows={6} /></div>
|
||||
|
||||
const menu: { page: 'lobby' | 'settings' | 'history' | RoomPage, label: string, icon: LucideIcon }[] = [
|
||||
const menu: { page: 'lobby' | 'settings' | 'history' | 'playlists' | RoomPage, label: string, icon: LucideIcon }[] = [
|
||||
{ page: 'listen', label: '正在聆听', icon: Headphones },
|
||||
{ page: 'queue', label: '播放列表', icon: ListMusic },
|
||||
{ page: 'chat', label: '房间聊天', icon: MessageCircle },
|
||||
{ page: 'history', label: '播放历史', icon: History },
|
||||
{ page: 'playlists', label: '我的歌单', icon: Library },
|
||||
{ page: 'lobby', label: '发现房间', icon: House },
|
||||
{ page: 'settings', label: '设置', icon: Settings },
|
||||
]
|
||||
@@ -45,9 +47,11 @@ const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [
|
||||
]
|
||||
|
||||
const pageClasses: Partial<Record<Page, string>> = {
|
||||
queue: 'scrollbar-hidden',
|
||||
history: 'scrollbar-hidden',
|
||||
settings: 'settings-view',
|
||||
'queue': 'scrollbar-hidden',
|
||||
'history': 'scrollbar-hidden',
|
||||
'playlists': 'scrollbar-hidden',
|
||||
'my-playlist': 'scrollbar-hidden',
|
||||
'settings': 'settings-view',
|
||||
}
|
||||
|
||||
function closeMenu(element: HTMLElement): void {
|
||||
@@ -112,21 +116,24 @@ function Header({ page }: { page: Page }) {
|
||||
<MoreHorizontal size={20} />
|
||||
</summary>
|
||||
<nav className="main-nav" aria-label="主导航">
|
||||
{menu.map(({ page: target, label, icon: Icon }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
className={`nav-item text-ui-base ${page === target ? 'selected' : ''}`}
|
||||
aria-current={page === target ? 'page' : undefined}
|
||||
onClick={(event) => {
|
||||
navigationStore.open(target)
|
||||
closeMenu(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.7} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{menu.map(({ page: target, label, icon: Icon }) => {
|
||||
const selected = page === target || (target === 'playlists' && page === 'my-playlist')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
className={`nav-item text-ui-base ${selected ? 'selected' : ''}`}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
onClick={(event) => {
|
||||
navigationStore.open(target)
|
||||
closeMenu(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.7} />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="nav-item text-ui-base"
|
||||
@@ -187,6 +194,13 @@ function RoutedHistory() {
|
||||
return <HistoryPage filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} />
|
||||
}
|
||||
|
||||
function RoutedMyPlaylist() {
|
||||
const route = useStore(navigationStore, s => s.route)
|
||||
if (route.name !== 'my-playlist')
|
||||
return null
|
||||
return <MyPlaylistPage id={route.id} onBack={() => navigationStore.open('playlists')} />
|
||||
}
|
||||
|
||||
function RoutedSearch() {
|
||||
const route = useStore(navigationStore, s => s.route)
|
||||
if (route.name !== 'search')
|
||||
@@ -258,6 +272,13 @@ export function App() {
|
||||
{page === 'playlist' && <RoomGate onBrowse={browse}><RoutedPlaylist /></RoomGate>}
|
||||
{page === 'chat' && <RoomGate onBrowse={browse}><ChatPanel /></RoomGate>}
|
||||
{page === 'history' && <RoutedHistory />}
|
||||
{page === 'playlists' && (
|
||||
<PlaylistsPage
|
||||
onOpen={playlist => navigationStore.navigate({ name: 'my-playlist', id: playlist.id, panel: navigationStore.state.route.panel }, playlist.name)}
|
||||
onRegister={() => navigationStore.open('settings')}
|
||||
/>
|
||||
)}
|
||||
{page === 'my-playlist' && <RoutedMyPlaylist />}
|
||||
{page === 'settings' && <SettingsPage />}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { lazyPage } from '@/shared/ui'
|
||||
|
||||
export { setAccountSettingsOpener } from './account-settings'
|
||||
export { playlistsStore } from './playlists-store'
|
||||
export { saveToPlaylistLabel } from './save-label'
|
||||
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
|
||||
export const PlaylistsPage = lazyPage(() => import('./playlists-page').then(module => module.PlaylistsPage))
|
||||
export const MyPlaylistPage = lazyPage(() => import('./my-playlist-page').then(module => module.MyPlaylistPage))
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongItem } from '@/shared/ui'
|
||||
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { enqueue, sourceName } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, Skeleton, SongList } from '@/shared/ui'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
import { playlistsStore, trackKey } from './playlists-store'
|
||||
import { saveToPlaylistLabel } from './save-label'
|
||||
import { SaveToPlaylistDialog } from './save-to-playlist'
|
||||
import './playlists.css'
|
||||
|
||||
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
|
||||
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
|
||||
const listed = useStore(playlistsStore, s => s.playlists !== null)
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const ephemeral = useStore(roomStore, s => s.room?.room.kind === 'ephemeral')
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
const [items, setItems] = useState<MyPlaylistItem[] | null>(null)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.listMyPlaylistItems(id).then(({ items }) => {
|
||||
if (!active)
|
||||
return
|
||||
setItems(items)
|
||||
setFailed(null)
|
||||
}, (error: unknown) => {
|
||||
if (active)
|
||||
setFailed(errorMessage(error))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id, attempt, playlist?.updatedAtMs])
|
||||
useEffect(() => {
|
||||
const refresh = () => setAttempt(n => n + 1)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => window.removeEventListener('focus', refresh)
|
||||
}, [])
|
||||
const [now] = useState(Date.now)
|
||||
const name = playlist?.name ?? '歌单'
|
||||
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
|
||||
const gone = listed && !playlist
|
||||
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
|
||||
const shown = gone || failed ? null : items
|
||||
let body
|
||||
if (gone) {
|
||||
body = (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="这个歌单不存在了"
|
||||
action={<button type="button" className="button-secondary" onClick={onBack}>返回我的歌单</button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (failed) {
|
||||
body = (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title={failed}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
onClick={() => {
|
||||
setFailed(null)
|
||||
setAttempt(n => n + 1)
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (items === null) {
|
||||
body = <Skeleton rows={6} />
|
||||
}
|
||||
else if (items.length === 0) {
|
||||
body = (
|
||||
<EmptyState icon={ListMusic} title="这个歌单还是空的">
|
||||
在播放栏、播放列表和播放历史里,可以把歌保存到歌单。
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
else {
|
||||
body = (
|
||||
<SongList
|
||||
songs={items.map(({ track, meta }): SongItem => ({
|
||||
key: trackKey(track),
|
||||
title: meta.title,
|
||||
artist: meta.artist || '未知艺术家',
|
||||
album: meta.album,
|
||||
coverUrl: meta.coverUrl,
|
||||
durationMs: meta.durationMs,
|
||||
sourceUrl: meta.pageUrl,
|
||||
sourceName: sourceName(track),
|
||||
playing: sameTrack(track, playing),
|
||||
}))}
|
||||
actions={(song, index) => {
|
||||
const { track } = items[index]
|
||||
const blocked = track.source === 'link' && ephemeral
|
||||
return [
|
||||
{
|
||||
icon: CirclePlus,
|
||||
label: blocked ? '临时房间不能点链接里的歌' : `点歌${bookTitle(song.title)}`,
|
||||
disabled: !connected || blocked,
|
||||
run: async () => {
|
||||
await enqueue(track)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ListPlus,
|
||||
label: saveToPlaylistLabel(song.title),
|
||||
secondary: true,
|
||||
run: async () => setSaving({ track, title: song.title }),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: `把${bookTitle(song.title)}移出这个歌单`,
|
||||
disabled: !playlist,
|
||||
secondary: true,
|
||||
run: async () => {
|
||||
if (!playlist)
|
||||
return
|
||||
// 失败时歌单可能已经在别处变了,重新读取。
|
||||
if (await playlistsStore.removeItems(playlist, [track]))
|
||||
setItems(list => list?.filter(item => !sameTrack(item.track, track)) ?? list)
|
||||
else
|
||||
setAttempt(n => n + 1)
|
||||
},
|
||||
},
|
||||
]
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section className="page-content my-playlist-page" aria-labelledby="my-playlist-title">
|
||||
<button type="button" className="button-ghost my-playlist-back text-ui-sm" aria-label="返回我的歌单" onClick={onBack}>
|
||||
<ArrowLeft size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
我的歌单
|
||||
</button>
|
||||
<header className="my-playlist-header">
|
||||
<PlaylistCover cover={playlist?.coverUrl ?? null} favorites={playlist?.favorites ?? false} size="lg" />
|
||||
<div className="my-playlist-info">
|
||||
<h1 id="my-playlist-title" className="my-playlist-name">
|
||||
<span>{name}</span>
|
||||
{playlist?.favorites && <FavoritesMark size={22} />}
|
||||
</h1>
|
||||
{playlist && (
|
||||
<p className="text-ui-base muted">
|
||||
{`${playlist.trackCount} 首,更新于${formatPlaylistDate(playlist.updatedAtMs, now)},创建于${formatPlaylistDate(playlist.createdAtMs, now)}`}
|
||||
</p>
|
||||
)}
|
||||
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
|
||||
</div>
|
||||
</header>
|
||||
{body}
|
||||
{!connected && shown?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
|
||||
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ function FilledStar() {
|
||||
|
||||
export function PlaylistCover({ cover, favorites, size }: { cover: string | null, favorites: boolean, size: 'sm' | 'md' | 'lg' }) {
|
||||
const [failedSrc, setFailedSrc] = useState<string>()
|
||||
const large = size !== 'sm'
|
||||
if (!cover || cover === failedSrc) {
|
||||
return (
|
||||
<span className={`playlist-cover playlist-cover-${size}`} data-favorites={favorites || undefined}>
|
||||
@@ -20,7 +21,9 @@ export function PlaylistCover({ cover, favorites, size }: { cover: string | null
|
||||
}
|
||||
return (
|
||||
<span className={`playlist-cover playlist-cover-${size}`}>
|
||||
{large && <img className="playlist-cover-glow" src={cover} alt="" loading="lazy" decoding="async" />}
|
||||
<img src={cover} alt="" loading="lazy" decoding="async" onError={() => setFailedSrc(cover)} />
|
||||
{large && favorites && <span className="playlist-cover-badge"><FilledStar /></span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
|
||||
// 用本地时间构造时刻:断言与运行测试的时区无关。
|
||||
function at(year: number, month: number, day: number, hour = 0, minute = 0): number {
|
||||
return new Date(year, month - 1, day, hour, minute).getTime()
|
||||
}
|
||||
|
||||
const now = at(2026, 11, 9, 15, 30)
|
||||
|
||||
describe('formatPlaylistDate', () => {
|
||||
it('今天的显示时刻', () => {
|
||||
expect(formatPlaylistDate(at(2026, 11, 9, 14, 20), now)).toBe('今天 14:20')
|
||||
expect(formatPlaylistDate(at(2026, 11, 9, 0, 5), now)).toBe('今天 00:05')
|
||||
})
|
||||
|
||||
it('昨天的显示时刻', () => {
|
||||
expect(formatPlaylistDate(at(2026, 11, 8, 21, 4), now)).toBe('昨天 21:04')
|
||||
expect(formatPlaylistDate(at(2026, 11, 8, 0, 0), at(2026, 11, 9, 23, 59))).toBe('昨天 00:00')
|
||||
})
|
||||
|
||||
it('跨月的昨天', () => {
|
||||
expect(formatPlaylistDate(at(2026, 10, 31, 23, 59), at(2026, 11, 1, 0, 1))).toBe('昨天 23:59')
|
||||
})
|
||||
|
||||
it('同一年更早的只显示月日', () => {
|
||||
expect(formatPlaylistDate(at(2026, 11, 7, 9, 0), now)).toBe('11月7日')
|
||||
expect(formatPlaylistDate(at(2026, 1, 1, 0, 0), now)).toBe('1月1日')
|
||||
})
|
||||
|
||||
it('跨年显示年份', () => {
|
||||
expect(formatPlaylistDate(at(2025, 12, 31, 22, 0), now)).toBe('2025年12月31日')
|
||||
expect(formatPlaylistDate(at(2025, 12, 31, 22, 0), at(2026, 1, 1, 8, 0))).toBe('昨天 22:00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
function dayStart(date: Date): number {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function formatPlaylistDate(ms: number, now: number): string {
|
||||
const date = new Date(ms)
|
||||
const today = new Date(now)
|
||||
// 夏令时切换的那天不是 24 小时,按天取整。
|
||||
const days = Math.round((dayStart(today) - dayStart(date)) / dayMs)
|
||||
if (days === 0 || days === 1)
|
||||
return `${days === 0 ? '今天' : '昨天'} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
const day = `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
return date.getFullYear() === today.getFullYear() ? day : `${date.getFullYear()}年${day}`
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { KeyboardEvent, ToggleEvent } from 'react'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Modal } from '@/shared/ui'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
|
||||
const menuGap = 4
|
||||
const viewportMargin = 8
|
||||
|
||||
const menuKeys: 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,
|
||||
}
|
||||
|
||||
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted }: {
|
||||
playlist: MyPlaylist
|
||||
align?: 'start' | 'end'
|
||||
className?: string
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const menuId = useId()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dialog, setDialog] = useState<'rename' | 'delete' | null>(null)
|
||||
|
||||
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const close = () => menuRef.current?.hidePopover()
|
||||
document.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
document.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const items = () => [...menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
|
||||
// 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。
|
||||
const place = (event: ToggleEvent<HTMLDivElement>) => {
|
||||
if (event.newState !== 'open')
|
||||
return
|
||||
const button = buttonRef.current!.getBoundingClientRect()
|
||||
const { style } = event.currentTarget
|
||||
style.top = `${button.bottom + menuGap}px`
|
||||
style.left = align === 'start' ? `${button.left}px` : 'auto'
|
||||
style.right = align === 'end' ? `${document.documentElement.clientWidth - button.right}px` : 'auto'
|
||||
}
|
||||
|
||||
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
|
||||
const opened = event.newState === 'open'
|
||||
setOpen(opened)
|
||||
if (!opened)
|
||||
return
|
||||
const menu = event.currentTarget
|
||||
const button = buttonRef.current!.getBoundingClientRect()
|
||||
const rect = menu.getBoundingClientRect()
|
||||
if (rect.bottom > window.innerHeight - viewportMargin)
|
||||
menu.style.top = `${Math.max(viewportMargin, button.top - menuGap - rect.height)}px`
|
||||
const left = Math.max(viewportMargin, Math.min(rect.left, document.documentElement.clientWidth - viewportMargin - rect.width))
|
||||
if (left !== rect.left) {
|
||||
menu.style.left = `${left}px`
|
||||
menu.style.right = 'auto'
|
||||
}
|
||||
items()[0]?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Tab') {
|
||||
event.currentTarget.hidePopover()
|
||||
return
|
||||
}
|
||||
const move = menuKeys[event.key]
|
||||
if (!move)
|
||||
return
|
||||
event.preventDefault()
|
||||
const list = items()
|
||||
list[move(list.indexOf(document.activeElement as HTMLElement), list.length)]?.focus()
|
||||
}
|
||||
|
||||
const choose = (next: 'rename' | 'delete') => {
|
||||
menuRef.current!.hidePopover()
|
||||
setDialog(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={`icon-button playlist-menu-button ${className}`}
|
||||
popoverTarget={menuId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`“${playlist.name}”的更多操作`}
|
||||
title="更多操作"
|
||||
>
|
||||
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
|
||||
</button>
|
||||
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={`“${playlist.name}”的操作`} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
|
||||
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item" onClick={() => choose('rename')}>
|
||||
<Pencil size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
重命名
|
||||
</button>
|
||||
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item playlist-menu-danger" onClick={() => choose('delete')}>
|
||||
<Trash2 size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
{dialog === 'rename' && <RenameDialog playlist={playlist} onClose={() => setDialog(null)} />}
|
||||
{dialog === 'delete' && <DeleteDialog playlist={playlist} onClose={() => setDialog(null)} onDeleted={onDeleted} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RenameDialog({ playlist, onClose }: { playlist: MyPlaylist, onClose: () => void }) {
|
||||
const [name, setName] = useState(playlist.name)
|
||||
const [saving, setSaving] = useState(false)
|
||||
return (
|
||||
<Modal title="重命名" onClose={onClose}>
|
||||
<form
|
||||
className="form-stack"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (saving)
|
||||
return
|
||||
setSaving(true)
|
||||
if (await playlistsStore.rename(playlist, name))
|
||||
onClose()
|
||||
else
|
||||
setSaving(false)
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
歌单名
|
||||
<input data-autofocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<button type="submit" className="button-primary" disabled={saving || name.trim() === ''}>保存</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteDialog({ playlist, onClose, onDeleted }: { playlist: MyPlaylist, onClose: () => void, onDeleted?: () => void }) {
|
||||
const [removing, setRemoving] = useState(false)
|
||||
return (
|
||||
<Modal title="删除歌单" onClose={onClose}>
|
||||
<p className="text-ui-base muted">{`删除“${playlist.name}”之后,里面的歌也一起删除。`}</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-destructive"
|
||||
disabled={removing}
|
||||
onClick={async () => {
|
||||
setRemoving(true)
|
||||
if (await playlistsStore.remove(playlist)) {
|
||||
onClose()
|
||||
onDeleted?.()
|
||||
}
|
||||
else {
|
||||
setRemoving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,14 @@ export interface PlaylistSort {
|
||||
order: SortOrder
|
||||
}
|
||||
|
||||
export const sortKeys: SortKey[] = ['name', 'createdAt', 'updatedAt', 'trackCount']
|
||||
export const sortOrders: SortOrder[] = ['asc', 'desc']
|
||||
|
||||
// 换一个字段排序时的方向:名称从 A 到 Z,时间最新在前,歌曲数最多在前。
|
||||
export function defaultOrder(key: SortKey): SortOrder {
|
||||
return key === 'name' ? 'asc' : 'desc'
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
||||
|
||||
function compareBy(key: SortKey, a: MyPlaylist, b: MyPlaylist): number {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { PlaylistSort, SortKey, SortOrder } from './playlist-sort'
|
||||
import type { ViewPreference } from './view-preference'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { ArrowDown, ArrowDownAZ, ArrowDownNarrowWide, ArrowDownWideNarrow, ArrowDownZA, ArrowUp, LayoutGrid, List, Plus, Star } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, IconButton, Modal, notificationStore, Select, Skeleton } from '@/shared/ui'
|
||||
import { CreatePlaylistForm } from './create-playlist-form'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
import { defaultOrder, sortKeys, sortPlaylists } from './playlist-sort'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
import { loadViewPreference, saveViewPreference } from './view-preference'
|
||||
import './playlists.css'
|
||||
|
||||
const sortLabels: Record<SortKey, string> = { name: '名称', createdAt: '创建时间', updatedAt: '更新时间', trackCount: '歌曲数' }
|
||||
const sortOptions = sortKeys.map(value => ({ value, label: sortLabels[value] }))
|
||||
const columns: SortKey[] = ['name', 'trackCount', 'updatedAt', 'createdAt']
|
||||
|
||||
const orderLabels: Record<SortKey, Record<SortOrder, string>> = {
|
||||
name: { asc: 'A–Z', desc: 'Z–A' },
|
||||
createdAt: { asc: '最早在前', desc: '最新在前' },
|
||||
updatedAt: { asc: '最早在前', desc: '最新在前' },
|
||||
trackCount: { asc: '最少在前', desc: '最多在前' },
|
||||
}
|
||||
|
||||
function orderIcon({ key, order }: PlaylistSort): LucideIcon {
|
||||
if (key === 'name')
|
||||
return order === 'asc' ? ArrowDownAZ : ArrowDownZA
|
||||
return order === 'asc' ? ArrowDownNarrowWide : ArrowDownWideNarrow
|
||||
}
|
||||
|
||||
function reversed(order: SortOrder): SortOrder {
|
||||
return order === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
function loadProfile(): void {
|
||||
void profileStore.load().catch((error: unknown) => {
|
||||
notificationStore.error(errorMessage(error))
|
||||
})
|
||||
}
|
||||
|
||||
export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPlaylist) => void, onRegister: () => void }) {
|
||||
const member = useStore(profileStore, s => s.member)
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const playlists = useStore(playlistsStore, s => s.playlists)
|
||||
const failed = useStore(playlistsStore, s => s.failed)
|
||||
const profileFailed = useStore(profileStore, s => s.failed)
|
||||
const [preference, setPreference] = useState(loadViewPreference)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [now] = useState(Date.now)
|
||||
useEffect(() => {
|
||||
if (!member)
|
||||
loadProfile()
|
||||
}, [member])
|
||||
useEffect(() => {
|
||||
if (account)
|
||||
void playlistsStore.load()
|
||||
}, [account])
|
||||
const { view, sort } = preference
|
||||
const update = (next: ViewPreference) => {
|
||||
saveViewPreference(next)
|
||||
setPreference(next)
|
||||
}
|
||||
const sortBy = (key: SortKey) => update({ view, sort: { key, order: key === sort.key ? reversed(sort.order) : defaultOrder(key) } })
|
||||
const shown = account ? playlists : null
|
||||
let body
|
||||
if (!member) {
|
||||
body = profileFailed
|
||||
? <button type="button" className="button-secondary" onClick={loadProfile}>重试</button>
|
||||
: <Skeleton rows={4} />
|
||||
}
|
||||
else if (!account) {
|
||||
body = (
|
||||
<EmptyState icon={Star} title="收藏你喜欢的歌" action={<button type="button" className="button-secondary" onClick={onRegister}>去注册</button>}>
|
||||
注册账号之后,就能收藏歌曲、建自己的歌单,在其他设备上也能看到。
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
else if (playlists === null) {
|
||||
body = failed
|
||||
? <button type="button" className="button-secondary" onClick={() => void playlistsStore.load()}>重试</button>
|
||||
: <Skeleton rows={4} />
|
||||
}
|
||||
else {
|
||||
const sorted = sortPlaylists(playlists, sort)
|
||||
body = view === 'list'
|
||||
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} />
|
||||
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} />
|
||||
}
|
||||
return (
|
||||
<section className="page-content playlists-page" aria-labelledby="playlists-title">
|
||||
<header className="playlists-toolbar">
|
||||
<div className="playlists-heading">
|
||||
<h1 id="playlists-title" className="text-ui-xl">我的歌单</h1>
|
||||
{shown && <p className="text-ui-sm muted">{`${shown.length} 个歌单`}</p>}
|
||||
</div>
|
||||
{shown && (
|
||||
<>
|
||||
<div className="playlists-controls">
|
||||
<div className="playlists-view-switch" role="group" aria-label="视图">
|
||||
<IconButton icon={List} label="列表" aria-pressed={view === 'list'} onClick={() => update({ view: 'list', sort })} />
|
||||
<IconButton icon={LayoutGrid} label="网格" aria-pressed={view === 'grid'} onClick={() => update({ view: 'grid', sort })} />
|
||||
</div>
|
||||
<div className="playlists-sort">
|
||||
<Select
|
||||
className="playlists-sort-key"
|
||||
ariaLabel={`排序方式:${sortLabels[sort.key]}`}
|
||||
value={sort.key}
|
||||
options={sortOptions}
|
||||
onChange={(value) => {
|
||||
const key = value as SortKey
|
||||
update({ view, sort: { key, order: key === sort.key ? sort.order : defaultOrder(key) } })
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
className="playlists-sort-order"
|
||||
icon={orderIcon(sort)}
|
||||
label={orderLabels[sort.key][sort.order]}
|
||||
onClick={() => update({ view, sort: { key: sort.key, order: reversed(sort.order) } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="button-primary playlists-create" onClick={() => setCreating(true)}>
|
||||
<Plus size={18} strokeWidth={2} aria-hidden="true" />
|
||||
新建歌单
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
{body}
|
||||
{creating && <CreateDialog onClose={() => setCreating(false)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
|
||||
playlists: MyPlaylist[]
|
||||
sort: PlaylistSort
|
||||
now: number
|
||||
onSort: (key: SortKey) => void
|
||||
onOpen: (playlist: MyPlaylist) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="playlist-table" role="table" aria-label="我的歌单">
|
||||
<div className="playlist-table-head" role="rowgroup">
|
||||
<div className="playlist-table-row" role="row">
|
||||
{columns.map((key) => {
|
||||
const active = key === sort.key
|
||||
const Arrow = sort.order === 'asc' ? ArrowUp : ArrowDown
|
||||
return (
|
||||
<div key={key} className={`playlist-col-${key}`} role="columnheader" aria-sort={active ? (sort.order === 'asc' ? 'ascending' : 'descending') : undefined}>
|
||||
<button type="button" className="playlist-sort-button text-ui-sm" data-active={active || undefined} onClick={() => onSort(key)}>
|
||||
{sortLabels[key]}
|
||||
{active && <Arrow size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="playlist-col-menu" role="columnheader"><span className="sr-only">操作</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="rowgroup">
|
||||
{playlists.map(playlist => (
|
||||
<div key={playlist.id} className="playlist-table-row playlist-row" role="row">
|
||||
<div className="playlist-col-name" role="cell">
|
||||
<button type="button" className="playlist-row-open" onClick={() => onOpen(playlist)}>
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
|
||||
<span className="playlist-row-copy">
|
||||
<span className="playlist-row-title">
|
||||
<strong className="text-ui-base truncate">{playlist.name}</strong>
|
||||
{playlist.favorites && <FavoritesMark />}
|
||||
</span>
|
||||
<span className="playlist-row-count text-ui-sm muted">{`${playlist.trackCount} 首`}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="playlist-col-trackCount text-ui-sm muted" role="cell">{playlist.trackCount}</div>
|
||||
<div className="playlist-col-updatedAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.updatedAtMs, now)}</div>
|
||||
<div className="playlist-col-createdAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.createdAtMs, now)}</div>
|
||||
<div className="playlist-col-menu" role="cell">
|
||||
{!playlist.favorites && <PlaylistMenu playlist={playlist} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistGrid({ playlists, now, onOpen }: { playlists: MyPlaylist[], now: number, onOpen: (playlist: MyPlaylist) => void }) {
|
||||
return (
|
||||
<ul className="playlist-grid">
|
||||
{playlists.map(playlist => (
|
||||
<li key={playlist.id} className="playlist-card">
|
||||
<button type="button" className="playlist-card-open" onClick={() => onOpen(playlist)}>
|
||||
<span className="playlist-card-art">
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="md" />
|
||||
</span>
|
||||
<strong className="text-ui-base truncate">{playlist.name}</strong>
|
||||
<span className="text-ui-sm muted truncate">{`${playlist.trackCount} 首 · ${formatPlaylistDate(playlist.updatedAtMs, now)}`}</span>
|
||||
</button>
|
||||
{!playlist.favorites && <PlaylistMenu playlist={playlist} className="playlist-card-menu" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Modal title="新建歌单" onClose={onClose}>
|
||||
<CreatePlaylistForm onCreated={onClose} onCancel={onClose} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -21,4 +21,107 @@
|
||||
.playlist-cover-sm { width: 40px; }
|
||||
.playlist-cover-md { width: 100%; }
|
||||
.playlist-cover-lg { width: 232px; border-radius: 10px; }
|
||||
/* z-index 为负:光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */
|
||||
.playlist-cover-glow { position: absolute; inset: 0; z-index: -1; opacity: var(--glow-opacity); filter: blur(24px) saturate(1.8); transform: translateY(10%) scale(.95); transition: opacity 240ms ease, transform 240ms ease; pointer-events: none; }
|
||||
.playlist-cover-lg > .playlist-cover-glow { filter: blur(32px) saturate(1.8); }
|
||||
:root:is([data-performance='low'], [data-performance='off']) .playlist-cover-glow { display: none; }
|
||||
.playlist-cover-badge { position: absolute; bottom: 8px; left: 8px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--color-primary); color: var(--color-primary-foreground); box-shadow: 0 1px 4px rgb(0 0 0 / 35%); }
|
||||
.playlist-cover-badge > svg { width: 13px; height: 13px; }
|
||||
.playlist-cover-lg > .playlist-cover-badge { bottom: 12px; left: 12px; width: 32px; height: 32px; }
|
||||
.playlist-cover-lg > .playlist-cover-badge > svg { width: 17px; height: 17px; }
|
||||
.playlist-favorites-mark { flex-shrink: 0; color: var(--color-brand); }
|
||||
|
||||
.playlist-menu { inset: auto; margin: 0; min-width: 148px; padding: 6px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 12px 32px var(--color-shadow); transform-origin: top center; }
|
||||
.playlist-menu:popover-open { display: flex; flex-direction: column; gap: 2px; animation: popover-in 160ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
.playlist-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; white-space: nowrap; }
|
||||
.playlist-menu-item:hover, .playlist-menu-item:focus-visible { outline: none; background: var(--color-hover); }
|
||||
.playlist-menu-item > svg { color: var(--color-foreground-subtle); }
|
||||
.playlist-menu-danger, .playlist-menu-danger > svg { color: var(--color-destructive); }
|
||||
.playlist-menu-button { color: var(--color-foreground-subtle); }
|
||||
.playlist-menu-button:hover:not(:disabled), .playlist-menu-button[aria-expanded='true'] { color: var(--color-foreground); }
|
||||
:root[data-performance='low'] .playlist-menu:popover-open { animation: fade-in 160ms ease both; }
|
||||
|
||||
.playlists-page { container: playlists / inline-size; }
|
||||
.playlists-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: 'heading controls create'; align-items: end; gap: 12px; margin-bottom: 28px; }
|
||||
.playlists-heading { grid-area: heading; min-width: 0; }
|
||||
.playlists-heading h1 { line-height: 1.25; }
|
||||
.playlists-heading p { margin-top: 4px; }
|
||||
.playlists-controls { grid-area: controls; display: flex; align-items: center; gap: 8px; }
|
||||
.playlists-create { grid-area: create; }
|
||||
.playlists-view-switch { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--color-border); border-radius: 8px; }
|
||||
.playlists-view-switch .icon-button { width: 32px; height: 30px; border-radius: 6px; color: var(--color-foreground-subtle); }
|
||||
.playlists-view-switch .icon-button svg { width: 18px; height: 18px; }
|
||||
.playlists-view-switch .icon-button:hover:not(:disabled) { color: var(--color-foreground); }
|
||||
.playlists-view-switch .icon-button[aria-pressed='true'] { background: var(--color-selected); color: var(--color-foreground); }
|
||||
.playlists-sort { display: flex; }
|
||||
.playlists-sort-key .select-trigger { min-width: calc(4em + 48px); min-height: 36px; padding: 6px 10px 6px 12px; gap: 8px; border-color: var(--color-border); border-radius: 8px 0 0 8px; background: transparent; }
|
||||
.playlists-sort-order { width: 36px; height: 36px; border: 1px solid var(--color-border); border-left: 0; border-radius: 0 8px 8px 0; color: var(--color-foreground-subtle); }
|
||||
.playlists-sort-order svg { width: 18px; height: 18px; }
|
||||
.playlists-sort-order:hover:not(:disabled) { color: var(--color-foreground); }
|
||||
|
||||
.playlist-table { --playlist-columns: minmax(0, 1fr) 64px 132px 132px 36px; }
|
||||
.playlist-table-row { display: grid; grid-template-columns: var(--playlist-columns); align-items: center; gap: 24px; padding: 0 8px; }
|
||||
.playlist-table-head .playlist-table-row { min-height: 36px; margin-bottom: 6px; border-bottom: 1px solid var(--color-border); }
|
||||
.playlist-sort-button { display: inline-flex; align-items: center; gap: 4px; padding: 4px 0; border: 0; background: none; color: var(--color-foreground-subtle); white-space: nowrap; }
|
||||
.playlist-sort-button:hover, .playlist-sort-button[data-active] { color: var(--color-foreground); }
|
||||
.playlist-sort-button > svg { color: var(--color-brand); }
|
||||
.playlist-col-trackCount { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.playlist-col-updatedAt, .playlist-col-createdAt { overflow: hidden; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playlist-col-menu { display: flex; justify-content: flex-end; }
|
||||
.playlist-row { position: relative; min-height: 56px; border-radius: 8px; }
|
||||
.playlist-row:hover { background: var(--color-surface); }
|
||||
.playlist-row-open { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; padding: 8px 0; border: 0; background: none; color: inherit; text-align: left; }
|
||||
/* 按钮只在名称这一格里,伸出的伪元素让整行都能点开歌单。 */
|
||||
.playlist-row-open::after { content: ''; position: absolute; inset: 0; border-radius: 8px; }
|
||||
.playlist-row-open:focus-visible { outline: none; }
|
||||
.playlist-row-open:focus-visible::after { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.playlist-row-open:active:not(:disabled) { transform: none; }
|
||||
.playlist-row-copy { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.playlist-row-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.playlist-row-title > strong { font-weight: 500; }
|
||||
.playlist-row-count { display: none; }
|
||||
.playlist-row .playlist-menu-button { position: relative; z-index: 1; }
|
||||
|
||||
.playlist-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 28px 20px; margin: 0; padding: 0; list-style: none; }
|
||||
.playlist-card { position: relative; min-width: 0; transition: transform 200ms ease; }
|
||||
.playlist-card-open { isolation: isolate; display: flex; flex-direction: column; gap: 2px; width: 100%; min-width: 0; padding: 0; border: 0; border-radius: 10px; background: none; color: inherit; text-align: left; }
|
||||
.playlist-card-open:focus-visible { outline-offset: 6px; }
|
||||
.playlist-card-art { position: relative; display: block; margin-bottom: 12px; }
|
||||
.playlist-card-menu { position: absolute; top: 8px; right: 8px; z-index: 1; width: 32px; height: 32px; border-radius: 50%; background: var(--color-popover); color: var(--color-foreground); transition: opacity 150ms ease, background-color 150ms ease; }
|
||||
.playlist-card-menu:hover:not(:disabled), .playlist-card-menu[aria-expanded='true'] { background: var(--color-popover); color: var(--color-brand); }
|
||||
@media (hover: hover) {
|
||||
.playlist-row .playlist-menu-button, .playlist-card-menu { opacity: 0; }
|
||||
.playlist-row:hover .playlist-menu-button, .playlist-row:focus-within .playlist-menu-button,
|
||||
.playlist-card:hover .playlist-card-menu, .playlist-card:focus-within .playlist-card-menu,
|
||||
.playlist-menu-button[aria-expanded='true'] { opacity: 1; }
|
||||
.playlist-card:is(:hover, :has(:focus-visible)) .playlist-cover-glow { opacity: calc(var(--glow-opacity) + .2); transform: translateY(12%) scale(1); }
|
||||
}
|
||||
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
|
||||
:root:not([data-performance='low'], [data-performance='off'], [data-reduced-motion]) .playlist-card:is(:hover, :has(:focus-visible)) { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
.my-playlist-page { container: my-playlist / inline-size; }
|
||||
.my-playlist-back { margin: -6px 0 16px -8px; color: var(--color-foreground-subtle); }
|
||||
.my-playlist-back:hover:not(:disabled) { color: var(--color-foreground); }
|
||||
.my-playlist-header { isolation: isolate; display: flex; align-items: flex-end; gap: 28px; margin-bottom: 16px; padding-bottom: 28px; border-bottom: 1px solid var(--color-border); }
|
||||
.my-playlist-info { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
.my-playlist-name { display: flex; align-items: center; gap: 10px; max-width: 100%; font-size: calc(var(--text-ui-xl) + 8px); font-weight: 700; line-height: 1.2; }
|
||||
.my-playlist-name > span { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
|
||||
.my-playlist-menu { margin-top: 4px; border: 1px solid var(--color-border); border-radius: 50%; }
|
||||
.my-playlist-hint { margin-top: 12px; }
|
||||
|
||||
@container playlists (max-width: 720px) {
|
||||
.playlist-table { --playlist-columns: minmax(0, 1fr) 36px; }
|
||||
.playlist-table-head, .playlist-col-trackCount, .playlist-col-updatedAt, .playlist-col-createdAt { display: none; }
|
||||
.playlist-row-count { display: block; }
|
||||
}
|
||||
@container playlists (max-width: 560px) {
|
||||
.playlists-toolbar { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: 'heading create' 'controls controls'; align-items: center; gap: 16px 12px; margin-bottom: 20px; }
|
||||
.playlists-controls { justify-content: space-between; }
|
||||
.playlist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; }
|
||||
}
|
||||
@container my-playlist (max-width: 560px) {
|
||||
.my-playlist-header { flex-direction: column; align-items: flex-start; gap: 16px; padding-bottom: 20px; }
|
||||
.playlist-cover-lg { width: 176px; }
|
||||
.my-playlist-name { font-size: var(--text-ui-xl); }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadViewPreference, saveViewPreference } from './view-preference'
|
||||
|
||||
const key = 'alisten.playlists-view'
|
||||
let items: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
items = new Map()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (name: string) => items.get(name) ?? null,
|
||||
setItem: (name: string, value: string) => items.set(name, value),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const fallback = { view: 'grid', sort: { key: 'updatedAt', order: 'desc' } }
|
||||
|
||||
describe('视图偏好', () => {
|
||||
it('没有保存过时用网格视图,按更新时间、最新在前', () => {
|
||||
expect(loadViewPreference()).toEqual(fallback)
|
||||
})
|
||||
|
||||
it('保存之后读到同样的偏好', () => {
|
||||
const preference = { view: 'list', sort: { key: 'name', order: 'asc' } } as const
|
||||
saveViewPreference(preference)
|
||||
expect(loadViewPreference()).toEqual(preference)
|
||||
})
|
||||
|
||||
it('不是 JSON 时回退默认', () => {
|
||||
items.set(key, '{')
|
||||
expect(loadViewPreference()).toEqual(fallback)
|
||||
items.set(key, 'null')
|
||||
expect(loadViewPreference()).toEqual(fallback)
|
||||
})
|
||||
|
||||
it('无效的字段各自回退默认', () => {
|
||||
items.set(key, JSON.stringify({ view: 'list', sort: { key: 'size', order: 'asc' } }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
|
||||
items.set(key, JSON.stringify({ view: 'columns', sort: { key: 'trackCount', order: 'asc' } }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'grid', sort: { key: 'trackCount', order: 'asc' } })
|
||||
items.set(key, JSON.stringify({ view: 'list', sort: { key: 'name', order: 'up' } }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
|
||||
items.set(key, JSON.stringify({ view: 'list', sort: 'name' }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PlaylistSort } from './playlist-sort'
|
||||
import { sortKeys, sortOrders } from './playlist-sort'
|
||||
|
||||
export type PlaylistView = 'list' | 'grid'
|
||||
|
||||
export interface ViewPreference {
|
||||
view: PlaylistView
|
||||
sort: PlaylistSort
|
||||
}
|
||||
|
||||
const preferenceKey = 'alisten.playlists-view'
|
||||
const views: PlaylistView[] = ['list', 'grid']
|
||||
const defaultPreference: ViewPreference = { view: 'grid', sort: { key: 'updatedAt', order: 'desc' } }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function readStored(): Record<string, unknown> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(preferenceKey) ?? 'null')
|
||||
return isObject(value) ? value : {}
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadViewPreference(): ViewPreference {
|
||||
const stored = readStored()
|
||||
const sort = isObject(stored.sort) ? stored.sort : {}
|
||||
const key = sortKeys.find(k => k === sort.key)
|
||||
const order = sortOrders.find(o => o === sort.order)
|
||||
return {
|
||||
view: views.find(v => v === stored.view) ?? defaultPreference.view,
|
||||
sort: key && order ? { key, order } : defaultPreference.sort,
|
||||
}
|
||||
}
|
||||
|
||||
export function saveViewPreference(preference: ViewPreference): void {
|
||||
localStorage.setItem(preferenceKey, JSON.stringify(preference))
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
--color-toast: #303034;
|
||||
--color-overlay: rgb(0 0 0 / 55%);
|
||||
--color-shadow: rgb(0 0 0 / 24%);
|
||||
--glow-opacity: .8;
|
||||
color-scheme: dark;
|
||||
font-synthesis: none;
|
||||
}
|
||||
@@ -87,6 +88,7 @@
|
||||
--color-success: #287144;
|
||||
--color-toast: #fff;
|
||||
--color-shadow: rgb(30 30 45 / 12%);
|
||||
--glow-opacity: .5;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
@@ -121,6 +123,7 @@
|
||||
--color-success: #287144;
|
||||
--color-toast: #fff;
|
||||
--color-shadow: rgb(30 30 45 / 12%);
|
||||
--glow-opacity: .5;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +108,4 @@
|
||||
.modal-body { padding: 20px 20px calc(20px + env(safe-area-inset-bottom)); }
|
||||
.notifications { --notification-top: calc(76px + env(safe-area-inset-top)); }
|
||||
}
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
Reference in new issue
Block a user