refactor(web): converge PlaylistTrackRow drag-reorder onto @neodrag/svelte (B4)
This commit is contained in:
@@ -1,25 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GripVertical, X } from 'lucide-svelte';
|
import { GripVertical, X } from 'lucide-svelte';
|
||||||
|
import { draggable, type DragEventData } from '@neodrag/svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import type { PlaylistTrack, TrackRef } from '$lib/api/types';
|
import type { PlaylistTrack, TrackRef } from '$lib/api/types';
|
||||||
|
import { offsetToDelta } from './queue-row-math';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
row,
|
row,
|
||||||
isOwner,
|
isOwner,
|
||||||
onRemove,
|
onRemove,
|
||||||
onPlay,
|
onPlay,
|
||||||
onDragStart,
|
onMove
|
||||||
onDragOver,
|
|
||||||
onDrop
|
|
||||||
}: {
|
}: {
|
||||||
row: PlaylistTrack;
|
row: PlaylistTrack;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
onRemove: (position: number) => void;
|
onRemove: (position: number) => void;
|
||||||
onPlay: (position: number) => void;
|
onPlay: (position: number) => void;
|
||||||
onDragStart?: (position: number) => void;
|
onMove?: (fromPos: number, toPos: number) => void;
|
||||||
onDragOver?: (e: DragEvent, position: number) => void;
|
|
||||||
onDrop?: (e: DragEvent, position: number) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const isUnavailable = $derived(row.track_id === null);
|
const isUnavailable = $derived(row.track_id === null);
|
||||||
@@ -41,21 +39,42 @@
|
|||||||
: null
|
: 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 {
|
function format(sec: number): string {
|
||||||
const m = Math.floor(sec / 60);
|
const m = Math.floor(sec / 60);
|
||||||
const s = sec % 60;
|
const s = sec % 60;
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="listitem"
|
role="listitem"
|
||||||
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
|
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'}"
|
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}"
|
||||||
draggable={isOwner && !isUnavailable}
|
use:draggable={{
|
||||||
ondragstart={() => onDragStart?.(row.position)}
|
axis: 'y',
|
||||||
ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }}
|
bounds: 'parent',
|
||||||
ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }}
|
disabled: !canDrag,
|
||||||
|
onDragStart: handleDragStart,
|
||||||
|
onDragEnd: handleDragEnd
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -31,20 +31,16 @@
|
|||||||
const playlistQuery = $derived($playlistStore);
|
const playlistQuery = $derived($playlistStore);
|
||||||
const isOwner = $derived(user.value?.id === playlistQuery?.data?.user_id);
|
const isOwner = $derived(user.value?.id === playlistQuery?.data?.user_id);
|
||||||
|
|
||||||
let dragFromPos = $state<number | null>(null);
|
async function onMove(fromPos: number, toPos: number) {
|
||||||
|
if (!playlistQuery?.data) return;
|
||||||
async function onDrop(toPos: number) {
|
const tracks = playlistQuery.data.tracks;
|
||||||
if (dragFromPos === null || !playlistQuery?.data) {
|
// Clamp toPos so a drag past the list edges resolves cleanly to the
|
||||||
return;
|
// first/last slot rather than producing an out-of-range index.
|
||||||
}
|
const clamped = Math.max(0, Math.min(tracks.length - 1, toPos));
|
||||||
if (dragFromPos === toPos) {
|
if (fromPos === clamped) return;
|
||||||
dragFromPos = null;
|
const cur = tracks.map((t) => t.position);
|
||||||
return;
|
const moved = cur.splice(fromPos, 1)[0];
|
||||||
}
|
cur.splice(clamped, 0, moved);
|
||||||
const cur = playlistQuery.data.tracks.map((t) => t.position);
|
|
||||||
const moved = cur.splice(dragFromPos, 1)[0];
|
|
||||||
cur.splice(toPos, 0, moved);
|
|
||||||
dragFromPos = null;
|
|
||||||
try {
|
try {
|
||||||
await reorderPlaylist(id, cur);
|
await reorderPlaylist(id, cur);
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||||
@@ -268,8 +264,7 @@
|
|||||||
isOwner={isOwner ?? false}
|
isOwner={isOwner ?? false}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onDragStart={(p) => (dragFromPos = p)}
|
onMove={onMove}
|
||||||
onDrop={(_, p) => onDrop(p)}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user