feat: implement add mode in PlaylistEditPage; add drag-and-drop functionality for managing available sounds

This commit is contained in:
JSC
2025-08-11 01:00:26 +02:00
parent d80d8588f6
commit 490221ffdd

View File

@@ -2,7 +2,9 @@ import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
DndContext,
DragEndEvent,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
closestCenter,
PointerSensor,
useSensor,
@@ -13,9 +15,11 @@ import {
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'
@@ -24,7 +28,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, Plus, Minus } from 'lucide-react'
import { toast } from 'sonner'
import { formatDuration } from '@/utils/format-duration'
@@ -137,6 +141,173 @@ function SortableTableRow({
)
}
// 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>
)
}
// Drop zone component for inserting sounds at specific positions
interface DropZoneProps {
index: number
isActive: boolean
}
function DropZone({ index, isActive }: DropZoneProps) {
const { isOver, setNodeRef } = useDroppable({
id: `drop-zone-${index}`,
data: { type: 'dropzone', index }
})
return (
<div
ref={setNodeRef}
className={`h-2 transition-colors ${
isActive || isOver
? 'bg-primary/30 border-2 border-dashed border-primary'
: 'transparent'
}`}
/>
)
}
// 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 }>()
@@ -151,6 +322,14 @@ 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(
@@ -204,6 +383,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()
@@ -324,10 +527,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 (err) {
// 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) {
@@ -337,19 +558,78 @@ export function PlaylistEditPage() {
}
// dnd-kit drag handler
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
// Only handle table-sound reordering
if (overId.startsWith('table-sound-')) {
const draggedSoundId = parseInt(activeId.replace('table-sound-', ''), 10)
const targetSoundId = parseInt(overId.replace('table-sound-', ''), 10)
// Handle adding sound from available list to playlist
if (activeId.startsWith('available-sound-') && (overId.startsWith('playlist-sound-') || overId.startsWith('drop-zone-'))) {
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.startsWith('drop-zone-')) {
position = parseInt(overId.replace('drop-zone-', ''), 10)
}
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)
@@ -375,6 +655,29 @@ export function PlaylistEditPage() {
}
}
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.startsWith('drop-zone-')) {
const position = parseInt(overId.replace('drop-zone-', ''), 10)
setDropPosition(position)
} else {
setDropPosition(null)
}
}
if (loading) {
return (
<AppLayout
@@ -429,7 +732,9 @@ export function PlaylistEditPage() {
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
>
<AppLayout
breadcrumb={{
@@ -624,6 +929,14 @@ export function PlaylistEditPage() {
Playlist Sounds ({sounds.length})
</CardTitle>
<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"
@@ -647,7 +960,79 @@ 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-1">
<DropZone index={0} isActive={draggedItem?.startsWith('available-sound-') || false} />
{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}
/>
<DropZone index={index + 1} isActive={draggedItem?.startsWith('available-sound-') || false} />
</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} />
)}
</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">
<SortableContext
items={sounds.map(sound => `table-sound-${sound.id}`)}