feat: implement drag-and-drop functionality for sound management in PlaylistEditPage; add sortable components for better user experience
This commit is contained in:
11
bun.lock
11
bun.lock
@@ -4,6 +4,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"dependencies": {
|
"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",
|
"@number-flow/react": "^0.5.10",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@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=="],
|
"@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/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=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="],
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@number-flow/react": "^0.5.10",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router'
|
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 { AppLayout } from '@/components/AppLayout'
|
||||||
import { playlistsService, type Playlist, type PlaylistSound } from '@/lib/api/services/playlists'
|
import { playlistsService, type Playlist, type PlaylistSound } from '@/lib/api/services/playlists'
|
||||||
import { soundsService, type Sound } from '@/lib/api/services/sounds'
|
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 { toast } from 'sonner'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="flex items-center gap-3 p-3 border-b hover:bg-muted/50 cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground font-mono w-8">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{sound.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(sound.duration || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<TableRow
|
||||||
|
key={sound.id}
|
||||||
|
className="hover:bg-muted/50"
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground font-mono text-sm">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 cursor-grab active:cursor-grabbing"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full"></div>
|
||||||
|
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full mt-0.5"></div>
|
||||||
|
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full mt-0.5"></div>
|
||||||
|
</div>
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{sound.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDuration(sound.duration || 0)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{sound.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{sound.play_count}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onMoveSoundUp(index)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onMoveSoundDown(index)}
|
||||||
|
disabled={index === totalSounds - 1}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onRemoveSound(sound.id)}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
title="Remove from playlist"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="flex items-center gap-3 p-3 border-b hover:bg-muted/50 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
onClick={() => onAddSound(sound.id)}
|
||||||
|
>
|
||||||
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{sound.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(sound.duration || 0)} • {sound.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Plus className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`h-2 border-2 border-dashed transition-colors ${
|
||||||
|
isOver || isActive
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-transparent hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty playlist drop zone
|
||||||
|
function EmptyPlaylistDropZone() {
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
isOver,
|
||||||
|
} = useDroppable({
|
||||||
|
id: 'playlist-position-0',
|
||||||
|
data: { type: 'position', position: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex items-center justify-center h-full text-muted-foreground border-2 border-dashed transition-colors ${
|
||||||
|
isOver
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-muted hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p>Drag sounds here to add to playlist</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PlaylistEditPage() {
|
export function PlaylistEditPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -31,6 +294,18 @@ export function PlaylistEditPage() {
|
|||||||
const [availableSounds, setAvailableSounds] = useState<Sound[]>([])
|
const [availableSounds, setAvailableSounds] = useState<Sound[]>([])
|
||||||
const [loadingAvailableSounds, setLoadingAvailableSounds] = useState(false)
|
const [loadingAvailableSounds, setLoadingAvailableSounds] = useState(false)
|
||||||
|
|
||||||
|
// dnd-kit state
|
||||||
|
const [draggedSound, setDraggedSound] = useState<Sound | PlaylistSound | null>(null)
|
||||||
|
|
||||||
|
// dnd-kit sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -236,41 +511,128 @@ export function PlaylistEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddSoundToPlaylist = async (soundId: number, position?: number) => {
|
const handleAddSoundToPlaylist = async (soundId: number, position?: number) => {
|
||||||
|
// Find the sound being added for potential rollback
|
||||||
|
const soundToAdd = availableSounds.find(sound => sound.id === soundId)
|
||||||
|
|
||||||
try {
|
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)
|
await playlistsService.addSoundToPlaylist(playlistId, soundId, position)
|
||||||
toast.success('Sound added to playlist')
|
toast.success('Sound added to playlist')
|
||||||
|
|
||||||
// Refresh sounds and available sounds
|
// Refresh playlist sounds to show the new addition
|
||||||
await fetchSounds()
|
await fetchSounds()
|
||||||
await fetchAvailableSounds()
|
|
||||||
} catch (err) {
|
} 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'
|
const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist'
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag and drop handlers
|
// dnd-kit drag handlers
|
||||||
const handleDragStart = (e: React.DragEvent, soundId: number) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
e.dataTransfer.setData('text/plain', soundId.toString())
|
const { active } = event
|
||||||
e.dataTransfer.effectAllowed = 'copy'
|
|
||||||
|
// 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) => {
|
const handleDragOver = () => {
|
||||||
e.preventDefault()
|
// Handle drag over logic here if needed for visual feedback
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
e.preventDefault()
|
const { active, over } = event
|
||||||
const soundIdStr = e.dataTransfer.getData('text/plain')
|
|
||||||
const soundId = parseInt(soundIdStr, 10)
|
|
||||||
|
|
||||||
// Get the position from the drop target
|
setDraggedSound(null)
|
||||||
const target = e.currentTarget as HTMLElement
|
|
||||||
const position = parseInt(target.dataset.position || '0', 10)
|
|
||||||
|
|
||||||
if (!isNaN(soundId) && !isNaN(position)) {
|
if (!over) return
|
||||||
await handleAddSoundToPlaylist(soundId, position)
|
|
||||||
|
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 (
|
return (
|
||||||
<AppLayout
|
<DndContext
|
||||||
breadcrumb={{
|
sensors={sensors}
|
||||||
items: [
|
collisionDetection={closestCenter}
|
||||||
{ label: 'Dashboard', href: '/' },
|
onDragStart={handleDragStart}
|
||||||
{ label: 'Playlists', href: '/playlists' },
|
onDragOver={handleDragOver}
|
||||||
{ label: playlist.name }
|
onDragEnd={handleDragEnd}
|
||||||
]
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<AppLayout
|
||||||
|
breadcrumb={{
|
||||||
|
items: [
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Playlists', href: '/playlists' },
|
||||||
|
{ label: playlist.name }
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -574,51 +943,26 @@ export function PlaylistEditPage() {
|
|||||||
Current Playlist ({sounds.length} sounds)
|
Current Playlist ({sounds.length} sounds)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 border rounded-lg overflow-hidden">
|
<div className="flex-1 border rounded-lg overflow-hidden">
|
||||||
{sounds.length === 0 ? (
|
<SortableContext
|
||||||
<div
|
items={sounds.map(sound => `playlist-sound-${sound.id}`)}
|
||||||
className="flex items-center justify-center h-full text-muted-foreground border-2 border-dashed border-muted hover:border-primary/50 transition-colors cursor-copy"
|
strategy={verticalListSortingStrategy}
|
||||||
data-position="0"
|
>
|
||||||
onDragOver={handleDragOver}
|
{sounds.length === 0 ? (
|
||||||
onDrop={handleDrop}
|
<EmptyPlaylistDropZone />
|
||||||
>
|
) : (
|
||||||
<p>Drag sounds here to add to playlist</p>
|
<div className="h-full overflow-y-auto">
|
||||||
</div>
|
{/* Drop zone at the top */}
|
||||||
) : (
|
<DropZone position={0} />
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
{/* Drop zone at the top */}
|
|
||||||
<div
|
|
||||||
className="h-2 border-2 border-dashed border-transparent hover:border-primary/50 transition-colors cursor-copy"
|
|
||||||
data-position="0"
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{sounds.map((sound, index) => (
|
{sounds.map((sound, index) => (
|
||||||
<div key={sound.id}>
|
<div key={sound.id}>
|
||||||
<div className="flex items-center gap-3 p-3 border-b hover:bg-muted/50">
|
<SortablePlaylistItem sound={sound} index={index} />
|
||||||
<div className="text-sm text-muted-foreground font-mono w-8">
|
<DropZone position={index + 1} />
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">{sound.name}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{formatDuration(sound.duration || 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
{/* Drop zone after each item */}
|
</div>
|
||||||
<div
|
)}
|
||||||
className="h-2 border-2 border-dashed border-transparent hover:border-primary/50 transition-colors cursor-copy"
|
</SortableContext>
|
||||||
data-position={index + 1}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -638,26 +982,20 @@ export function PlaylistEditPage() {
|
|||||||
<p>No available EXT sounds</p>
|
<p>No available EXT sounds</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full overflow-y-auto">
|
<SortableContext
|
||||||
{availableSounds.map((sound) => (
|
items={availableSounds.map(sound => sound.id.toString())}
|
||||||
<div
|
strategy={verticalListSortingStrategy}
|
||||||
key={sound.id}
|
>
|
||||||
className="flex items-center gap-3 p-3 border-b hover:bg-muted/50 cursor-grab active:cursor-grabbing select-none"
|
<div className="h-full overflow-y-auto">
|
||||||
draggable
|
{availableSounds.map((sound) => (
|
||||||
onDragStart={(e) => handleDragStart(e, sound.id)}
|
<DraggableAvailableSound
|
||||||
onClick={() => handleAddSoundToPlaylist(sound.id)}
|
key={sound.id}
|
||||||
>
|
sound={sound}
|
||||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
onAddSound={handleAddSoundToPlaylist}
|
||||||
<div className="flex-1 min-w-0">
|
/>
|
||||||
<div className="font-medium truncate">{sound.name}</div>
|
))}
|
||||||
<div className="text-sm text-muted-foreground">
|
</div>
|
||||||
{formatDuration(sound.duration || 0)} • {sound.type}
|
</SortableContext>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Plus className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,84 +1015,67 @@ export function PlaylistEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<SortableContext
|
||||||
<TableHeader>
|
items={sounds.map(sound => `table-sound-${sound.id}`)}
|
||||||
<TableRow>
|
strategy={verticalListSortingStrategy}
|
||||||
<TableHead className="w-12"></TableHead>
|
>
|
||||||
<TableHead>Name</TableHead>
|
<Table>
|
||||||
<TableHead>Duration</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Type</TableHead>
|
<TableRow>
|
||||||
<TableHead>Plays</TableHead>
|
<TableHead className="w-16 text-center">
|
||||||
<TableHead className="w-32">Actions</TableHead>
|
<div className="flex items-center justify-center gap-1">
|
||||||
</TableRow>
|
<div className="flex flex-col">
|
||||||
</TableHeader>
|
<div className="w-1 h-1 bg-muted-foreground/40 rounded-full"></div>
|
||||||
<TableBody>
|
<div className="w-1 h-1 bg-muted-foreground/40 rounded-full mt-0.5"></div>
|
||||||
{sounds.map((sound, index) => (
|
<div className="w-1 h-1 bg-muted-foreground/40 rounded-full mt-0.5"></div>
|
||||||
<TableRow key={sound.id} className="hover:bg-muted/50">
|
|
||||||
<TableCell className="text-center text-muted-foreground font-mono text-sm">
|
|
||||||
{index + 1}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium truncate">
|
|
||||||
{sound.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xs">#</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableHead>
|
||||||
<TableCell>{formatDuration(sound.duration || 0)}</TableCell>
|
<TableHead>Name</TableHead>
|
||||||
<TableCell>
|
<TableHead>Duration</TableHead>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<TableHead>Type</TableHead>
|
||||||
{sound.type}
|
<TableHead>Plays</TableHead>
|
||||||
</Badge>
|
<TableHead className="w-32">Actions</TableHead>
|
||||||
</TableCell>
|
|
||||||
<TableCell>{sound.play_count}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleMoveSoundUp(index)}
|
|
||||||
disabled={index === 0}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
title="Move up"
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleMoveSoundDown(index)}
|
|
||||||
disabled={index === sounds.length - 1}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
title="Move down"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleRemoveSound(sound.id)}
|
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
||||||
title="Remove from playlist"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{sounds.map((sound, index) => (
|
||||||
|
<SortableTableRow
|
||||||
|
key={sound.id}
|
||||||
|
sound={sound}
|
||||||
|
index={index}
|
||||||
|
onMoveSoundUp={handleMoveSoundUp}
|
||||||
|
onMoveSoundDown={handleMoveSoundDown}
|
||||||
|
onRemoveSound={handleRemoveSound}
|
||||||
|
totalSounds={sounds.length}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</AppLayout>
|
{/* Drag Overlay */}
|
||||||
|
<DragOverlay>
|
||||||
|
{draggedSound && (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-background border rounded-lg shadow-lg">
|
||||||
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{draggedSound.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(draggedSound.duration || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</AppLayout>
|
||||||
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user