feat: add sound addition functionality to PlaylistEditPage; implement drag-and-drop for adding available sounds

This commit is contained in:
JSC
2025-08-10 20:07:14 +02:00
parent 9c01cd538e
commit 34f20f33af

View File

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