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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user