refactor(web): converge PlaylistTrackRow drag-reorder onto @neodrag/svelte (B4)

This commit is contained in:
2026-05-08 11:22:23 -04:00
parent 3e34f1f7b3
commit 5a174c9ef0
2 changed files with 40 additions and 26 deletions
+29 -10
View File
@@ -1,25 +1,23 @@
<script lang="ts">
import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack, TrackRef } from '$lib/api/types';
import { offsetToDelta } from './queue-row-math';
let {
row,
isOwner,
onRemove,
onPlay,
onDragStart,
onDragOver,
onDrop
onMove
}: {
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;
onMove?: (fromPos: number, toPos: number) => void;
} = $props();
const isUnavailable = $derived(row.track_id === null);
@@ -41,21 +39,42 @@
: null
);
let measuredRowHeight = 56; // sensible default; replaced on dragStart
function handleDragStart(data: DragEventData) {
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
onMove?.(row.position, row.position + delta);
}
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Drag is gated on owner+available. When neither, the action runs but
// the dragEnd handler skips by returning before onMove fires; visually
// the user never sees the row move.
const canDrag = $derived(isOwner && !isUnavailable);
</script>
<div
role="listitem"
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); }}
use:draggable={{
axis: 'y',
bounds: 'parent',
disabled: !canDrag,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd
}}
>
{#if isOwner}
<span
+11 -16
View File
@@ -31,20 +31,16 @@
const playlistQuery = $derived($playlistStore);
const isOwner = $derived(user.value?.id === playlistQuery?.data?.user_id);
let dragFromPos = $state<number | null>(null);
async function onDrop(toPos: number) {
if (dragFromPos === null || !playlistQuery?.data) {
return;
}
if (dragFromPos === toPos) {
dragFromPos = null;
return;
}
const cur = playlistQuery.data.tracks.map((t) => t.position);
const moved = cur.splice(dragFromPos, 1)[0];
cur.splice(toPos, 0, moved);
dragFromPos = null;
async function onMove(fromPos: number, toPos: number) {
if (!playlistQuery?.data) return;
const tracks = playlistQuery.data.tracks;
// Clamp toPos so a drag past the list edges resolves cleanly to the
// first/last slot rather than producing an out-of-range index.
const clamped = Math.max(0, Math.min(tracks.length - 1, toPos));
if (fromPos === clamped) return;
const cur = tracks.map((t) => t.position);
const moved = cur.splice(fromPos, 1)[0];
cur.splice(clamped, 0, moved);
try {
await reorderPlaylist(id, cur);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
@@ -268,8 +264,7 @@
isOwner={isOwner ?? false}
onRemove={onRemove}
onPlay={onPlay}
onDragStart={(p) => (dragFromPos = p)}
onDrop={(_, p) => onDrop(p)}
onMove={onMove}
/>
{/each}
{/if}