From 0c7875cac597665a3fa1a052c48f02f1ade9f012 Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 10 Aug 2025 21:33:03 +0200 Subject: [PATCH] feat: implement drag-and-drop functionality for sound management in PlaylistEditPage; add sortable components for better user experience --- bun.lock | 11 + package.json | 3 + src/pages/PlaylistEditPage.tsx | 641 +++++++++++++++++++++++++-------- 3 files changed, 495 insertions(+), 160 deletions(-) diff --git a/bun.lock b/bun.lock index b83c29d..5279b69 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "": { "name": "frontend", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@number-flow/react": "^0.5.10", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", @@ -86,6 +89,14 @@ "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], diff --git a/package.json b/package.json index 9c001df..38eba39 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@number-flow/react": "^0.5.10", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", diff --git a/src/pages/PlaylistEditPage.tsx b/src/pages/PlaylistEditPage.tsx index 6724e69..33c53b3 100644 --- a/src/pages/PlaylistEditPage.tsx +++ b/src/pages/PlaylistEditPage.tsx @@ -1,5 +1,22 @@ import { useEffect, useState, useCallback } from 'react' import { useParams, useNavigate } from 'react-router' +import { + DndContext, + DragOverlay, + DragStartEvent, + DragEndEvent, + closestCenter, + PointerSensor, + useSensor, + useSensors, + useDroppable, +} from '@dnd-kit/core' +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { AppLayout } from '@/components/AppLayout' import { playlistsService, type Playlist, type PlaylistSound } from '@/lib/api/services/playlists' import { soundsService, type Sound } from '@/lib/api/services/sounds' @@ -15,6 +32,252 @@ import { AlertCircle, Save, Music, Clock, ChevronUp, ChevronDown, Trash2, Refres import { toast } from 'sonner' import { formatDuration } from '@/utils/format-duration' +// Sortable playlist item component for add sounds mode +interface SortablePlaylistItemProps { + sound: PlaylistSound + index: number +} + +function SortablePlaylistItem({ sound, index }: SortablePlaylistItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `playlist-sound-${sound.id}` }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+
+ {index + 1} +
+ +
+
{sound.name}
+
+ {formatDuration(sound.duration || 0)} +
+
+
+ ) +} + +// Sortable table row component for normal table view +interface SortableTableRowProps { + sound: PlaylistSound + index: number + onMoveSoundUp: (index: number) => void + onMoveSoundDown: (index: number) => void + onRemoveSound: (soundId: number) => void + totalSounds: number +} + +function SortableTableRow({ + sound, + index, + onMoveSoundUp, + onMoveSoundDown, + onRemoveSound, + totalSounds +}: SortableTableRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `table-sound-${sound.id}` }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.8 : 1, + } + + return ( + + +
+
+
+
+
+
+ {index + 1} +
+
+ +
+ +
+
+ {sound.name} +
+
+
+
+ {formatDuration(sound.duration || 0)} + + + {sound.type} + + + {sound.play_count} + +
+ + + +
+
+
+ ) +} + +// Draggable available sound component +interface DraggableAvailableSoundProps { + sound: Sound + onAddSound: (soundId: number) => void +} + +function DraggableAvailableSound({ sound, onAddSound }: DraggableAvailableSoundProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sound.id.toString() }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
onAddSound(sound.id)} + > + +
+
{sound.name}
+
+ {formatDuration(sound.duration || 0)} • {sound.type} +
+
+ +
+ ) +} + +// Drop zone component +interface DropZoneProps { + position: number + isActive?: boolean +} + +function DropZone({ position, isActive }: DropZoneProps) { + const { + setNodeRef, + isOver, + } = useDroppable({ + id: `playlist-position-${position}`, + data: { type: 'position', position } + }) + + return ( +
+ ) +} + +// Empty playlist drop zone +function EmptyPlaylistDropZone() { + const { + setNodeRef, + isOver, + } = useDroppable({ + id: 'playlist-position-0', + data: { type: 'position', position: 0 } + }) + + return ( +
+

Drag sounds here to add to playlist

+
+ ) +} + export function PlaylistEditPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() @@ -31,6 +294,18 @@ export function PlaylistEditPage() { const [availableSounds, setAvailableSounds] = useState([]) const [loadingAvailableSounds, setLoadingAvailableSounds] = useState(false) + // dnd-kit state + const [draggedSound, setDraggedSound] = useState(null) + + // dnd-kit sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + // Form state const [formData, setFormData] = useState({ name: '', @@ -236,41 +511,128 @@ export function PlaylistEditPage() { } const handleAddSoundToPlaylist = async (soundId: number, position?: number) => { + // Find the sound being added for potential rollback + const soundToAdd = availableSounds.find(sound => sound.id === soundId) + try { + // Optimistically remove the sound from available sounds for instant feedback + setAvailableSounds(prev => prev.filter(sound => sound.id !== soundId)) + await playlistsService.addSoundToPlaylist(playlistId, soundId, position) toast.success('Sound added to playlist') - // Refresh sounds and available sounds + // Refresh playlist sounds to show the new addition await fetchSounds() - await fetchAvailableSounds() } catch (err) { + // Rollback: add the sound back to available sounds if the API call failed + if (soundToAdd) { + setAvailableSounds(prev => [...prev, soundToAdd]) + } const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist' toast.error(errorMessage) } } - // Drag and drop handlers - const handleDragStart = (e: React.DragEvent, soundId: number) => { - e.dataTransfer.setData('text/plain', soundId.toString()) - e.dataTransfer.effectAllowed = 'copy' + // dnd-kit drag handlers + const handleDragStart = (event: DragStartEvent) => { + const { active } = event + + // Extract sound ID from different ID formats + const activeId = active.id as string + const getSoundId = (id: string) => { + if (id.startsWith('table-sound-')) { + return parseInt(id.replace('table-sound-', ''), 10) + } else if (id.startsWith('playlist-sound-')) { + return parseInt(id.replace('playlist-sound-', ''), 10) + } else { + return parseInt(id, 10) + } + } + + const soundId = getSoundId(activeId) + const availableSound = availableSounds.find(s => s.id === soundId) + const playlistSound = sounds.find(s => s.id === soundId) + + setDraggedSound(availableSound || playlistSound || null) } - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'copy' + const handleDragOver = () => { + // Handle drag over logic here if needed for visual feedback } - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault() - const soundIdStr = e.dataTransfer.getData('text/plain') - const soundId = parseInt(soundIdStr, 10) + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event - // Get the position from the drop target - const target = e.currentTarget as HTMLElement - const position = parseInt(target.dataset.position || '0', 10) + setDraggedSound(null) - if (!isNaN(soundId) && !isNaN(position)) { - await handleAddSoundToPlaylist(soundId, position) + if (!over) return + + const activeId = active.id as string + const overId = over.id as string + + // Extract sound ID from different ID formats + const getDraggedSoundId = (id: string) => { + if (id.startsWith('table-sound-')) { + return parseInt(id.replace('table-sound-', ''), 10) + } else if (id.startsWith('playlist-sound-')) { + return parseInt(id.replace('playlist-sound-', ''), 10) + } else { + return parseInt(id, 10) + } + } + + const getTargetSoundId = (id: string) => { + if (id.startsWith('table-sound-')) { + return parseInt(id.replace('table-sound-', ''), 10) + } else if (id.startsWith('playlist-sound-')) { + return parseInt(id.replace('playlist-sound-', ''), 10) + } else { + return parseInt(id, 10) + } + } + + const draggedSoundId = getDraggedSoundId(activeId) + + // Check if dragging from available sounds (adding new sound) + const isFromAvailable = availableSounds.some(s => s.id === draggedSoundId) + + // Handle dropping onto playlist positions (inserting new sound in add sounds mode) + if (overId.startsWith('playlist-position-') && isFromAvailable) { + const position = parseInt(overId.replace('playlist-position-', ''), 10) + await handleAddSoundToPlaylist(draggedSoundId, position) + return + } + + // Handle reordering within playlist (both add sounds mode and table mode) + if (overId.startsWith('playlist-sound-') || overId.startsWith('table-sound-')) { + const targetSoundId = getTargetSoundId(overId) + const draggedIndex = sounds.findIndex(s => s.id === draggedSoundId) + const targetIndex = sounds.findIndex(s => s.id === targetSoundId) + + // Only allow reordering if both sounds are in the playlist + if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) { + // Reorder sounds in playlist + const newSounds = [...sounds] + const [draggedSound] = newSounds.splice(draggedIndex, 1) + newSounds.splice(targetIndex, 0, draggedSound) + + // Create sound positions for API + const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) + + try { + await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) + setSounds(newSounds) + toast.success('Playlist reordered') + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to reorder playlist' + toast.error(errorMessage) + } + } + // Handle dropping from available sounds onto existing playlist item (insert before) + else if (isFromAvailable && targetIndex !== -1) { + await handleAddSoundToPlaylist(draggedSoundId, targetIndex) + return + } } } @@ -325,15 +687,22 @@ export function PlaylistEditPage() { } return ( - +
@@ -574,51 +943,26 @@ export function PlaylistEditPage() { Current Playlist ({sounds.length} sounds)
- {sounds.length === 0 ? ( -
-

Drag sounds here to add to playlist

-
- ) : ( -
- {/* Drop zone at the top */} -
- - {sounds.map((sound, index) => ( -
-
-
- {index + 1} -
- -
-
{sound.name}
-
- {formatDuration(sound.duration || 0)} -
-
+ `playlist-sound-${sound.id}`)} + strategy={verticalListSortingStrategy} + > + {sounds.length === 0 ? ( + + ) : ( +
+ {/* Drop zone at the top */} + + + {sounds.map((sound, index) => ( +
+ +
- - {/* Drop zone after each item */} -
-
- ))} -
- )} + ))} +
+ )} +
@@ -638,26 +982,20 @@ export function PlaylistEditPage() {

No available EXT sounds

) : ( -
- {availableSounds.map((sound) => ( -
handleDragStart(e, sound.id)} - onClick={() => handleAddSoundToPlaylist(sound.id)} - > - -
-
{sound.name}
-
- {formatDuration(sound.duration || 0)} • {sound.type} -
-
- -
- ))} -
+ sound.id.toString())} + strategy={verticalListSortingStrategy} + > +
+ {availableSounds.map((sound) => ( + + ))} +
+
)}
@@ -677,84 +1015,67 @@ export function PlaylistEditPage() {
) : (
- - - - - Name - Duration - Type - Plays - Actions - - - - {sounds.map((sound, index) => ( - - - {index + 1} - - -
- -
-
- {sound.name} -
+ `table-sound-${sound.id}`)} + strategy={verticalListSortingStrategy} + > +
+ + + +
+
+
+
+
+ #
- - {formatDuration(sound.duration || 0)} - - - {sound.type} - - - {sound.play_count} - -
- - - -
-
+
+ Name + Duration + Type + Plays + Actions
- ))} - -
+ + + {sounds.map((sound, index) => ( + + ))} + + +
) )} -
+
- + {/* Drag Overlay */} + + {draggedSound && ( +
+ +
+
{draggedSound.name}
+
+ {formatDuration(draggedSound.duration || 0)} +
+
+
+ )} +
+ + ) } \ No newline at end of file