feat(queue): migrate QueueView drag & drop to @dnd-kit (B3)
This commit is contained in:
parent
222fb95372
commit
42490b539c
2 changed files with 147 additions and 70 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue