diff --git a/web/src/lib/components/AddToPlaylistMenu.svelte b/web/src/lib/components/AddToPlaylistMenu.svelte
new file mode 100644
index 00000000..c979bffb
--- /dev/null
+++ b/web/src/lib/components/AddToPlaylistMenu.svelte
@@ -0,0 +1,124 @@
+
+
+
e.stopPropagation()}
+>
+ {#each ownPlaylists as p (p.id)}
+
add(p.id)}
+ />
+ {/each}
+
+ {#if ownPlaylists.length > 0}
+
+ {/if}
+
+ {#if !creating}
+ { creating = true; }}
+ />
+ {:else}
+
+
{ if (e.key === 'Enter') createAndAdd(); }}
+ />
+
+
+
+
+
+ {/if}
+
+ {#if error}
+ {error}
+ {/if}
+
diff --git a/web/src/lib/components/AddToPlaylistMenu.test.ts b/web/src/lib/components/AddToPlaylistMenu.test.ts
new file mode 100644
index 00000000..84c3d6d0
--- /dev/null
+++ b/web/src/lib/components/AddToPlaylistMenu.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
+import type { TrackRef } from '$lib/api/types';
+
+const playlistsData = vi.hoisted(() => ({
+ owned: [
+ {
+ id: 'p1',
+ user_id: 'u-self',
+ owner_username: 'me',
+ name: 'B-list',
+ description: '',
+ is_public: false,
+ cover_url: '',
+ track_count: 3,
+ duration_sec: 0,
+ created_at: '',
+ updated_at: ''
+ },
+ {
+ id: 'p2',
+ user_id: 'u-self',
+ owner_username: 'me',
+ name: 'A-list',
+ description: '',
+ is_public: false,
+ cover_url: '',
+ track_count: 5,
+ duration_sec: 0,
+ created_at: '',
+ updated_at: ''
+ }
+ ],
+ public: [] as unknown[]
+}));
+
+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/api/playlists', () => ({
+ createPlaylistsQuery: () =>
+ readable({ data: playlistsData, isPending: false, isError: false }),
+ appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
+ createPlaylist: vi.fn().mockResolvedValue({
+ id: 'p3',
+ user_id: 'u-self',
+ owner_username: 'me',
+ name: 'New',
+ description: '',
+ is_public: false,
+ cover_url: '',
+ track_count: 0,
+ duration_sec: 0,
+ created_at: '',
+ updated_at: ''
+ })
+}));
+
+import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
+
+const track: TrackRef = {
+ id: 't1',
+ title: 'Roygbiv',
+ album_id: 'a1',
+ album_title: 'MHTRTC',
+ artist_id: 'ar1',
+ artist_name: 'BoC',
+ duration_sec: 137,
+ stream_url: '/api/tracks/t1/stream'
+};
+
+describe('AddToPlaylistMenu', () => {
+ test('lists own playlists alphabetically followed by "New playlist…"', () => {
+ render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
+ const items = screen.getAllByRole('menuitem');
+ // First two should be the playlists in alpha order, then "New playlist…".
+ expect(items[0].textContent).toContain('A-list');
+ expect(items[1].textContent).toContain('B-list');
+ expect(items[2].textContent).toContain('New playlist');
+ });
+
+ test('clicking a playlist appends and closes', async () => {
+ const onClose = vi.fn();
+ const { appendTracks } = await import('$lib/api/playlists');
+ render(AddToPlaylistMenu, { props: { track, onClose } });
+ await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+ expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']);
+ });
+
+ test('"New playlist…" toggles inline create form', async () => {
+ render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
+ await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ }));
+ expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument();
+ expect(screen.getByText(/Create & add/i)).toBeInTheDocument();
+ });
+});
diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte
index 315e6e1e..791d502a 100644
--- a/web/src/lib/components/TrackMenu.svelte
+++ b/web/src/lib/components/TrackMenu.svelte
@@ -17,6 +17,7 @@
import { useQueryClient } from '@tanstack/svelte-query';
import FlagPopover from './FlagPopover.svelte';
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
+ import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import { user } from '$lib/auth/store.svelte';
@@ -39,6 +40,7 @@
let menuOpen = $state(false);
let flagOpen = $state(false);
let removeOpen = $state(false);
+ let addOpen = $state(false);
const queryClient = useQueryClient();
@@ -67,6 +69,7 @@
menuOpen = false;
flagOpen = false;
removeOpen = false;
+ addOpen = false;
}
function onPlayNext() {
@@ -122,7 +125,7 @@
}
function onKeydown(e: KeyboardEvent) {
- if (!menuOpen && !flagOpen && !removeOpen) return;
+ if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAll();
@@ -130,7 +133,7 @@
}
- (menuOpen = false)} onkeydown={onKeydown} />
+ { menuOpen = false; addOpen = false; }} onkeydown={onKeydown} />
diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts
index d7d80600..16eb7054 100644
--- a/web/src/lib/components/TrackMenu.test.ts
+++ b/web/src/lib/components/TrackMenu.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/svelte';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import type { TrackRef } from '$lib/api/types';
@@ -37,6 +37,13 @@ vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
}));
+vi.mock('$lib/api/playlists', () => ({
+ createPlaylistsQuery: () =>
+ readable({ data: { owned: [], public: [] }, isPending: false, isError: false }),
+ appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
+ createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' })
+}));
+
vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn()
@@ -100,12 +107,15 @@ describe('TrackMenu', () => {
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
});
- test('"Add to playlist…" rendered as aria-disabled with tooltip', async () => {
+ test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => {
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
const item = screen.getByRole('menuitem', { name: /add to playlist/i });
- expect(item.getAttribute('aria-disabled')).toBe('true');
- expect(item.getAttribute('title')).toBe('Coming with playlists');
+ expect(item.getAttribute('aria-disabled')).toBe('false');
+ await fireEvent.click(item);
+ await waitFor(() =>
+ expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument()
+ );
});
test('"Play next" dispatches playNext(track)', async () => {