4b101481fc
- 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.
125 lines
3.5 KiB
Svelte
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>
|