feat: add sound addition functionality to PlaylistEditPage; implement drag-and-drop for adding available sounds
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
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,7 +11,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, ArrowLeft, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
|
||||
@@ -26,6 +27,9 @@ export function PlaylistEditPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [isEditMode, setIsEditMode] = useState(false)
|
||||
const [isAddSoundsMode, setIsAddSoundsMode] = useState(false)
|
||||
const [availableSounds, setAvailableSounds] = useState<Sound[]>([])
|
||||
const [loadingAvailableSounds, setLoadingAvailableSounds] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -202,6 +206,74 @@ export function PlaylistEditPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAvailableSounds = useCallback(async () => {
|
||||
try {
|
||||
setLoadingAvailableSounds(true)
|
||||
// Get all EXT sounds
|
||||
const allExtSounds = await soundsService.getSoundsByType('EXT')
|
||||
|
||||
// Filter out sounds that are already in the current playlist
|
||||
const currentSoundIds = sounds.map(sound => sound.id)
|
||||
const available = allExtSounds.filter(sound => !currentSoundIds.includes(sound.id))
|
||||
|
||||
setAvailableSounds(available)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch available sounds'
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoadingAvailableSounds(false)
|
||||
}
|
||||
}, [sounds])
|
||||
|
||||
const handleOpenAddSounds = async () => {
|
||||
setIsAddSoundsMode(true)
|
||||
await fetchAvailableSounds()
|
||||
}
|
||||
|
||||
const handleCloseAddSounds = () => {
|
||||
setIsAddSoundsMode(false)
|
||||
setAvailableSounds([])
|
||||
}
|
||||
|
||||
const handleAddSoundToPlaylist = async (soundId: number, position?: number) => {
|
||||
try {
|
||||
await playlistsService.addSoundToPlaylist(playlistId, soundId, position)
|
||||
toast.success('Sound added to playlist')
|
||||
|
||||
// Refresh sounds and available sounds
|
||||
await fetchSounds()
|
||||
await fetchAvailableSounds()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, soundId: number) => {
|
||||
e.dataTransfer.setData('text/plain', soundId.toString())
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const soundIdStr = e.dataTransfer.getData('text/plain')
|
||||
const soundId = parseInt(soundIdStr, 10)
|
||||
|
||||
// Get the position from the drop target
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const position = parseInt(target.dataset.position || '0', 10)
|
||||
|
||||
if (!isNaN(soundId) && !isNaN(position)) {
|
||||
await handleAddSoundToPlaylist(soundId, position)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout
|
||||
@@ -451,8 +523,28 @@ export function PlaylistEditPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Music className="h-5 w-5" />
|
||||
Playlist Sounds ({sounds.length})
|
||||
{isAddSoundsMode ? 'Add Sounds to Playlist' : `Playlist Sounds (${sounds.length})`}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAddSoundsMode ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCloseAddSounds}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenAddSounds}
|
||||
title="Add sounds to playlist"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -461,10 +553,118 @@ export function PlaylistEditPage() {
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAddSoundsMode && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Drag sounds from the available list (right) to add them to your playlist (left) at specific positions, or click a sound to add it to the end.
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{soundsLoading ? (
|
||||
{isAddSoundsMode ? (
|
||||
/* Add Sounds Mode - Two Column Layout */
|
||||
<div className="grid grid-cols-2 gap-6 min-h-[600px]">
|
||||
{/* Current Playlist Sounds - Left Column */}
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<Music className="h-4 w-4" />
|
||||
Current Playlist ({sounds.length} sounds)
|
||||
</h3>
|
||||
<div className="flex-1 border rounded-lg overflow-hidden">
|
||||
{sounds.length === 0 ? (
|
||||
<div
|
||||
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"
|
||||
data-position="0"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<p>Drag sounds here to add to playlist</p>
|
||||
</div>
|
||||
) : (
|
||||
<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) => (
|
||||
<div key={sound.id}>
|
||||
<div className="flex items-center gap-3 p-3 border-b hover:bg-muted/50">
|
||||
<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>
|
||||
|
||||
{/* Drop zone after each item */}
|
||||
<div
|
||||
className="h-2 border-2 border-dashed border-transparent hover:border-primary/50 transition-colors cursor-copy"
|
||||
data-position={index + 1}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Sounds - Right Column */}
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<Music className="h-4 w-4" />
|
||||
Available EXT Sounds ({availableSounds.length} available)
|
||||
</h3>
|
||||
<div className="flex-1 border rounded-lg overflow-hidden">
|
||||
{loadingAvailableSounds ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : availableSounds.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p>No available EXT sounds</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{availableSounds.map((sound) => (
|
||||
<div
|
||||
key={sound.id}
|
||||
className="flex items-center gap-3 p-3 border-b hover:bg-muted/50 cursor-grab active:cursor-grabbing select-none"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, sound.id)}
|
||||
onClick={() => handleAddSoundToPlaylist(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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal Mode - Table View */
|
||||
soundsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
@@ -490,7 +690,7 @@ export function PlaylistEditPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sounds.map((sound, index) => (
|
||||
<TableRow key={sound.id}>
|
||||
<TableRow key={sound.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center text-muted-foreground font-mono text-sm">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
@@ -549,10 +749,12 @@ export function PlaylistEditPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user