feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352)

The "Add to playlist…" entry that #372 reserved as a disabled slot
is now active. Submenu lists the operator's own playlists
alphabetically; "New playlist…" toggles an inline create form that
makes the playlist + appends the track in two API calls.

TrackMenu's existing test asserts the entry is enabled and opens the
submenu instead of the previous "is disabled with tooltip" check.
This commit is contained in:
2026-05-03 11:20:14 -04:00
parent 4067be04a6
commit 71dbaaede5
4 changed files with 255 additions and 8 deletions
@@ -0,0 +1,124 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
let {
track,
onClose
}: {
track: TrackRef;
onClose: () => void;
} = $props();
const queryClient = useQueryClient();
const playlistsStore = $derived(createPlaylistsQuery());
const playlistsQuery = $derived($playlistsStore);
const ownPlaylists = $derived(
(playlistsQuery?.data?.owned ?? []).slice().sort((a, b) => a.name.localeCompare(b.name))
);
let creating = $state(false);
let newName = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
async function add(playlistID: string) {
busy = true;
error = null;
try {
await appendTracks(playlistID, [track.id]);
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlistID) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
onClose();
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
error = copyForCode(code);
} finally {
busy = false;
}
}
async function createAndAdd() {
if (!newName.trim()) {
error = 'Name is required';
return;
}
busy = true;
error = null;
try {
const created = await createPlaylist({ name: newName.trim() });
await appendTracks(created.id, [track.id]);
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
onClose();
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
error = copyForCode(code);
} finally {
busy = false;
}
}
</script>
<div
role="menu"
aria-label="Add to playlist"
class="absolute right-full top-0 z-30 mr-1 w-56 rounded-md border border-border bg-surface p-1 shadow-lg"
onclick={(e) => e.stopPropagation()}
>
{#each ownPlaylists as p (p.id)}
<TrackMenuItem
icon={Plus}
label={p.name}
onclick={() => add(p.id)}
/>
{/each}
{#if ownPlaylists.length > 0}
<TrackMenuDivider />
{/if}
{#if !creating}
<TrackMenuItem
icon={Plus}
label="New playlist…"
onclick={() => { creating = true; }}
/>
{:else}
<div class="p-2">
<input
type="text"
placeholder="Playlist name"
bind:value={newName}
class="w-full rounded border border-border bg-background px-2 py-1 text-sm text-text-primary"
onkeydown={(e) => { if (e.key === 'Enter') createAndAdd(); }}
/>
<div class="mt-2 flex justify-end gap-1">
<button
type="button"
onclick={() => { creating = false; newName = ''; }}
class="rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-hover"
>
Cancel
</button>
<button
type="button"
onclick={createAndAdd}
disabled={busy || !newName.trim()}
class="rounded bg-action-secondary px-2 py-1 text-xs text-text-primary disabled:opacity-50"
>
Create & add
</button>
</div>
</div>
{/if}
{#if error}
<p class="px-2 py-1 text-xs text-action-destructive">{error}</p>
{/if}
</div>