feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s
test-web / test (push) Successful in 31s
Add a global selection store backing a checkbox per track row and a floating SelectionBar above PlayerBar with Play next / Add to queue / Add to playlist / Like all / Clear. - selection/store.svelte.ts: id Set + TrackRef Map + anchor index; toggleOne, selectRange (shift+click), clearSelection. Singleton — layout effect clears on pathname change. - TrackRow: checkbox slot replaces the track number on hover; row click toggles selection once any row is picked; shift+click extends the range. Esc clears (takes priority over closing the queue drawer). - SelectionBar: floating pill above PlayerBar, mounted inside the QueryClientProvider so the Like-all action can resolve. - player.playNextMany: bulk variant of playNext for "Play next" on a multi-track selection. Covered by selection-store unit tests and three new TrackRow tests (checkbox toggle, sticky select-mode row click, shift+click range).
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { selection, clearSelection } from '$lib/selection/store.svelte';
|
||||
import { playNextMany, enqueueTracks } from '$lib/player/store.svelte';
|
||||
import { likeEntity, createLikedIdsQuery } from '$lib/api/likes';
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const likedQ = $derived(createLikedIdsQuery());
|
||||
const likedIds = $derived($likedQ?.data?.track_ids ?? []);
|
||||
|
||||
let addOpen = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
function onPlayNext() {
|
||||
playNextMany(selection.tracks);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function onAddQueue() {
|
||||
enqueueTracks(selection.tracks);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function onLikeAll() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const likedSet = new Set(likedIds);
|
||||
const toLike = selection.tracks.filter((t) => !likedSet.has(t.id));
|
||||
for (const t of toLike) {
|
||||
await likeEntity(queryClient, 'track', t.id);
|
||||
}
|
||||
clearSelection();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
addOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if selection.active}
|
||||
<!-- Sits above PlayerBar (z-30 < 40). Anchors to the viewport bottom
|
||||
with bottom-24 so PlayerBar's ~80px shell stays visible. The bar
|
||||
is dismissed by the × button or by Escape (handled in layout). -->
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Selection actions"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-24 z-30 flex justify-center px-4"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-2 rounded-full border border-border
|
||||
bg-surface px-3 py-2 shadow-lg"
|
||||
>
|
||||
<span class="px-2 text-sm text-text-primary">
|
||||
{selection.count} selected
|
||||
</span>
|
||||
<span class="h-5 w-px bg-border"></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPlayNext}
|
||||
aria-label="Play selected next"
|
||||
title="Play next"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ListPlus size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Play next</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddQueue}
|
||||
aria-label="Add selected to queue"
|
||||
title="Add to queue"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Plus size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Add to queue</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (addOpen = !addOpen)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addOpen}
|
||||
aria-label="Add selected to playlist"
|
||||
title="Add to playlist"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ListMusic size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Add to playlist</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLikeAll}
|
||||
disabled={busy}
|
||||
aria-label="Like all selected"
|
||||
title="Like all"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
>
|
||||
<Heart size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Like all</span>
|
||||
</button>
|
||||
|
||||
<span class="h-5 w-px bg-border"></span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
class="rounded p-1.5 text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<X size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addOpen}
|
||||
<div class="pointer-events-auto absolute bottom-full mb-2">
|
||||
<AddToPlaylistMenu tracks={selection.tracks} {onClose} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user