diff --git a/bun.lock b/bun.lock index b83c29d..5279b69 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "": { "name": "frontend", "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", "@radix-ui/react-avatar": "^1.1.10", "@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=="], + "@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/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], diff --git a/package.json b/package.json index 9c001df..38eba39 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "preview": "vite preview" }, "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", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", diff --git a/src/components/player/CompactPlayer.tsx b/src/components/player/CompactPlayer.tsx index f66b79f..56735db 100644 --- a/src/components/player/CompactPlayer.tsx +++ b/src/components/player/CompactPlayer.tsx @@ -27,7 +27,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) { status: 'stopped', mode: 'continuous', volume: 80, - previous_volume: 50, + previous_volume: 80, position: 0 }) const [isLoading, setIsLoading] = useState(false) diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx index 49ee910..3e41019 100644 --- a/src/components/player/Player.tsx +++ b/src/components/player/Player.tsx @@ -46,7 +46,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) { status: 'stopped', mode: 'continuous', volume: 80, - previous_volume: 50, + previous_volume: 80, position: 0 }) const [displayMode, setDisplayMode] = useState(() => { @@ -566,11 +566,6 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) { )} - {state.playlist && ( -

- {state.playlist.name} -

- )} {/* Progress Bar */} diff --git a/src/pages/PlaylistEditPage.tsx b/src/pages/PlaylistEditPage.tsx index 9891b5e..2e68326 100644 --- a/src/pages/PlaylistEditPage.tsx +++ b/src/pages/PlaylistEditPage.tsx @@ -1,7 +1,25 @@ import { useEffect, useState, useCallback } from 'react' 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 { 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,10 +28,279 @@ 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' + +// 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 ( + + +
+
+
+
+
+
+ {index + 1} +
+
+ +
+ +
+
+ {sound.name} +
+
+
+
+ {formatDuration(sound.duration || 0)} + + + {sound.type} + + + {sound.play_count} + +
+ + + +
+
+
+ ) +} + +// 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 ( +
+
+
+
+
+
+
+
+
+ + + {index + 1} + + + + +
+
+ {sound.name} +
+
+
+ + +
+ ) +} + +// 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 ( +
+ + +
+
+ {sound.name} +
+
+
+ ) +} + + +// Simple drop area for the end of the playlist +function EndDropArea() { + const { setNodeRef } = useDroppable({ + id: 'playlist-end', + data: { type: 'playlist-end' } + }) + + return ( +
+ ) +} + +// Inline preview component that shows where the sound will be dropped +interface InlinePreviewProps { + sound: Sound | PlaylistSound + position: number +} + +function InlinePreview({ sound, position }: InlinePreviewProps) { + return ( +
+
+
+
+
+
+
+
+ + + {position + 1} + + + + +
+
+ {sound.name} +
+
+ Will be added here +
+
+
+ ) +} + + export function PlaylistEditPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() @@ -27,6 +314,24 @@ 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([]) + const [availableSoundsLoading, setAvailableSoundsLoading] = useState(false) + const [draggedItem, setDraggedItem] = useState(null) + const [draggedSound, setDraggedSound] = useState(null) + const [dropPosition, setDropPosition] = useState(null) + + + // dnd-kit sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + // Form state const [formData, setFormData] = useState({ name: '', @@ -70,6 +375,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() @@ -190,10 +519,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 { + // 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) { @@ -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) { return ( +
-

{playlist.name}

@@ -453,14 +919,24 @@ export function PlaylistEditPage() { Playlist Sounds ({sounds.length}) - +

+ + +
@@ -475,84 +951,126 @@ export function PlaylistEditPage() {

No sounds in this playlist

+ ) : isAddMode ? ( + // Add Mode: Split layout with simplified playlist and available sounds +
+ {/* Current Playlist Sounds - Simplified */} +
+

Current Playlist

+ `playlist-sound-${sound.id}`)} + strategy={verticalListSortingStrategy} + > +
+ {sounds.map((sound, index) => { + const adjustedIndex = dropPosition !== null && dropPosition <= index ? index + 1 : index + return ( +
+ {/* Show inline preview if this is the drop position */} + {dropPosition === index && draggedSound && draggedItem?.startsWith('available-sound-') && ( + + )} + +
+ ) + })} + {/* Show inline preview at the end if that's the drop position */} + {dropPosition === sounds.length && draggedSound && draggedItem?.startsWith('available-sound-') && ( + + )} + {/* Invisible drop area at the end */} + +
+
+
+ + {/* Available Sounds */} +
+

+ Available EXT Sounds ({availableSounds.length}) +

+ {availableSoundsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : availableSounds.length === 0 ? ( +
+ +

No EXT sounds available

+

All EXT sounds are already in this playlist

+
+ ) : ( + `available-sound-${sound.id}`)} + strategy={verticalListSortingStrategy} + > +
+ {availableSounds.map((sound) => ( + + ))} +
+
+ )} +
+
) : ( + // Normal Mode: Full table view
- - - - - Name - Duration - Type - Plays - Actions - - - - {sounds.map((sound, index) => ( - - - {index + 1} - - -
- -
-
- {sound.name} -
+ `table-sound-${sound.id}`)} + strategy={verticalListSortingStrategy} + > +
+ + + +
+
+
+
+
+ #
- - {formatDuration(sound.duration || 0)} - - - {sound.type} - - - {sound.play_count} - -
- - - -
-
+
+ Name + Duration + Type + Plays + Actions
- ))} - -
+ + + {sounds.map((sound, index) => ( + + ))} + + +
)} -
- +
+ +
+ ) } \ No newline at end of file