diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte
new file mode 100644
index 00000000..a2a0e627
--- /dev/null
+++ b/web/src/routes/playlists/[id]/+page.svelte
@@ -0,0 +1,243 @@
+
+
+
+ {#if playlistQuery?.isPending}
+
Loading…
+ {:else if playlistQuery?.isError}
+
playlistQuery.refetch()} />
+ {:else if playlistQuery?.data}
+ {@const pl = playlistQuery.data}
+ {#if !editing}
+
+ {:else}
+
+ {/if}
+
+
+ {#if pl.tracks.length === 0}
+
+ {#if isOwner}
+ No tracks yet. Add some via the "Add to playlist…" entry on any track row.
+ {:else}
+ No tracks in this playlist.
+ {/if}
+
+ {:else}
+ {#each pl.tracks as row (row.position)}
+ (dragFromPos = p)}
+ onDrop={(_, p) => onDrop(p)}
+ />
+ {/each}
+ {/if}
+
+ {/if}
+
diff --git a/web/src/routes/playlists/[id]/playlist.test.ts b/web/src/routes/playlists/[id]/playlist.test.ts
new file mode 100644
index 00000000..49023af3
--- /dev/null
+++ b/web/src/routes/playlists/[id]/playlist.test.ts
@@ -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;
+ 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;
+
+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();
+ });
+});