feat(web): /playlists/{id} detail page with drag-reorder (M7 #352)
Header shows collage, name, description, public/private chip, track count, owner attribution (when not owner). Edit + delete buttons visible to the owner only. Track list uses PlaylistTrackRow with HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered positions and TanStack Query invalidates the playlist + index cache. Toast surface is a placeholder browser alert in slice 1 — a real toast is a polish task whenever it lands.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import PlaylistTrackRow from '$lib/components/PlaylistTrackRow.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
import {
|
||||
createPlaylistQuery,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
removePlaylistTrack,
|
||||
reorderPlaylist
|
||||
} from '$lib/api/playlists';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { copyForCode } from '$lib/api/error-copy';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
const id = $derived($page.params.id);
|
||||
const queryClient = useQueryClient();
|
||||
const playlistStore = $derived(createPlaylistQuery(id));
|
||||
const playlistQuery = $derived($playlistStore);
|
||||
const isOwner = $derived(user.value?.id === playlistQuery?.data?.user_id);
|
||||
|
||||
let dragFromPos = $state<number | null>(null);
|
||||
|
||||
async function onDrop(toPos: number) {
|
||||
if (dragFromPos === null || !playlistQuery?.data) {
|
||||
return;
|
||||
}
|
||||
if (dragFromPos === toPos) {
|
||||
dragFromPos = null;
|
||||
return;
|
||||
}
|
||||
const cur = playlistQuery.data.tracks.map((t) => t.position);
|
||||
const moved = cur.splice(dragFromPos, 1)[0];
|
||||
cur.splice(toPos, 0, moved);
|
||||
dragFromPos = null;
|
||||
try {
|
||||
await reorderPlaylist(id, cur);
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||||
alert(copyForCode(code));
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(position: number) {
|
||||
try {
|
||||
await removePlaylistTrack(id, position);
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||||
alert(copyForCode(code));
|
||||
}
|
||||
}
|
||||
|
||||
function onPlay(position: number) {
|
||||
if (!playlistQuery?.data) return;
|
||||
// Skip rows where track_id is null (track was removed from library).
|
||||
const live = playlistQuery.data.tracks
|
||||
.filter((t) => t.track_id !== null)
|
||||
.map(toTrackRef);
|
||||
// Find which `live` index corresponds to the clicked position. Some
|
||||
// positions in the original list may be unavailable (filtered out),
|
||||
// so the index in the playable list != the playlist position.
|
||||
const clickedTrackID = playlistQuery.data.tracks.find((t) => t.position === position)?.track_id;
|
||||
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
||||
playQueue(live, Math.max(0, startIdx));
|
||||
}
|
||||
|
||||
function toTrackRef(t: {
|
||||
track_id: string | null;
|
||||
album_id: string | null;
|
||||
artist_id: string | null;
|
||||
title: string;
|
||||
album_title: string;
|
||||
artist_name: string;
|
||||
duration_sec: number;
|
||||
stream_url: string | null;
|
||||
}): TrackRef {
|
||||
return {
|
||||
id: t.track_id!,
|
||||
title: t.title,
|
||||
album_id: t.album_id ?? '',
|
||||
album_title: t.album_title,
|
||||
artist_id: t.artist_id ?? '',
|
||||
artist_name: t.artist_name,
|
||||
duration_sec: t.duration_sec,
|
||||
stream_url: t.stream_url ?? ''
|
||||
} as unknown as TrackRef;
|
||||
}
|
||||
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
let editDesc = $state('');
|
||||
let editIsPublic = $state(false);
|
||||
|
||||
function startEdit() {
|
||||
if (!playlistQuery?.data) return;
|
||||
editName = playlistQuery.data.name;
|
||||
editDesc = playlistQuery.data.description;
|
||||
editIsPublic = playlistQuery.data.is_public;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
try {
|
||||
await updatePlaylist(id, { name: editName, description: editDesc, is_public: editIsPublic });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
editing = false;
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||||
alert(copyForCode(code));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDelete() {
|
||||
if (!confirm('Delete this playlist? This cannot be undone.')) return;
|
||||
try {
|
||||
await deletePlaylist(id);
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
goto('/playlists');
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||||
alert(copyForCode(code));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-6">
|
||||
{#if playlistQuery?.isPending}
|
||||
<p class="text-text-muted">Loading…</p>
|
||||
{:else if playlistQuery?.isError}
|
||||
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
||||
{:else if playlistQuery?.data}
|
||||
{@const pl = playlistQuery.data}
|
||||
{#if !editing}
|
||||
<header class="mb-6 flex items-start gap-4">
|
||||
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
||||
{#if pl.cover_url}
|
||||
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
|
||||
{#if pl.description}
|
||||
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-text-muted">
|
||||
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
||||
{#if pl.is_public}· public{:else}· private{/if}
|
||||
{#if !isOwner}· by {pl.owner_username}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEdit}
|
||||
aria-label="Edit playlist"
|
||||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={16} strokeWidth={1} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={submitDelete}
|
||||
aria-label="Delete playlist"
|
||||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-action-destructive"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1} />
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
{:else}
|
||||
<section class="mb-6 rounded-md border border-border bg-surface p-4">
|
||||
<label class="block text-sm font-medium text-text-primary">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<label class="mt-3 block text-sm font-medium text-text-primary">
|
||||
Description
|
||||
<textarea
|
||||
bind:value={editDesc}
|
||||
rows="2"
|
||||
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-text-primary">
|
||||
<input type="checkbox" bind:checked={editIsPublic} class="rounded border-border" />
|
||||
Make this playlist public
|
||||
</label>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = false)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={submitEdit}
|
||||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="rounded-md border border-border bg-surface">
|
||||
{#if pl.tracks.length === 0}
|
||||
<p class="px-3 py-6 text-sm text-text-muted">
|
||||
{#if isOwner}
|
||||
No tracks yet. Add some via the "Add to playlist…" entry on any track row.
|
||||
{:else}
|
||||
No tracks in this playlist.
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
{#each pl.tracks as row (row.position)}
|
||||
<PlaylistTrackRow
|
||||
{row}
|
||||
isOwner={isOwner ?? false}
|
||||
onRemove={onRemove}
|
||||
onPlay={onPlay}
|
||||
onDragStart={(p) => (dragFromPos = p)}
|
||||
onDrop={(_, p) => onDrop(p)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { PlaylistDetail } from '$lib/api/types';
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: writable({ params: { id: 'p1' } })
|
||||
}));
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => ({
|
||||
createPlaylistQuery: vi.fn(),
|
||||
updatePlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
deletePlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
removePlaylistTrack: vi.fn().mockResolvedValue(undefined),
|
||||
reorderPlaylist: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn(),
|
||||
enqueueTracks: vi.fn(),
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
}));
|
||||
|
||||
// Cascade through PlaylistTrackRow's mocks (LikeButton + TrackMenu transitively).
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/quarantine', () => ({
|
||||
createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }),
|
||||
flagTrack: vi.fn(),
|
||||
unflagTrack: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||||
|
||||
import PlaylistDetailPage from './+page.svelte';
|
||||
import { createPlaylistQuery, deletePlaylist } from '$lib/api/playlists';
|
||||
|
||||
const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>;
|
||||
|
||||
const ownDetail: PlaylistDetail = {
|
||||
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine',
|
||||
description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274,
|
||||
created_at: '', updated_at: '',
|
||||
tracks: [
|
||||
{ position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' },
|
||||
{ position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('Playlist detail page', () => {
|
||||
test('renders header + tracks for owner', () => {
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides edit + delete for non-owner', () => {
|
||||
mockedQuery.mockReturnValue(mockQuery({
|
||||
data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true }
|
||||
}));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Delete button confirms then deletes', async () => {
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||||
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
|
||||
render(PlaylistDetailPage);
|
||||
await fireEvent.click(screen.getByLabelText(/delete playlist/i));
|
||||
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user