feat(queue): migrate QueueView drag & drop to @dnd-kit (B3)

This commit is contained in:
senke 2026-02-20 13:00:57 +01:00
parent 222fb95372
commit 42490b539c
2 changed files with 147 additions and 70 deletions

View file

@ -0,0 +1,91 @@
/**
* QueueSortableItem sortable row for queue (dnd-kit).
* v0.102 B3: Uses @dnd-kit/sortable for QueueView drag & drop.
*/
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { GripVertical, Play, X } from 'lucide-react';
import { formatTime } from '@/features/player/services/playerService';
import type { Track } from '@/features/player/types';
interface QueueSortableItemProps {
id: string;
track: Track;
onPlay: (track: Track) => void;
onRemove: () => void;
}
export function QueueSortableItem({
id,
track,
onPlay,
onRemove,
}: QueueSortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-4 p-4 bg-card rounded-lg border border-transparent hover:border-border transition-all group',
isDragging && 'opacity-50 z-10 shadow-lg ring-2 ring-primary/30 cursor-grabbing',
isOver && !isDragging && 'border-t-2 border-t-primary bg-primary/5',
)}
>
<div
{...attributes}
{...listeners}
className="text-muted-foreground cursor-grab active:cursor-grabbing hover:text-foreground p-1"
>
<GripVertical className="w-5 h-5" />
</div>
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative">
<img
src={track.cover || '/placeholder.svg'}
alt=""
className="w-full h-full object-cover"
/>
<div
className="absolute inset-0 bg-background/50 hidden group-hover:flex items-center justify-center cursor-pointer"
onClick={() => onPlay(track)}
>
<Play className="w-4 h-4 text-foreground fill-current" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-foreground truncate">
{track.title}
</div>
<div className="text-xs text-muted-foreground truncate">
{track.artist}
</div>
</div>
<div className="text-muted-foreground font-mono text-xs hidden sm:block">
{formatTime(track.duration)}
</div>
<button
type="button"
className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onRemove}
aria-label="Remove from queue"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View file

@ -1,4 +1,18 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import {
DndContext,
closestCenter,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { usePlayerStore } from '@/features/player/store/playerStore';
import { usePlayer } from '@/features/player/hooks/usePlayer';
import { formatTime } from '@/features/player/services/playerService';
@ -6,6 +20,7 @@ import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { EmptyState } from '../../ui/empty-state';
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
import { QueueSortableItem } from './QueueSortableItem';
import {
createPlaylist,
addTrack,
@ -13,8 +28,6 @@ import {
import {
Play,
Pause,
X,
GripVertical,
Trash2,
Save,
ListMusic,
@ -39,34 +52,31 @@ export const QueueView: React.FC = () => {
const { addToast } = useToast();
const [showSaveModal, setShowSaveModal] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
// Transparent ghost image
const ghost = document.createElement('div');
ghost.style.opacity = '0';
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
if (draggedIndex === null || draggedIndex === index) return;
const from = currentIndex + 1 + draggedIndex;
const to = currentIndex + 1 + index;
reorderQueue(from, to);
setDraggedIndex(index);
};
const sortableIds = upNext.map((_, i) => `queue-${i}`);
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(String(active.id));
const newIndex = sortableIds.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
const from = currentIndex + 1 + oldIndex;
const to = currentIndex + 1 + newIndex;
reorderQueue(from, to);
},
[currentIndex, reorderQueue, sortableIds],
);
const handleSavePlaylist = async (name: string, isPublic: boolean) => {
const tracksToSave = [
@ -181,50 +191,26 @@ export const QueueView: React.FC = () => {
size="md"
/>
) : (
upNext.map((track, i) => (
<div
key={`${track.id}-${i}`}
draggable
onDragStart={(e) => handleDragStart(e, i)}
onDragOver={(e) => handleDragOver(e, i)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-4 p-4 bg-card rounded-lg border border-transparent hover:border-border transition-all group ${draggedIndex === i ? 'opacity-50 border-primary shadow-lg scale-[1.02] cursor-grabbing' : ''} ${dragOverIndex === i && draggedIndex !== null && draggedIndex !== i ? 'border-t-2 border-t-primary bg-primary/5' : ''}`}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
<div className="text-muted-foreground cursor-grab active:cursor-grabbing hover:text-foreground p-1">
<GripVertical className="w-5 h-5" />
</div>
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative">
<img
src={track.cover || '/placeholder.svg'}
alt=""
className="w-full h-full object-cover"
{upNext.map((track, i) => (
<QueueSortableItem
key={sortableIds[i]}
id={sortableIds[i]}
track={track}
onPlay={playTrack}
onRemove={() => removeFromQueue(currentIndex + 1 + i)}
/>
<div
className="absolute inset-0 bg-background/50 hidden group-hover:flex items-center justify-center cursor-pointer"
onClick={() => playTrack(track)}
>
<Play className="w-4 h-4 text-foreground fill-current" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-foreground truncate">
{track.title}
</div>
<div className="text-xs text-muted-foreground truncate">
{track.artist}
</div>
</div>
<div className="text-muted-foreground font-mono text-xs hidden sm:block">
{formatTime(track.duration)}
</div>
<button
className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeFromQueue(currentIndex + 1 + i)}
>
<X className="w-4 h-4" />
</button>
</div>
))
))}
</SortableContext>
</DndContext>
)}
</div>
</div>