Merge branch 'add_sound_to_playlist_dnd'

This commit is contained in:
JSC
2025-08-11 09:42:10 +02:00
5 changed files with 629 additions and 102 deletions

View File

@@ -4,6 +4,9 @@
"": { "": {
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@number-flow/react": "^0.5.10", "@number-flow/react": "^0.5.10",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
@@ -86,6 +89,14 @@
"@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="], "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="],

View File

@@ -11,6 +11,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@number-flow/react": "^0.5.10", "@number-flow/react": "^0.5.10",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",

View File

@@ -27,7 +27,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
status: 'stopped', status: 'stopped',
mode: 'continuous', mode: 'continuous',
volume: 80, volume: 80,
previous_volume: 50, previous_volume: 80,
position: 0 position: 0
}) })
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)

View File

@@ -46,7 +46,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
status: 'stopped', status: 'stopped',
mode: 'continuous', mode: 'continuous',
volume: 80, volume: 80,
previous_volume: 50, previous_volume: 80,
position: 0 position: 0
}) })
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => { const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
@@ -566,11 +566,6 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
{state.playlist && (
<p className="text-lg text-muted-foreground">
{state.playlist.name}
</p>
)}
</div> </div>
{/* Progress Bar */} {/* Progress Bar */}

View File

@@ -1,7 +1,25 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router' 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 { 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,10 +28,279 @@ 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, Plus, Minus } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDuration } from '@/utils/format-duration' 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() { export function PlaylistEditPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -27,6 +314,24 @@ export function PlaylistEditPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [isEditMode, setIsEditMode] = 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 // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -70,6 +375,30 @@ export function PlaylistEditPage() {
} }
}, [playlistId]) }, [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(() => { useEffect(() => {
if (!isNaN(playlistId)) { if (!isNaN(playlistId)) {
fetchPlaylist() fetchPlaylist()
@@ -190,10 +519,28 @@ export function PlaylistEditPage() {
const handleRemoveSound = async (soundId: number) => { const handleRemoveSound = async (soundId: number) => {
try { 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) await playlistsService.removeSoundFromPlaylist(playlistId, soundId)
setSounds(prev => prev.filter(sound => sound.id !== soundId)) setSounds(prev => prev.filter(sound => sound.id !== soundId))
toast.success('Sound removed from playlist') 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 // Refresh playlist data to update counts
await fetchPlaylist() await fetchPlaylist()
} catch (err) { } 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) { if (loading) {
return ( return (
<AppLayout <AppLayout
@@ -253,26 +720,25 @@ export function PlaylistEditPage() {
} }
return ( return (
<AppLayout <DndContext
breadcrumb={{ sensors={sensors}
items: [ collisionDetection={closestCenter}
{ label: 'Dashboard', href: '/' }, onDragStart={handleDragStart}
{ label: 'Playlists', href: '/playlists' }, onDragEnd={handleDragEnd}
{ label: playlist.name } 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-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4"> <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> <div>
<h1 className="text-2xl font-bold">{playlist.name}</h1> <h1 className="text-2xl font-bold">{playlist.name}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -453,14 +919,24 @@ export function PlaylistEditPage() {
<Music className="h-5 w-5" /> <Music className="h-5 w-5" />
Playlist Sounds ({sounds.length}) Playlist Sounds ({sounds.length})
</CardTitle> </CardTitle>
<Button <div className="flex items-center gap-2">
variant="outline" <Button
size="sm" variant={isAddMode ? "default" : "outline"}
onClick={fetchSounds} size="sm"
disabled={soundsLoading} onClick={toggleAddMode}
> title={isAddMode ? "Exit add mode" : "Enter add mode to add EXT sounds"}
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} /> >
</Button> {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> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -475,84 +951,126 @@ export function PlaylistEditPage() {
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No sounds in this playlist</p> <p>No sounds in this playlist</p>
</div> </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"> <div className="rounded-md border">
<Table> <SortableContext
<TableHeader> items={sounds.map(sound => `table-sound-${sound.id}`)}
<TableRow> strategy={verticalListSortingStrategy}
<TableHead className="w-12"></TableHead> >
<TableHead>Name</TableHead> <Table>
<TableHead>Duration</TableHead> <TableHeader>
<TableHead>Type</TableHead> <TableRow>
<TableHead>Plays</TableHead> <TableHead className="w-16 text-center">
<TableHead className="w-32">Actions</TableHead> <div className="flex items-center justify-center gap-1">
</TableRow> <div className="flex flex-col">
</TableHeader> <div className="w-1 h-1 bg-muted-foreground/40 rounded-full"></div>
<TableBody> <div className="w-1 h-1 bg-muted-foreground/40 rounded-full mt-0.5"></div>
{sounds.map((sound, index) => ( <div className="w-1 h-1 bg-muted-foreground/40 rounded-full mt-0.5"></div>
<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>
</div> </div>
<span className="text-xs">#</span>
</div> </div>
</TableCell> </TableHead>
<TableCell>{formatDuration(sound.duration || 0)}</TableCell> <TableHead>Name</TableHead>
<TableCell> <TableHead>Duration</TableHead>
<Badge variant="secondary" className="text-xs"> <TableHead>Type</TableHead>
{sound.type} <TableHead>Plays</TableHead>
</Badge> <TableHead className="w-32">Actions</TableHead>
</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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {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> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</AppLayout>
</AppLayout>
</DndContext>
) )
} }