feat: implement drag-and-drop functionality for sound management in PlaylistEditPage; add sortable components for better user experience

This commit is contained in:
JSC
2025-08-10 21:33:03 +02:00
parent 34f20f33af
commit 0c7875cac5
3 changed files with 495 additions and 160 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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>
) )
} }