Files
minstrel/web/src/lib/components/PlaylistTrackRow.svelte
T
bvandeusen 4067be04a6 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.
2026-05-03 11:16:35 -04:00

98 lines
2.9 KiB
Svelte

<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>