Files
minstrel/web/src/lib/components/TrackMenu.svelte
T
bvandeusen f7278f2417
test-web / test (push) Successful in 32s
feat(web): drop "Remove from library" from the track kebab
No single-click destructive action belongs in the kebab. Removing the item
orphaned its whole path (RemoveTrackPopover was its only caller, and the
admin/tracks API client was the popover's only caller), so per the repo's
no-dead-code convention the chain is fully removed: the menu item + its
admin/isAdmin plumbing in TrackMenu, RemoveTrackPopover(.svelte/.test),
src/lib/api/admin/tracks(.ts/.test), and the now-needless transitive mocks
in the CompactTrackCard / PlaylistTrackRow / playlist specs.

The kebab is now an 8-item, admin-agnostic menu. The DELETE /api/admin/tracks
server endpoint is untouched — a future safer admin surface can rebind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:17:35 -04:00

201 lines
5.7 KiB
Svelte

<script lang="ts">
import {
MoreVertical,
ListVideo,
ListMusic,
Heart,
HeartOff,
ListPlus,
Radio,
Disc3,
User,
EyeOff,
Eye
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import FlagPopover from './FlagPopover.svelte';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
import { qk } from '$lib/api/queries';
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
let {
track,
direction = 'down',
hideQueueActions = false
}: {
track: TrackRef;
/** Whether the dropdown opens above the trigger ('up') or below ('down').
Pass 'up' when used near the bottom of the viewport (e.g. PlayerBar). */
direction?: 'up' | 'down';
/** Hide "Play next" / "Add to queue". Set true on the PlayerBar where
the menu's track IS the currently-playing one — those actions have
no meaningful semantics for the track you're already listening to. */
hideQueueActions?: boolean;
} = $props();
let menuOpen = $state(false);
let flagOpen = $state(false);
let addOpen = $state(false);
const queryClient = useQueryClient();
const likedQ = $derived(createLikedIdsQuery());
const liked = $derived.by(() => {
const data = $likedQ?.data;
if (!data) return false;
return data.track_ids.includes(track.id);
});
const hiddenQ = $derived(createMyQuarantineQuery());
const hidden = $derived.by(() => {
const rows = $hiddenQ?.data;
if (!rows) return false;
return rows.some((r) => r.track_id === track.id);
});
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
menuOpen = !menuOpen;
}
function closeAll() {
menuOpen = false;
flagOpen = false;
addOpen = false;
}
function onPlayNext() {
playNext(track);
closeAll();
}
function onAddToQueue() {
enqueueTrack(track);
closeAll();
}
function onStartRadio() {
playRadio(track.id);
closeAll();
}
async function onToggleLike() {
closeAll();
if (liked) {
await unlikeEntity(queryClient, 'track', track.id);
} else {
await likeEntity(queryClient, 'track', track.id);
}
}
async function onToggleHide() {
if (hidden) {
// Unhide: simple — unflag, then refresh the quarantine list.
await unflagTrack(track.id);
await queryClient.invalidateQueries({ queryKey: qk.myQuarantine() });
closeAll();
} else {
// Hide: needs a reason. Reuse FlagPopover.
menuOpen = false;
flagOpen = true;
}
}
function onGoToAlbum() {
closeAll();
goto(`/albums/${track.album_id}`);
}
function onGoToArtist() {
closeAll();
goto(`/artists/${track.artist_id}`);
}
function onKeydown(e: KeyboardEvent) {
if (!menuOpen && !flagOpen && !addOpen) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAll();
}
}
</script>
<svelte:window onclick={() => { menuOpen = false; addOpen = false; }} onkeydown={onKeydown} />
<div class="relative inline-block">
<button
type="button"
aria-label={`Track actions for ${track.title}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={toggleMenu}
class="rounded p-1 text-text-muted hover:text-text-primary"
>
<MoreVertical size={16} strokeWidth={1} />
</button>
{#if menuOpen}
<div
role="menu"
tabindex="-1"
class="absolute right-0 z-20 w-56 rounded-md border border-border bg-surface p-1 shadow-lg
{direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'}"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
>
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
the kebab reads the same on both clients: queue group → like /
add-to-playlist / start-radio group → navigation group → hide.
"Start radio" is shown even under hideQueueActions (reseeding a
station from the current track is meaningful where play-next /
add-to-queue are not). No single-click destructive action lives
here — track removal is intentionally kept out of the kebab. -->
{#if !hideQueueActions}
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
<TrackMenuDivider />
{/if}
<TrackMenuItem
icon={liked ? HeartOff : Heart}
label={liked ? 'Unlike' : 'Like'}
onclick={onToggleLike}
/>
<TrackMenuItem
icon={ListPlus}
label="Add to playlist…"
onclick={() => { addOpen = true; menuOpen = false; }}
/>
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
<TrackMenuDivider />
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
<TrackMenuDivider />
<TrackMenuItem
icon={hidden ? Eye : EyeOff}
label={hidden ? 'Unhide' : 'Hide'}
onclick={onToggleHide}
/>
</div>
{/if}
{#if flagOpen}
<FlagPopover {track} onClose={closeAll} />
{/if}
{#if addOpen}
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
{/if}
</div>