feat: import playlists from files, links and search results
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
This commit is contained in:
1 parent
93a91aad81
commit
0f84f11fd2
13 files changed
+382
-126
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { openAccountSettings } from './account-settings'
|
||||
|
||||
export function AccountPrompt({ message, onClose }: { message: string, onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-ui-base muted">{message}</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
onClick={() => {
|
||||
onClose()
|
||||
openAccountSettings()
|
||||
}}
|
||||
>
|
||||
去账号设置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
|
||||
// 游客在保存歌曲或歌单的对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
|
||||
let open = (): void => {}
|
||||
|
||||
export function openAccountSettings(): void {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { KeyboardEvent, ReactNode, ToggleEvent } from 'react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
|
||||
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 interface MenuAction {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
danger?: boolean
|
||||
run: () => void
|
||||
}
|
||||
|
||||
export function ActionMenu({ actions, menuLabel, align = 'end', className, label, title, disabled, children }: {
|
||||
actions: MenuAction[]
|
||||
menuLabel: string
|
||||
align?: 'start' | 'end'
|
||||
className: string
|
||||
label?: string
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const menuId = useId()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
|
||||
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()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={className}
|
||||
popoverTarget={menuId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={menuLabel} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
|
||||
{actions.map(({ icon: Icon, label, danger, run }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
className={danger ? 'playlist-menu-item playlist-menu-danger' : 'playlist-menu-item'}
|
||||
onClick={() => {
|
||||
menuRef.current!.hidePopover()
|
||||
run()
|
||||
}}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { FileUp, Import, Link } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Modal, notificationStore } from '@/shared/ui'
|
||||
import { ActionMenu } from './action-menu'
|
||||
import { importLimit, parsePlaylistFile } from './playlist-file'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
import { defaultImportName } from './save-platform-playlist'
|
||||
|
||||
async function importFile(file: File): Promise<void> {
|
||||
const content = await file.text().catch(() => null)
|
||||
const parsed = content === null ? '没能读取这个文件。' : parsePlaylistFile(content)
|
||||
if (typeof parsed === 'string') {
|
||||
notificationStore.error(parsed)
|
||||
return
|
||||
}
|
||||
const result = await playlistsStore.importFile(parsed.name, parsed.tracks)
|
||||
if (!result)
|
||||
return
|
||||
const done = `已导入 ${result.imported} 首到“${result.playlist.name}”`
|
||||
if (parsed.truncated)
|
||||
notificationStore.success(`${done}。文件里超过 ${importLimit} 首,只导入了前 ${importLimit} 首。`, 6000)
|
||||
else
|
||||
notificationStore.success(done)
|
||||
}
|
||||
|
||||
export function ImportMenu({ onOpen }: { onOpen: (playlist: MyPlaylist) => void }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [linking, setLinking] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<ActionMenu
|
||||
actions={[
|
||||
{ icon: FileUp, label: '从文件导入', run: () => fileRef.current!.click() },
|
||||
{ icon: Link, label: '从链接导入', run: () => setLinking(true) },
|
||||
]}
|
||||
menuLabel="导入歌单"
|
||||
className="button-secondary"
|
||||
disabled={importing}
|
||||
>
|
||||
<Import size={18} strokeWidth={1.7} aria-hidden="true" />
|
||||
{importing ? '正在导入…' : '导入'}
|
||||
</ActionMenu>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
hidden
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
// 清空之后,再选同一个文件也会触发 change。
|
||||
event.target.value = ''
|
||||
if (!file)
|
||||
return
|
||||
setImporting(true)
|
||||
await importFile(file)
|
||||
setImporting(false)
|
||||
}}
|
||||
/>
|
||||
{linking && (
|
||||
<ImportLinkDialog
|
||||
onClose={() => setLinking(false)}
|
||||
onImported={(playlist) => {
|
||||
setLinking(false)
|
||||
onOpen(playlist)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportLinkDialog({ onClose, onImported }: { onClose: () => void, onImported: (playlist: MyPlaylist) => void }) {
|
||||
const [link, setLink] = useState('')
|
||||
const [name, setName] = useState(defaultImportName)
|
||||
const [busy, setBusy] = useState(false)
|
||||
return (
|
||||
<Modal title="从链接导入" onClose={onClose}>
|
||||
<form
|
||||
className="form-stack"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (busy)
|
||||
return
|
||||
setBusy(true)
|
||||
const found = await playlistsStore.resolveLink(link)
|
||||
const result = found && await playlistsStore.importPlatform(name, found.source, found.playlistId)
|
||||
if (!result) {
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
notificationStore.success(`已导入 ${result.imported} 首`)
|
||||
onImported(result.playlist)
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
歌单链接
|
||||
<input data-autofocus required placeholder="粘贴网易云音乐的歌单链接或分享文字" value={link} onChange={event => setLink(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
歌单名
|
||||
<input required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
|
||||
<button type="submit" className="button-primary" disabled={busy || link.trim() === '' || name.trim() === ''}>{busy ? '正在导入…' : '导入'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { lazyPage } from '@/shared/ui'
|
||||
export { setAccountSettingsOpener } from './account-settings'
|
||||
export { playlistsStore } from './playlists-store'
|
||||
export { saveToPlaylistLabel } from './save-label'
|
||||
export { SavePlatformPlaylistButton } from './save-platform-playlist'
|
||||
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))
|
||||
@@ -9,9 +9,10 @@ import { roomStore } from '@/core/room'
|
||||
import { enqueue } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, Modal, Skeleton } from '@/shared/ui'
|
||||
import { EmptyState, Modal, notificationStore, Skeleton } from '@/shared/ui'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { exportFileName, exportPlaylist } from './playlist-file'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
import { PlaylistTracks } from './playlist-tracks'
|
||||
import { playlistsStore, trackKey } from './playlists-store'
|
||||
@@ -27,6 +28,23 @@ function keysOf(items: readonly MyPlaylistItem[]): string[] {
|
||||
return items.map(item => trackKey(item.track))
|
||||
}
|
||||
|
||||
// 导出服务端的曲目:页面上的曲目可能还没读到,或者带着没同步完的移动和移出。
|
||||
async function exportToFile(playlist: MyPlaylist): Promise<void> {
|
||||
try {
|
||||
const { items } = await api.listMyPlaylistItems(playlist.id)
|
||||
const url = URL.createObjectURL(new Blob([exportPlaylist(playlist.name, items, Date.now())], { type: 'application/json' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = exportFileName(playlist.name)
|
||||
link.click()
|
||||
// 下载在 click() 时已经开始,到下一个任务再释放对象 URL。
|
||||
setTimeout(() => URL.revokeObjectURL(url))
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(`没能导出:${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -224,7 +242,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
{selecting ? '完成' : '选择'}
|
||||
</button>
|
||||
)}
|
||||
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
|
||||
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,118 +1,38 @@
|
||||
import type { KeyboardEvent, ToggleEvent } from 'react'
|
||||
import type { MenuAction } from './action-menu'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Ellipsis, FileDown, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/shared/ui'
|
||||
import { ActionMenu } from './action-menu'
|
||||
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 }: {
|
||||
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted, onExport }: {
|
||||
playlist: MyPlaylist
|
||||
align?: 'start' | 'end'
|
||||
className?: string
|
||||
onDeleted?: () => void
|
||||
onExport?: () => 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)
|
||||
}
|
||||
|
||||
const actions: MenuAction[] = []
|
||||
if (!playlist.favorites)
|
||||
actions.push({ icon: Pencil, label: '重命名', run: () => setDialog('rename') })
|
||||
if (onExport)
|
||||
actions.push({ icon: FileDown, label: '导出', run: onExport })
|
||||
if (!playlist.favorites)
|
||||
actions.push({ icon: Trash2, label: '删除', danger: true, run: () => setDialog('delete') })
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
<ActionMenu
|
||||
actions={actions}
|
||||
menuLabel={`“${playlist.name}”的操作`}
|
||||
align={align}
|
||||
className={`icon-button playlist-menu-button ${className}`}
|
||||
popoverTarget={menuId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`“${playlist.name}”的更多操作`}
|
||||
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>
|
||||
</ActionMenu>
|
||||
{dialog === 'rename' && <RenameDialog playlist={playlist} onClose={() => setDialog(null)} />}
|
||||
{dialog === 'delete' && <DeleteDialog playlist={playlist} onClose={() => setDialog(null)} onDeleted={onDeleted} />}
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { ImportMenu } from './import-menu'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
@@ -145,10 +146,13 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="button-primary playlists-create" onClick={() => setCreating(true)}>
|
||||
<Plus size={18} strokeWidth={2} aria-hidden="true" />
|
||||
新建歌单
|
||||
</button>
|
||||
<div className="playlists-actions">
|
||||
<ImportMenu onOpen={onOpen} />
|
||||
<button type="button" className="button-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={18} strokeWidth={2} aria-hidden="true" />
|
||||
新建歌单
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
.playlists-error { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
|
||||
.playlists-heading p { margin-top: 4px; }
|
||||
.playlists-controls { grid-area: controls; display: flex; align-items: center; gap: 8px; }
|
||||
.playlists-create { grid-area: create; }
|
||||
.playlists-actions { grid-area: create; display: flex; gap: 8px; }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Source } from '@/core/realtime'
|
||||
import { ListPlus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { Modal, notificationStore } from '@/shared/ui'
|
||||
import { AccountPrompt } from './account-prompt'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
|
||||
export const defaultImportName = '导入的歌单'
|
||||
|
||||
export function SavePlatformPlaylistButton({ source, playlistId, name }: { source: Source, playlistId: string, name?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="button-secondary" onClick={() => setOpen(true)}>
|
||||
<ListPlus size={18} strokeWidth={1.7} aria-hidden="true" />
|
||||
保存为我的歌单
|
||||
</button>
|
||||
{open && <SavePlatformPlaylistDialog source={source} playlistId={playlistId} name={name ?? defaultImportName} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SavePlatformPlaylistDialog({ source, playlistId, name: original, onClose }: { source: Source, playlistId: string, name: string, onClose: () => void }) {
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const [name, setName] = useState(original)
|
||||
const [busy, setBusy] = useState(false)
|
||||
return (
|
||||
<Modal title="保存为我的歌单" onClose={onClose}>
|
||||
{account
|
||||
? (
|
||||
<form
|
||||
className="form-stack"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (busy)
|
||||
return
|
||||
setBusy(true)
|
||||
const result = await playlistsStore.importPlatform(name, source, playlistId)
|
||||
if (!result) {
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
notificationStore.success(`已保存为“${result.playlist.name}”,共 ${result.imported} 首`)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
歌单名
|
||||
<input data-autofocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
|
||||
<button type="submit" className="button-primary" disabled={busy || name.trim() === ''}>{busy ? '正在保存…' : '保存'}</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
: <AccountPrompt message="注册或登录之后就能保存这个歌单。" onClose={onClose} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui'
|
||||
import { openAccountSettings } from './account-settings'
|
||||
import { AccountPrompt } from './account-prompt'
|
||||
import { CreatePlaylistForm } from './create-playlist-form'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { sortPlaylists } from './playlist-sort'
|
||||
@@ -104,23 +104,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
|
||||
|
||||
let body
|
||||
if (!account) {
|
||||
body = (
|
||||
<>
|
||||
<p className="text-ui-base muted">注册或登录之后就能把歌保存到歌单。</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
onClick={() => {
|
||||
onClose()
|
||||
openAccountSettings()
|
||||
}}
|
||||
>
|
||||
去账号设置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
body = <AccountPrompt message="注册或登录之后就能把歌保存到歌单。" onClose={onClose} />
|
||||
}
|
||||
else if (readFailed || (playlists === null && error !== null)) {
|
||||
body = (
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SongItem } from '@/shared/ui'
|
||||
import { ArrowLeft, ArrowRight, CirclePlus, Clock3, Search, Sparkles } from 'lucide-react'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { historyStore } from '@/features/history'
|
||||
import { SavePlatformPlaylistButton } from '@/features/playlists'
|
||||
import { enqueue } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
@@ -346,6 +347,7 @@ export function SearchPage({ inlineSearchClassName, onSearch, onOpenPlaylist }:
|
||||
}
|
||||
|
||||
export function PlaylistPage({ title, page, onPage }: { title: string | undefined, page: number, onPage: (page: number) => void }) {
|
||||
const playlist = useStore(searchStore, s => s.playlist)
|
||||
const tracks = useStore(searchStore, s => s.tracks)
|
||||
const total = useStore(searchStore, s => s.tracksTotal)
|
||||
const loading = useStore(searchStore, s => s.loading)
|
||||
@@ -353,8 +355,9 @@ export function PlaylistPage({ title, page, onPage }: { title: string | undefine
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
return (
|
||||
<div className="page-content search-page">
|
||||
<header className="search-page-heading">
|
||||
<header className="search-page-heading search-playlist-heading">
|
||||
<h1 className="text-ui-xl">{title ?? '歌单'}</h1>
|
||||
{playlist && <SavePlatformPlaylistButton source={playlist.source} playlistId={playlist.id} name={title} />}
|
||||
</header>
|
||||
{failed && (
|
||||
<div className="search-error" role="alert">
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
.search-source .select-trigger { padding: 7px 12px; border-radius: 999px; background: transparent; }
|
||||
.search-page-heading { margin: 16px 0 28px; }
|
||||
.search-page-heading p { margin-top: 6px; overflow-wrap: anywhere; }
|
||||
.search-playlist-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }
|
||||
.search-playlist-heading h1 { min-width: 0; overflow-wrap: anywhere; }
|
||||
.search-recommend-hint { display: none; align-items: center; gap: 6px; }
|
||||
.search-recommend-hint > svg { color: var(--color-brand); }
|
||||
.search-page .section-heading { margin-bottom: 16px; }
|
||||
|
||||
Reference in new issue
Block a user