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 { useParams, useNavigate } from 'react-router'
|
||||||
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 { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { toast } from 'sonner'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ export function PlaylistEditPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [isEditMode, setIsEditMode] = useState(false)
|
const [isEditMode, setIsEditMode] = useState(false)
|
||||||
|
const [isAddSoundsMode, setIsAddSoundsMode] = useState(false)
|
||||||
|
const [availableSounds, setAvailableSounds] = useState<Sound[]>([])
|
||||||
|
const [loadingAvailableSounds, setLoadingAvailableSounds] = useState(false)
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
@@ -451,108 +523,238 @@ export function PlaylistEditPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Music className="h-5 w-5" />
|
<Music className="h-5 w-5" />
|
||||||
Playlist Sounds ({sounds.length})
|
{isAddSoundsMode ? 'Add Sounds to Playlist' : `Playlist Sounds (${sounds.length})`}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
{isAddSoundsMode ? (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={fetchSounds}
|
variant="outline"
|
||||||
disabled={soundsLoading}
|
size="sm"
|
||||||
>
|
onClick={handleCloseAddSounds}
|
||||||
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} />
|
>
|
||||||
</Button>
|
<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"
|
||||||
|
onClick={fetchSounds}
|
||||||
|
disabled={soundsLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{soundsLoading ? (
|
{isAddSoundsMode ? (
|
||||||
<div className="space-y-3">
|
/* Add Sounds Mode - Two Column Layout */
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
<div className="grid grid-cols-2 gap-6 min-h-[600px]">
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
{/* Current Playlist Sounds - Left Column */}
|
||||||
))}
|
<div className="flex flex-col">
|
||||||
</div>
|
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||||
) : sounds.length === 0 ? (
|
<Music className="h-4 w-4" />
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
Current Playlist ({sounds.length} sounds)
|
||||||
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
</h3>
|
||||||
<p>No sounds in this playlist</p>
|
<div className="flex-1 border rounded-lg overflow-hidden">
|
||||||
</div>
|
{sounds.length === 0 ? (
|
||||||
) : (
|
<div
|
||||||
<div className="rounded-md border">
|
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"
|
||||||
<Table>
|
data-position="0"
|
||||||
<TableHeader>
|
onDragOver={handleDragOver}
|
||||||
<TableRow>
|
onDrop={handleDrop}
|
||||||
<TableHead className="w-12"></TableHead>
|
>
|
||||||
<TableHead>Name</TableHead>
|
<p>Drag sounds here to add to playlist</p>
|
||||||
<TableHead>Duration</TableHead>
|
</div>
|
||||||
<TableHead>Type</TableHead>
|
) : (
|
||||||
<TableHead>Plays</TableHead>
|
<div className="h-full overflow-y-auto">
|
||||||
<TableHead className="w-32">Actions</TableHead>
|
{/* Drop zone at the top */}
|
||||||
</TableRow>
|
<div
|
||||||
</TableHeader>
|
className="h-2 border-2 border-dashed border-transparent hover:border-primary/50 transition-colors cursor-copy"
|
||||||
<TableBody>
|
data-position="0"
|
||||||
{sounds.map((sound, index) => (
|
onDragOver={handleDragOver}
|
||||||
<TableRow key={sound.id}>
|
onDrop={handleDrop}
|
||||||
<TableCell className="text-center text-muted-foreground font-mono text-sm">
|
/>
|
||||||
{index + 1}
|
|
||||||
</TableCell>
|
{sounds.map((sound, index) => (
|
||||||
<TableCell>
|
<div key={sound.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 p-3 border-b hover:bg-muted/50">
|
||||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<div className="text-sm text-muted-foreground font-mono w-8">
|
||||||
<div className="min-w-0">
|
{index + 1}
|
||||||
<div className="font-medium truncate">
|
</div>
|
||||||
{sound.name}
|
<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>
|
</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>
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell>{formatDuration(sound.duration || 0)}</TableCell>
|
</div>
|
||||||
<TableCell>
|
)}
|
||||||
<Badge variant="secondary" className="text-xs">
|
</div>
|
||||||
{sound.type}
|
</div>
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
{/* Available Sounds - Right Column */}
|
||||||
<TableCell>{sound.play_count}</TableCell>
|
<div className="flex flex-col">
|
||||||
<TableCell>
|
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<Music className="h-4 w-4" />
|
||||||
<Button
|
Available EXT Sounds ({availableSounds.length} available)
|
||||||
size="sm"
|
</h3>
|
||||||
variant="ghost"
|
<div className="flex-1 border rounded-lg overflow-hidden">
|
||||||
onClick={() => handleMoveSoundUp(index)}
|
{loadingAvailableSounds ? (
|
||||||
disabled={index === 0}
|
<div className="flex items-center justify-center h-full">
|
||||||
className="h-8 w-8 p-0"
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
title="Move up"
|
</div>
|
||||||
>
|
) : availableSounds.length === 0 ? (
|
||||||
<ChevronUp className="h-4 w-4" />
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
</Button>
|
<p>No available EXT sounds</p>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
) : (
|
||||||
variant="ghost"
|
<div className="h-full overflow-y-auto">
|
||||||
onClick={() => handleMoveSoundDown(index)}
|
{availableSounds.map((sound) => (
|
||||||
disabled={index === sounds.length - 1}
|
<div
|
||||||
className="h-8 w-8 p-0"
|
key={sound.id}
|
||||||
title="Move down"
|
className="flex items-center gap-3 p-3 border-b hover:bg-muted/50 cursor-grab active:cursor-grabbing select-none"
|
||||||
>
|
draggable
|
||||||
<ChevronDown className="h-4 w-4" />
|
onDragStart={(e) => handleDragStart(e, sound.id)}
|
||||||
</Button>
|
onClick={() => handleAddSoundToPlaylist(sound.id)}
|
||||||
<Button
|
>
|
||||||
size="sm"
|
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
variant="ghost"
|
<div className="flex-1 min-w-0">
|
||||||
onClick={() => handleRemoveSound(sound.id)}
|
<div className="font-medium truncate">{sound.name}</div>
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
<div className="text-sm text-muted-foreground">
|
||||||
title="Remove from playlist"
|
{formatDuration(sound.duration || 0)} • {sound.type}
|
||||||
>
|
</div>
|
||||||
<Trash2 className="h-4 w-4" />
|
</div>
|
||||||
</Button>
|
<Plus className="h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
))}
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</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" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sounds.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No sounds in this playlist</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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} 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>
|
||||||
|
</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>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user