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 (
+
+
+
+
+
+
+
+ {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