feat(web): PlaylistTrackRow component for M7 #352 slice 1

Variant of TrackRow specialised for playlist detail. Drag handle
visible to owner only; remove button (X) visible to owner only;
greyed-out + strikethrough when track_id is null (upstream track
removed from library). Reuses the existing LikeButton and TrackMenu
when the track is still alive.
This commit is contained in:
2026-05-03 11:16:35 -04:00
parent 0eb346e0c6
commit 4067be04a6
2 changed files with 221 additions and 0 deletions
@@ -0,0 +1,97 @@
<script lang="ts">
import { GripVertical, X } from 'lucide-svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack, TrackRef } from '$lib/api/types';
let {
row,
isOwner,
onRemove,
onPlay,
onDragStart,
onDragOver,
onDrop
}: {
row: PlaylistTrack;
isOwner: boolean;
onRemove: (position: number) => void;
onPlay: (position: number) => void;
onDragStart?: (position: number) => void;
onDragOver?: (e: DragEvent, position: number) => void;
onDrop?: (e: DragEvent, position: number) => void;
} = $props();
const isUnavailable = $derived(row.track_id === null);
// Construct a minimal TrackRef for the kebab menu when the upstream
// track still exists. When unavailable, the menu is hidden.
const liveTrack = $derived<TrackRef | null>(
row.track_id
? ({
id: row.track_id,
title: row.title,
album_id: row.album_id ?? '',
album_title: row.album_title,
artist_id: row.artist_id ?? '',
artist_name: row.artist_name,
duration_sec: row.duration_sec,
stream_url: row.stream_url ?? ''
} as TrackRef)
: null
);
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<div
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}"
draggable={isOwner && !isUnavailable}
ondragstart={() => onDragStart?.(row.position)}
ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }}
ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }}
>
{#if isOwner}
<span
class="flex-shrink-0 cursor-grab text-text-muted active:cursor-grabbing"
aria-label="Drag handle"
>
<GripVertical size={14} strokeWidth={1} />
</span>
{/if}
<button
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
>
<div class="truncate {isUnavailable ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
</div>
</button>
<span class="flex-shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
{#if !isUnavailable && liveTrack}
<LikeButton entityType="track" entityId={liveTrack.id} />
<TrackMenu track={liveTrack} />
{/if}
{#if isOwner}
<button
type="button"
onclick={() => onRemove(row.position)}
aria-label={`Remove ${row.title} from playlist`}
class="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
>
<X size={14} strokeWidth={1} />
</button>
{/if}
</div>
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import type { PlaylistTrack } from '$lib/api/types';
// LikeButton + TrackMenu transitively pull in TanStack Query, the auth
// store, likes/quarantine APIs, the player store, and SvelteKit
// navigation. Mock all of them at module level so the component can be
// rendered in isolation.
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { id: 'u1', username: 'me', is_admin: false } }
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/api/likes', () => ({
createLikedIdsQuery: () =>
readable({
data: { track_ids: [], album_ids: [], artist_ids: [] },
isPending: false,
isError: false
}),
likeEntity: vi.fn().mockResolvedValue(undefined),
unlikeEntity: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('$lib/api/quarantine', () => ({
flagTrack: vi.fn().mockResolvedValue({}),
unflagTrack: vi.fn().mockResolvedValue(undefined),
listMyQuarantine: vi.fn().mockResolvedValue([]),
createMyQuarantineQuery: () =>
readable({ data: [], isPending: false, isError: false })
}));
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
}));
vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
playRadio: vi.fn()
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
import PlaylistTrackRow from './PlaylistTrackRow.svelte';
const live: PlaylistTrack = {
position: 2,
track_id: 't-1',
album_id: 'a-1',
artist_id: 'ar-1',
title: 'Roygbiv',
artist_name: 'Boards of Canada',
album_title: 'MHTRTC',
duration_sec: 137,
stream_url: '/api/tracks/t-1/stream',
added_at: ''
};
const removed: PlaylistTrack = {
...live,
track_id: null,
album_id: null,
artist_id: null,
stream_url: null
};
afterEach(() => vi.clearAllMocks());
describe('PlaylistTrackRow', () => {
test('renders title, artist, mm:ss duration', () => {
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
expect(screen.getByText('2:17')).toBeInTheDocument();
});
test('shows drag handle and remove button for owner', () => {
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.getByLabelText('Drag handle')).toBeInTheDocument();
expect(
screen.getByLabelText(/Remove Roygbiv from playlist/i)
).toBeInTheDocument();
});
test('hides drag handle and remove button for non-owner', () => {
render(PlaylistTrackRow, {
props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument();
expect(
screen.queryByLabelText(/Remove Roygbiv from playlist/i)
).not.toBeInTheDocument();
});
test('greyed-out + strikethrough when track_id is null', () => {
render(PlaylistTrackRow, {
props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
const titleEl = screen.getByText('Roygbiv');
expect(titleEl.className).toContain('line-through');
});
test('clicking the title fires onPlay with the position', async () => {
const onPlay = vi.fn();
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay }
});
await fireEvent.click(screen.getByText('Roygbiv'));
expect(onPlay).toHaveBeenCalledWith(2);
});
});