diff --git a/src/pages/PlaylistEditPage.tsx b/src/pages/PlaylistEditPage.tsx
index e44b87c..af733e8 100644
--- a/src/pages/PlaylistEditPage.tsx
+++ b/src/pages/PlaylistEditPage.tsx
@@ -2,7 +2,9 @@ import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
DndContext,
- DragEndEvent,
+ type DragEndEvent,
+ type DragStartEvent,
+ type DragOverEvent,
closestCenter,
PointerSensor,
useSensor,
@@ -13,9 +15,11 @@ import {
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
+import { useDroppable } from '@dnd-kit/core'
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'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
@@ -24,7 +28,7 @@ import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
-import { AlertCircle, Save, Music, Clock, ChevronUp, ChevronDown, Trash2, RefreshCw, Edit, X, ArrowLeft } from 'lucide-react'
+import { AlertCircle, Save, Music, Clock, ChevronUp, ChevronDown, Trash2, RefreshCw, Edit, X, Plus, Minus } from 'lucide-react'
import { toast } from 'sonner'
import { formatDuration } from '@/utils/format-duration'
@@ -137,6 +141,173 @@ function SortableTableRow({
)
}
+// Simplified sortable row component for add mode
+interface SimpleSortableRowProps {
+ sound: PlaylistSound
+ index: number
+ onRemoveSound: (soundId: number) => void
+}
+
+function SimpleSortableRow({ sound, index, onRemoveSound }: SimpleSortableRowProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: `playlist-sound-${sound.id}` })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.8 : 1,
+ }
+
+ return (
+
+
+
+
+
+ {index + 1}
+
+
+
+
+
+
+
+
onRemoveSound(sound.id)}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
+ title="Remove from playlist"
+ >
+
+
+
+ )
+}
+
+// Available sound component for dragging
+interface AvailableSoundProps {
+ sound: Sound
+}
+
+function AvailableSound({ sound }: AvailableSoundProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: `available-sound-${sound.id}` })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.8 : 1,
+ }
+
+ return (
+
+ )
+}
+
+// Drop zone component for inserting sounds at specific positions
+interface DropZoneProps {
+ index: number
+ isActive: boolean
+}
+
+function DropZone({ index, isActive }: DropZoneProps) {
+ const { isOver, setNodeRef } = useDroppable({
+ id: `drop-zone-${index}`,
+ data: { type: 'dropzone', index }
+ })
+
+ return (
+
+ )
+}
+
+// Inline preview component that shows where the sound will be dropped
+interface InlinePreviewProps {
+ sound: Sound | PlaylistSound
+ position: number
+}
+
+function InlinePreview({ sound, position }: InlinePreviewProps) {
+ return (
+
+
+
+
+ {position + 1}
+
+
+
+
+
+
+ {sound.name}
+
+
+ Will be added here
+
+
+
+ )
+}
+
export function PlaylistEditPage() {
const { id } = useParams<{ id: string }>()
@@ -151,6 +322,14 @@ export function PlaylistEditPage() {
const [saving, setSaving] = useState(false)
const [isEditMode, setIsEditMode] = useState(false)
+ // Add mode state
+ const [isAddMode, setIsAddMode] = useState(false)
+ const [availableSounds, setAvailableSounds] = useState([])
+ const [availableSoundsLoading, setAvailableSoundsLoading] = useState(false)
+ const [draggedItem, setDraggedItem] = useState(null)
+ const [draggedSound, setDraggedSound] = useState(null)
+ const [dropPosition, setDropPosition] = useState(null)
+
// dnd-kit sensors
const sensors = useSensors(
@@ -204,6 +383,30 @@ export function PlaylistEditPage() {
}
}, [playlistId])
+ const fetchAvailableSounds = useCallback(async () => {
+ try {
+ setAvailableSoundsLoading(true)
+ const soundsData = await soundsService.getSoundsByType('EXT')
+ // Filter out sounds that are already in the playlist
+ const playlistSoundIds = new Set(sounds.map(s => s.id))
+ const filteredSounds = soundsData.filter(sound => !playlistSoundIds.has(sound.id))
+ setAvailableSounds(filteredSounds)
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch available sounds'
+ toast.error(errorMessage)
+ } finally {
+ setAvailableSoundsLoading(false)
+ }
+ }, [sounds])
+
+ const toggleAddMode = async () => {
+ if (!isAddMode) {
+ // Entering add mode - fetch available sounds
+ await fetchAvailableSounds()
+ }
+ setIsAddMode(!isAddMode)
+ }
+
useEffect(() => {
if (!isNaN(playlistId)) {
fetchPlaylist()
@@ -324,10 +527,28 @@ export function PlaylistEditPage() {
const handleRemoveSound = async (soundId: number) => {
try {
+ // Find the sound being removed to check if it's EXT type
+ const removedSound = sounds.find(s => s.id === soundId)
+
await playlistsService.removeSoundFromPlaylist(playlistId, soundId)
setSounds(prev => prev.filter(sound => sound.id !== soundId))
toast.success('Sound removed from playlist')
+ // If it's an EXT sound and we're in add mode, add it back to available sounds
+ if (isAddMode && removedSound && removedSound.type === 'EXT') {
+ // Get the full sound data from the API to add back to available sounds
+ try {
+ const allExtSounds = await soundsService.getSoundsByType('EXT')
+ const soundToAddBack = allExtSounds.find(s => s.id === soundId)
+ if (soundToAddBack) {
+ setAvailableSounds(prev => [...prev, soundToAddBack].sort((a, b) => a.name.localeCompare(b.name)))
+ }
+ } catch (err) {
+ // If we can't fetch the sound data, just refresh the available sounds
+ await fetchAvailableSounds()
+ }
+ }
+
// Refresh playlist data to update counts
await fetchPlaylist()
} catch (err) {
@@ -337,19 +558,78 @@ export function PlaylistEditPage() {
}
- // dnd-kit drag handler
+ const handleDragStart = (event: DragStartEvent) => {
+ const draggedId = event.active.id.toString()
+ setDraggedItem(draggedId)
+ setDropPosition(null) // Clear any previous drop position
+
+ // Find the sound being dragged
+ if (draggedId.startsWith('available-sound-')) {
+ const soundId = parseInt(draggedId.replace('available-sound-', ''), 10)
+ const sound = availableSounds.find(s => s.id === soundId)
+ setDraggedSound(sound || null)
+ } else if (draggedId.startsWith('playlist-sound-') || draggedId.startsWith('table-sound-')) {
+ const soundId = parseInt(
+ draggedId.replace('playlist-sound-', '').replace('table-sound-', ''), 10
+ )
+ const sound = sounds.find(s => s.id === soundId)
+ setDraggedSound(sound || null)
+ }
+ }
+
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
+ setDraggedItem(null)
+ setDraggedSound(null)
+ setDropPosition(null)
if (!over) return
const activeId = active.id as string
const overId = over.id as string
- // Only handle table-sound reordering
- if (overId.startsWith('table-sound-')) {
- const draggedSoundId = parseInt(activeId.replace('table-sound-', ''), 10)
- const targetSoundId = parseInt(overId.replace('table-sound-', ''), 10)
+ // Handle adding sound from available list to playlist
+ if (activeId.startsWith('available-sound-') && (overId.startsWith('playlist-sound-') || overId.startsWith('drop-zone-'))) {
+ const soundId = parseInt(activeId.replace('available-sound-', ''), 10)
+
+ let position = sounds.length // Default to end
+
+ if (overId.startsWith('playlist-sound-')) {
+ const targetSoundId = parseInt(overId.replace('playlist-sound-', ''), 10)
+ const targetIndex = sounds.findIndex(s => s.id === targetSoundId)
+ position = targetIndex
+ } else if (overId.startsWith('drop-zone-')) {
+ position = parseInt(overId.replace('drop-zone-', ''), 10)
+ }
+
+ try {
+ await playlistsService.addSoundToPlaylist(playlistId, soundId, position)
+ toast.success('Sound added to playlist')
+
+ // Refresh playlist sounds first
+ await fetchSounds()
+
+ // Immediately remove the added sound from available sounds
+ if (isAddMode) {
+ setAvailableSounds(prev => prev.filter(s => s.id !== soundId))
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist'
+ toast.error(errorMessage)
+ }
+ return
+ }
+
+ // Handle reordering sounds within playlist (both table and simple view)
+ if ((activeId.startsWith('table-sound-') && overId.startsWith('table-sound-')) ||
+ (activeId.startsWith('playlist-sound-') && overId.startsWith('playlist-sound-'))) {
+
+ const draggedSoundId = parseInt(
+ activeId.replace('table-sound-', '').replace('playlist-sound-', ''), 10
+ )
+ const targetSoundId = parseInt(
+ overId.replace('table-sound-', '').replace('playlist-sound-', ''), 10
+ )
const draggedIndex = sounds.findIndex(s => s.id === draggedSoundId)
const targetIndex = sounds.findIndex(s => s.id === targetSoundId)
@@ -375,6 +655,29 @@ export function PlaylistEditPage() {
}
}
+ const handleDragOver = (event: DragOverEvent) => {
+ const { active, over } = event
+
+ // Only show preview when dragging available sounds
+ if (!active.id.toString().startsWith('available-sound-') || !over) {
+ setDropPosition(null)
+ return
+ }
+
+ const overId = over.id.toString()
+
+ if (overId.startsWith('playlist-sound-')) {
+ const targetSoundId = parseInt(overId.replace('playlist-sound-', ''), 10)
+ const targetIndex = sounds.findIndex(s => s.id === targetSoundId)
+ setDropPosition(targetIndex)
+ } else if (overId.startsWith('drop-zone-')) {
+ const position = parseInt(overId.replace('drop-zone-', ''), 10)
+ setDropPosition(position)
+ } else {
+ setDropPosition(null)
+ }
+ }
+
if (loading) {
return (
+
+ {isAddMode ? : }
+
No sounds in this playlist
+ ) : isAddMode ? (
+ // Add Mode: Split layout with simplified playlist and available sounds
+
+ {/* Current Playlist Sounds - Simplified */}
+
+
Current Playlist
+
`playlist-sound-${sound.id}`)}
+ strategy={verticalListSortingStrategy}
+ >
+
+
+ {sounds.map((sound, index) => {
+ const adjustedIndex = dropPosition !== null && dropPosition <= index ? index + 1 : index
+ return (
+
+ {/* Show inline preview if this is the drop position */}
+ {dropPosition === index && draggedSound && draggedItem?.startsWith('available-sound-') && (
+
+ )}
+
+
+
+ )
+ })}
+ {/* Show inline preview at the end if that's the drop position */}
+ {dropPosition === sounds.length && draggedSound && draggedItem?.startsWith('available-sound-') && (
+
+ )}
+
+
+
+
+ {/* Available Sounds */}
+
+
+ Available EXT Sounds ({availableSounds.length})
+
+ {availableSoundsLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : availableSounds.length === 0 ? (
+
+
+
No EXT sounds available
+
All EXT sounds are already in this playlist
+
+ ) : (
+
`available-sound-${sound.id}`)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {availableSounds.map((sound) => (
+
+ ))}
+
+
+ )}
+
+
) : (
+ // Normal Mode: Full table view
`table-sound-${sound.id}`)}