feat: share my playlists from the playlist menu
This commit is contained in:
1 parent
8b941764b1
commit
1f99bd939b
4 files changed
+210
-7
No files matched your search
@@ -242,7 +242,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
{selecting ? '完成' : '选择'}
|
||||
</button>
|
||||
)}
|
||||
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
|
||||
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" shareable onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
import type { MenuAction } from './action-menu'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { Ellipsis, FileDown, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Copy, Ellipsis, FileDown, Link2Off, Pencil, RefreshCw, Share2, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ConfirmDialog, Modal } from '@/shared/ui'
|
||||
import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui'
|
||||
import { ActionMenu } from './action-menu'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
|
||||
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted, onExport }: {
|
||||
async function copyShareLink(shareId: string, copied: string): Promise<void> {
|
||||
const url = `${window.location.origin}/s/${shareId}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
notificationStore.success(copied)
|
||||
}
|
||||
catch {
|
||||
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权;有的浏览器在请求返回之后已不算用户操作,也会拒绝写入。
|
||||
notificationStore.info(`请手动复制分享链接:${url}`, 8000)
|
||||
}
|
||||
}
|
||||
|
||||
async function share(playlist: MyPlaylist, copied: string): Promise<void> {
|
||||
const shareId = await playlistsStore.share(playlist)
|
||||
if (shareId)
|
||||
await copyShareLink(shareId, copied)
|
||||
}
|
||||
|
||||
export function PlaylistMenu({ playlist, align = 'end', className = '', shareable = false, onDeleted, onExport }: {
|
||||
playlist: MyPlaylist
|
||||
align?: 'start' | 'end'
|
||||
className?: string
|
||||
shareable?: boolean
|
||||
onDeleted?: () => void
|
||||
onExport?: () => void
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<'rename' | 'delete' | null>(null)
|
||||
const [dialog, setDialog] = useState<'rename' | 'delete' | 'reshare' | 'unshare' | null>(null)
|
||||
const actions: MenuAction[] = []
|
||||
if (!playlist.favorites)
|
||||
actions.push({ icon: Pencil, label: '重命名', run: () => setDialog('rename') })
|
||||
if (shareable && playlist.shareId) {
|
||||
const { shareId } = playlist
|
||||
actions.push(
|
||||
{ icon: Copy, label: '复制分享链接', run: () => void copyShareLink(shareId, '已复制分享链接') },
|
||||
{ icon: RefreshCw, label: '重新生成分享链接', run: () => setDialog('reshare') },
|
||||
{ icon: Link2Off, label: '停止分享', run: () => setDialog('unshare') },
|
||||
)
|
||||
}
|
||||
else if (shareable) {
|
||||
actions.push({ icon: Share2, label: '分享', run: () => void share(playlist, '已开启分享,链接已复制') })
|
||||
}
|
||||
if (onExport)
|
||||
actions.push({ icon: FileDown, label: '导出', run: onExport })
|
||||
if (!playlist.favorites)
|
||||
@@ -50,6 +80,32 @@ export function PlaylistMenu({ playlist, align = 'end', className = '', onDelete
|
||||
{`删除“${playlist.name}”之后,里面的歌也一起删除。`}
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
{dialog === 'reshare' && (
|
||||
<ConfirmDialog
|
||||
title="重新生成分享链接"
|
||||
confirmLabel="重新生成"
|
||||
closeOnConfirm
|
||||
onConfirm={() => share(playlist, '已生成新链接并复制')}
|
||||
onClose={() => setDialog(null)}
|
||||
>
|
||||
生成新链接之后,之前分享出去的链接就打不开了。
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
{dialog === 'unshare' && (
|
||||
<ConfirmDialog
|
||||
title="停止分享"
|
||||
confirmLabel="停止分享"
|
||||
danger
|
||||
closeOnConfirm
|
||||
onConfirm={async () => {
|
||||
if (await playlistsStore.unshare(playlist))
|
||||
notificationStore.success('已停止分享')
|
||||
}}
|
||||
onClose={() => setDialog(null)}
|
||||
>
|
||||
停止之后,分享出去的链接就打不开了。
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Track } from '@/core/realtime'
|
||||
import type { MyPlaylist, Track } from '@/core/realtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
class FakeStorage {
|
||||
@@ -35,6 +35,7 @@ const full = '一个歌单最多 1000 首歌'
|
||||
const changed = '歌单已经变了,刷新之后再试'
|
||||
const imported = { id: 'p3', name: '导入的歌单', favorites: false, trackCount: 2, createdAtMs: 2, updatedAtMs: 2, coverUrl: null, shareId: null }
|
||||
const fileTrack = { track: sunny, title: '晴天', durationMs: 269000 }
|
||||
const copied = { id: 'p4', name: '抄来的', favorites: false, trackCount: 2, createdAtMs: 3, updatedAtMs: 3, coverUrl: null, shareId: null }
|
||||
|
||||
let requests: string[]
|
||||
let release: (() => void)[]
|
||||
@@ -43,10 +44,12 @@ let failAdd: boolean
|
||||
let failCopy: boolean
|
||||
let failMove: boolean
|
||||
let importFailure: { status: number, code: string, message: string } | null
|
||||
let shareFailure: { status: number, code: string, message: string } | null
|
||||
let shares: number
|
||||
let bodies: unknown[]
|
||||
let holdReads: boolean
|
||||
let failReads: boolean
|
||||
let serverPlaylists: typeof favorites[]
|
||||
let serverPlaylists: MyPlaylist[]
|
||||
let server: Map<string, Map<string, Track>>
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
@@ -84,6 +87,8 @@ beforeEach(() => {
|
||||
failCopy = false
|
||||
failMove = false
|
||||
importFailure = null
|
||||
shareFailure = null
|
||||
shares = 0
|
||||
bodies = []
|
||||
holdReads = false
|
||||
failReads = false
|
||||
@@ -153,6 +158,18 @@ beforeEach(() => {
|
||||
return json(409, { error: { code: 'CONFLICT', message: changed } })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (shareFailure && /^(?:POST|DELETE) \/api\/(?:me\/playlists\/\w+\/share|shared-playlists\/[\w-]+\/copies)$/.test(request))
|
||||
return json(shareFailure.status, { error: { code: shareFailure.code, message: shareFailure.message } })
|
||||
const share = /^(POST|DELETE) \/api\/me\/playlists\/(\w+)\/share$/.exec(request)
|
||||
if (share) {
|
||||
const shareId = share[1] === 'POST' ? `share-${++shares}` : null
|
||||
serverPlaylists = serverPlaylists.map(p => p.id === share[2] ? { ...p, shareId } : p)
|
||||
return shareId ? json(200, { shareId }) : new Response(null, { status: 204 })
|
||||
}
|
||||
if (request === 'POST /api/shared-playlists/share-1/copies') {
|
||||
serverPlaylists.push(copied)
|
||||
return json(201, copied)
|
||||
}
|
||||
throw new Error(`没有预期的请求 ${request}`)
|
||||
})
|
||||
})
|
||||
@@ -629,4 +646,91 @@ describe('playlistsStore', () => {
|
||||
expect([await first, await second]).toEqual([null, null])
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单名要 1 到 50 个字。', '导入太频繁了,稍后再试'])
|
||||
})
|
||||
|
||||
it('share 返回新的分享 ID,列表里立刻换上它,再重新读取', async () => {
|
||||
serverPlaylists.push(commute)
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
requests = []
|
||||
holdReads = true
|
||||
const first = playlistsStore.share(commute)
|
||||
await flush()
|
||||
release.shift()!()
|
||||
expect(await first).toBe('share-1')
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, { ...commute, shareId: 'share-1' }])
|
||||
const second = playlistsStore.share({ ...commute, shareId: 'share-1' })
|
||||
await flush()
|
||||
release.splice(1, 1)[0]()
|
||||
expect(await second).toBe('share-2')
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, { ...commute, shareId: 'share-2' }])
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p2/share', 'GET /api/me/playlists', 'POST /api/me/playlists/p2/share', 'GET /api/me/playlists'])
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, { ...commute, shareId: 'share-2' }])
|
||||
})
|
||||
|
||||
it('unshare 之后列表里立刻去掉分享 ID,再重新读取', async () => {
|
||||
serverPlaylists.push({ ...commute, shareId: 'share-0' })
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
requests = []
|
||||
holdReads = true
|
||||
const done = playlistsStore.unshare({ ...commute, shareId: 'share-0' })
|
||||
await flush()
|
||||
release.shift()!()
|
||||
expect(await done).toBe(true)
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(requests).toEqual(['DELETE /api/me/playlists/p2/share', 'GET /api/me/playlists'])
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
|
||||
})
|
||||
|
||||
it('share 和 unshare 失败时提示服务端的文案,列表不变,不重新读取', async () => {
|
||||
serverPlaylists.push(commute)
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
await playlistsStore.load()
|
||||
requests = []
|
||||
shareFailure = { status: 404, code: 'NOT_FOUND', message: '歌单不存在' }
|
||||
const shared = playlistsStore.share(commute)
|
||||
await settle()
|
||||
const unshared = playlistsStore.unshare(commute)
|
||||
await settle()
|
||||
expect([await shared, await unshared]).toEqual([null, false])
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p2/share', 'DELETE /api/me/playlists/p2/share'])
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单不存在', '歌单不存在'])
|
||||
})
|
||||
|
||||
it('copyShared 成功后新歌单立刻出现在列表里,再重新读取', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
requests = []
|
||||
holdReads = true
|
||||
const done = playlistsStore.copyShared('share-1', '抄来的')
|
||||
await flush()
|
||||
release.shift()!()
|
||||
expect(await done).toEqual(copied)
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, copied])
|
||||
expect(bodies).toEqual([{ name: '抄来的' }])
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(requests).toEqual(['POST /api/shared-playlists/share-1/copies', 'GET /api/me/playlists'])
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, copied])
|
||||
})
|
||||
|
||||
it('copyShared 歌单名不合法时说明规则,其他错误提示服务端的文案', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
shareFailure = { status: 400, code: 'INVALID_ARGUMENT', message: 'name:去掉首尾空白后应有 1 到 50 个字符' }
|
||||
const first = playlistsStore.copyShared('share-1', ' ')
|
||||
await settle()
|
||||
shareFailure = { status: 404, code: 'NOT_FOUND', message: '分享已失效' }
|
||||
const second = playlistsStore.copyShared('share-1', '抄来的')
|
||||
await settle()
|
||||
expect([await first, await second]).toEqual([null, null])
|
||||
expect(requests).toEqual(['POST /api/shared-playlists/share-1/copies', 'POST /api/shared-playlists/share-1/copies'])
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单名要 1 到 50 个字。', '分享已失效'])
|
||||
})
|
||||
})
|
||||
@@ -210,6 +210,49 @@ class PlaylistsStore extends Store<PlaylistsState> {
|
||||
}
|
||||
}
|
||||
|
||||
// 开启分享和重新生成链接都用它,返回新的分享 ID;失败时返回 null。
|
||||
async share(playlist: MyPlaylist): Promise<string | null> {
|
||||
try {
|
||||
const { shareId } = await api.shareMyPlaylist(playlist.id)
|
||||
this.#changed(playlist.id, { shareId })
|
||||
return shareId
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async unshare(playlist: MyPlaylist): Promise<boolean> {
|
||||
try {
|
||||
await api.unshareMyPlaylist(playlist.id)
|
||||
this.#changed(playlist.id, { shareId: null })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async copyShared(shareId: string, name: string): Promise<MyPlaylist | null> {
|
||||
try {
|
||||
const playlist = await api.copySharedPlaylist(shareId, { name })
|
||||
this.#added(playlist)
|
||||
return playlist
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error, nameTexts))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 先改列表再重新读取:菜单要立刻显示新的分享状态,重新读取也让进行中的旧读取作废。
|
||||
#changed(id: string, change: Partial<MyPlaylist>): void {
|
||||
this.setState({ playlists: this.state.playlists?.map(p => p.id === id ? { ...p, ...change } : p) ?? null })
|
||||
void this.load()
|
||||
}
|
||||
|
||||
// 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。
|
||||
#added(playlist: MyPlaylist): void {
|
||||
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
|
||||
|
||||
Reference in new issue
Block a user