|
|
|
|
@@ -1,7 +1,25 @@
|
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
|
|
|
import { useParams, useNavigate } from 'react-router'
|
|
|
|
|
import {
|
|
|
|
|
DndContext,
|
|
|
|
|
type DragEndEvent,
|
|
|
|
|
type DragStartEvent,
|
|
|
|
|
type DragOverEvent,
|
|
|
|
|
closestCenter,
|
|
|
|
|
PointerSensor,
|
|
|
|
|
useSensor,
|
|
|
|
|
useSensors,
|
|
|
|
|
} from '@dnd-kit/core'
|
|
|
|
|
import {
|
|
|
|
|
SortableContext,
|
|
|
|
|
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'
|
|
|
|
|
@@ -10,10 +28,279 @@ 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'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 group"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center justify-center cursor-grab active:cursor-grabbing"
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
<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"></div>
|
|
|
|
|
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<span className="text-sm font-mono text-muted-foreground w-6 text-center">
|
|
|
|
|
{index + 1}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="font-medium truncate">
|
|
|
|
|
{sound.name}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => 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"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-muted/50 cursor-grab active:cursor-grabbing"
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
>
|
|
|
|
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="font-medium truncate">
|
|
|
|
|
{sound.name}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Simple drop area for the end of the playlist
|
|
|
|
|
function EndDropArea() {
|
|
|
|
|
const { setNodeRef } = useDroppable({
|
|
|
|
|
id: 'playlist-end',
|
|
|
|
|
data: { type: 'playlist-end' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
className="h-8 w-full" // Invisible drop area
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inline preview component that shows where the sound will be dropped
|
|
|
|
|
interface InlinePreviewProps {
|
|
|
|
|
sound: Sound | PlaylistSound
|
|
|
|
|
position: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function InlinePreview({ sound, position }: InlinePreviewProps) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3 p-3 border-2 border-dashed border-primary rounded-lg bg-primary/10 animate-pulse">
|
|
|
|
|
<div className="flex items-center justify-center">
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
<div className="w-1 h-1 bg-primary/60 rounded-full"></div>
|
|
|
|
|
<div className="w-1 h-1 bg-primary/60 rounded-full"></div>
|
|
|
|
|
<div className="w-1 h-1 bg-primary/60 rounded-full"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<span className="text-sm font-mono text-primary w-6 text-center">
|
|
|
|
|
{position + 1}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<Music className="h-4 w-4 text-primary flex-shrink-0" />
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="font-medium truncate text-primary">
|
|
|
|
|
{sound.name}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-primary/70">
|
|
|
|
|
Will be added here
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function PlaylistEditPage() {
|
|
|
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
@@ -27,6 +314,24 @@ 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<Sound[]>([])
|
|
|
|
|
const [availableSoundsLoading, setAvailableSoundsLoading] = useState(false)
|
|
|
|
|
const [draggedItem, setDraggedItem] = useState<string | null>(null)
|
|
|
|
|
const [draggedSound, setDraggedSound] = useState<Sound | PlaylistSound | null>(null)
|
|
|
|
|
const [dropPosition, setDropPosition] = useState<number | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// dnd-kit sensors
|
|
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, {
|
|
|
|
|
activationConstraint: {
|
|
|
|
|
distance: 8,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Form state
|
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
name: '',
|
|
|
|
|
@@ -70,6 +375,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()
|
|
|
|
|
@@ -190,10 +519,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 {
|
|
|
|
|
// 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) {
|
|
|
|
|
@@ -202,6 +549,126 @@ export function PlaylistEditPage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
// Handle adding sound from available list to playlist
|
|
|
|
|
if (activeId.startsWith('available-sound-') && (overId.startsWith('playlist-sound-') || overId === 'playlist-end')) {
|
|
|
|
|
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 === 'playlist-end') {
|
|
|
|
|
position = sounds.length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 === 'playlist-end') {
|
|
|
|
|
setDropPosition(sounds.length)
|
|
|
|
|
} else {
|
|
|
|
|
setDropPosition(null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<AppLayout
|
|
|
|
|
@@ -253,26 +720,25 @@ export function PlaylistEditPage() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AppLayout
|
|
|
|
|
breadcrumb={{
|
|
|
|
|
items: [
|
|
|
|
|
{ label: 'Dashboard', href: '/' },
|
|
|
|
|
{ label: 'Playlists', href: '/playlists' },
|
|
|
|
|
{ label: playlist.name }
|
|
|
|
|
]
|
|
|
|
|
}}
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
onDragStart={handleDragStart}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
>
|
|
|
|
|
<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 items-center justify-between mb-6">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => navigate('/playlists')}
|
|
|
|
|
>
|
|
|
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
|
|
|
Back
|
|
|
|
|
</Button>
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold">{playlist.name}</h1>
|
|
|
|
|
<p className="text-muted-foreground">
|
|
|
|
|
@@ -453,14 +919,24 @@ export function PlaylistEditPage() {
|
|
|
|
|
<Music className="h-5 w-5" />
|
|
|
|
|
Playlist Sounds ({sounds.length})
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={fetchSounds}
|
|
|
|
|
disabled={soundsLoading}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} />
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant={isAddMode ? "default" : "outline"}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={toggleAddMode}
|
|
|
|
|
title={isAddMode ? "Exit add mode" : "Enter add mode to add EXT sounds"}
|
|
|
|
|
>
|
|
|
|
|
{isAddMode ? <Minus className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={fetchSounds}
|
|
|
|
|
disabled={soundsLoading}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
@@ -475,84 +951,126 @@ export function PlaylistEditPage() {
|
|
|
|
|
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
|
|
|
<p>No sounds in this playlist</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : isAddMode ? (
|
|
|
|
|
// Add Mode: Split layout with simplified playlist and available sounds
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
{/* Current Playlist Sounds - Simplified */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h4 className="font-medium text-sm text-muted-foreground mb-3">Current Playlist</h4>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={sounds.map(sound => `playlist-sound-${sound.id}`)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{sounds.map((sound, index) => {
|
|
|
|
|
const adjustedIndex = dropPosition !== null && dropPosition <= index ? index + 1 : index
|
|
|
|
|
return (
|
|
|
|
|
<div key={sound.id}>
|
|
|
|
|
{/* Show inline preview if this is the drop position */}
|
|
|
|
|
{dropPosition === index && draggedSound && draggedItem?.startsWith('available-sound-') && (
|
|
|
|
|
<InlinePreview sound={draggedSound} position={dropPosition} />
|
|
|
|
|
)}
|
|
|
|
|
<SimpleSortableRow
|
|
|
|
|
sound={sound}
|
|
|
|
|
index={adjustedIndex}
|
|
|
|
|
onRemoveSound={handleRemoveSound}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
{/* Show inline preview at the end if that's the drop position */}
|
|
|
|
|
{dropPosition === sounds.length && draggedSound && draggedItem?.startsWith('available-sound-') && (
|
|
|
|
|
<InlinePreview sound={draggedSound} position={dropPosition} />
|
|
|
|
|
)}
|
|
|
|
|
{/* Invisible drop area at the end */}
|
|
|
|
|
<EndDropArea />
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Available Sounds */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h4 className="font-medium text-sm text-muted-foreground mb-3">
|
|
|
|
|
Available EXT Sounds ({availableSounds.length})
|
|
|
|
|
</h4>
|
|
|
|
|
{availableSoundsLoading ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
|
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : availableSounds.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
|
|
|
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
|
|
|
<p className="text-sm">No EXT sounds available</p>
|
|
|
|
|
<p className="text-xs mt-1">All EXT sounds are already in this playlist</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={availableSounds.map(sound => `available-sound-${sound.id}`)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
|
|
|
{availableSounds.map((sound) => (
|
|
|
|
|
<AvailableSound
|
|
|
|
|
key={sound.id}
|
|
|
|
|
sound={sound}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
// Normal Mode: Full table view
|
|
|
|
|
<div className="rounded-md border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-12"></TableHead>
|
|
|
|
|
<TableHead>Name</TableHead>
|
|
|
|
|
<TableHead>Duration</TableHead>
|
|
|
|
|
<TableHead>Type</TableHead>
|
|
|
|
|
<TableHead>Plays</TableHead>
|
|
|
|
|
<TableHead className="w-32">Actions</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{sounds.map((sound, index) => (
|
|
|
|
|
<TableRow key={sound.id}>
|
|
|
|
|
<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>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={sounds.map(sound => `table-sound-${sound.id}`)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-16 text-center">
|
|
|
|
|
<div className="flex items-center justify-center gap-1">
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<div className="w-1 h-1 bg-muted-foreground/40 rounded-full"></div>
|
|
|
|
|
<div className="w-1 h-1 bg-muted-foreground/40 rounded-full mt-0.5"></div>
|
|
|
|
|
<div className="w-1 h-1 bg-muted-foreground/40 rounded-full mt-0.5"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs">#</span>
|
|
|
|
|
</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={() => 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>
|
|
|
|
|
</TableHead>
|
|
|
|
|
<TableHead>Name</TableHead>
|
|
|
|
|
<TableHead>Duration</TableHead>
|
|
|
|
|
<TableHead>Type</TableHead>
|
|
|
|
|
<TableHead>Plays</TableHead>
|
|
|
|
|
<TableHead className="w-32">Actions</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</AppLayout>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</AppLayout>
|
|
|
|
|
</DndContext>
|
|
|
|
|
)
|
|
|
|
|
}
|