Files
minstrel/web/src/lib/components/AddToPlaylistMenu.svelte
T
bvandeusen 4b101481fc fix(web): drop-up Add-to-playlist submenu, hide horizontal scroll row scrollbar
- AddToPlaylistMenu: anchor submenu bottom to parent bottom so it
  grows upward (top-0 → bottom-0) — kebab row is usually near the
  bottom of the track menu and the submenu kept extending off-screen.
- HorizontalScrollRow: scrollbar-width: none + ::-webkit-scrollbar
  display:none. Page arrows already drive the scroll; the scrollbar
  was just consuming vertical real estate.
2026-05-03 14:04:07 -04:00

125 lines
3.5 KiB
Svelte

<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 bottom-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-action-fg 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>