- {state.current_sound.name}
+ {state.current_sound?.name || 'No track selected'}
{state.playlist?.name}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/lib/api/services/playlists.ts b/src/lib/api/services/playlists.ts
new file mode 100644
index 0000000..43a2125
--- /dev/null
+++ b/src/lib/api/services/playlists.ts
@@ -0,0 +1,181 @@
+import { apiClient } from '../client'
+
+export type PlaylistSortField = 'name' | 'genre' | 'created_at' | 'updated_at' | 'sound_count' | 'total_duration'
+export type SortOrder = 'asc' | 'desc'
+
+export interface Playlist {
+ id: number
+ name: string
+ description: string | null
+ genre: string | null
+ user_id: number | null
+ user_name: string | null
+ is_main: boolean
+ is_current: boolean
+ is_deletable: boolean
+ created_at: string
+ updated_at: string | null
+ sound_count: number
+ total_duration: number
+}
+
+export interface PlaylistSound {
+ id: number
+ filename: string
+ name: string
+ duration: number | null
+ size: number
+ hash: string
+ type: string
+ play_count: number
+ is_normalized: boolean
+ created_at: string
+ updated_at: string | null
+}
+
+export interface GetPlaylistsParams {
+ search?: string
+ sort_by?: PlaylistSortField
+ sort_order?: SortOrder
+ limit?: number
+ offset?: number
+}
+
+export class PlaylistsService {
+ /**
+ * Get all playlists with optional filtering, searching, and sorting
+ */
+ async getPlaylists(params?: GetPlaylistsParams): Promise
{
+ const searchParams = new URLSearchParams()
+
+ // Handle parameters
+ if (params?.search) {
+ searchParams.append('search', params.search)
+ }
+ if (params?.sort_by) {
+ searchParams.append('sort_by', params.sort_by)
+ }
+ if (params?.sort_order) {
+ searchParams.append('sort_order', params.sort_order)
+ }
+ if (params?.limit) {
+ searchParams.append('limit', params.limit.toString())
+ }
+ if (params?.offset) {
+ searchParams.append('offset', params.offset.toString())
+ }
+
+ const url = searchParams.toString() ? `/api/v1/playlists/?${searchParams.toString()}` : '/api/v1/playlists/'
+ return apiClient.get(url)
+ }
+
+ /**
+ * Get current user's playlists
+ */
+ async getUserPlaylists(): Promise {
+ return apiClient.get('/api/v1/playlists/user')
+ }
+
+ /**
+ * Get main playlist
+ */
+ async getMainPlaylist(): Promise {
+ return apiClient.get('/api/v1/playlists/main')
+ }
+
+ /**
+ * Get current playlist
+ */
+ async getCurrentPlaylist(): Promise {
+ return apiClient.get('/api/v1/playlists/current')
+ }
+
+ /**
+ * Create a new playlist
+ */
+ async createPlaylist(data: {
+ name: string
+ description?: string
+ genre?: string
+ }): Promise {
+ return apiClient.post('/api/v1/playlists/', data)
+ }
+
+ /**
+ * Update a playlist
+ */
+ async updatePlaylist(id: number, data: {
+ name?: string
+ description?: string
+ genre?: string
+ }): Promise {
+ return apiClient.put(`/api/v1/playlists/${id}`, data)
+ }
+
+ /**
+ * Delete a playlist
+ */
+ async deletePlaylist(id: number): Promise {
+ await apiClient.delete(`/api/v1/playlists/${id}`)
+ }
+
+ /**
+ * Set playlist as current
+ */
+ async setCurrentPlaylist(id: number): Promise {
+ return apiClient.put(`/api/v1/playlists/${id}/set-current`)
+ }
+
+ /**
+ * Get playlist statistics
+ */
+ async getPlaylistStats(id: number): Promise<{
+ sound_count: number
+ total_duration_ms: number
+ total_play_count: number
+ }> {
+ return apiClient.get(`/api/v1/playlists/${id}/stats`)
+ }
+
+ /**
+ * Get a specific playlist by ID
+ */
+ async getPlaylist(id: number): Promise {
+ return apiClient.get(`/api/v1/playlists/${id}`)
+ }
+
+ /**
+ * Get sounds in a playlist
+ */
+ async getPlaylistSounds(id: number): Promise {
+ return apiClient.get(`/api/v1/playlists/${id}/sounds`)
+ }
+
+ /**
+ * Add sound to playlist
+ */
+ async addSoundToPlaylist(playlistId: number, soundId: number, position?: number): Promise {
+ await apiClient.post(`/api/v1/playlists/${playlistId}/sounds`, {
+ sound_id: soundId,
+ position
+ })
+ }
+
+ /**
+ * Remove sound from playlist
+ */
+ async removeSoundFromPlaylist(playlistId: number, soundId: number): Promise {
+ await apiClient.delete(`/api/v1/playlists/${playlistId}/sounds/${soundId}`)
+ }
+
+ /**
+ * Reorder sounds in playlist
+ */
+ async reorderPlaylistSounds(playlistId: number, soundPositions: Array<[number, number]>): Promise {
+ await apiClient.put(`/api/v1/playlists/${playlistId}/sounds/reorder`, {
+ sound_positions: soundPositions
+ })
+ }
+}
+
+export const playlistsService = new PlaylistsService()
\ No newline at end of file
diff --git a/src/pages/PlaylistEditPage.tsx b/src/pages/PlaylistEditPage.tsx
new file mode 100644
index 0000000..f675e3a
--- /dev/null
+++ b/src/pages/PlaylistEditPage.tsx
@@ -0,0 +1,485 @@
+import { useEffect, useState, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router'
+import { AppLayout } from '@/components/AppLayout'
+import { playlistsService, type Playlist, type PlaylistSound } from '@/lib/api/services/playlists'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+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, ArrowLeft, Music, Clock, ChevronUp, ChevronDown, GripVertical, Trash2, RefreshCw } from 'lucide-react'
+import { toast } from 'sonner'
+import { formatDuration } from '@/utils/format-duration'
+
+export function PlaylistEditPage() {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const playlistId = parseInt(id!, 10)
+
+ const [playlist, setPlaylist] = useState(null)
+ const [sounds, setSounds] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [soundsLoading, setSoundsLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ // Form state
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ genre: ''
+ })
+
+ // Track if form has changes
+ const [hasChanges, setHasChanges] = useState(false)
+
+ const fetchPlaylist = useCallback(async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const playlistData = await playlistsService.getPlaylist(playlistId)
+ setPlaylist(playlistData)
+ setFormData({
+ name: playlistData.name,
+ description: playlistData.description || '',
+ genre: playlistData.genre || ''
+ })
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlist'
+ setError(errorMessage)
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }, [playlistId])
+
+ const fetchSounds = useCallback(async () => {
+ try {
+ setSoundsLoading(true)
+ const soundsData = await playlistsService.getPlaylistSounds(playlistId)
+ setSounds(soundsData)
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlist sounds'
+ toast.error(errorMessage)
+ } finally {
+ setSoundsLoading(false)
+ }
+ }, [playlistId])
+
+ useEffect(() => {
+ if (!isNaN(playlistId)) {
+ fetchPlaylist()
+ fetchSounds()
+ } else {
+ setError('Invalid playlist ID')
+ setLoading(false)
+ }
+ }, [playlistId, fetchPlaylist, fetchSounds])
+
+ useEffect(() => {
+ if (playlist) {
+ const changed =
+ formData.name !== playlist.name ||
+ formData.description !== (playlist.description || '') ||
+ formData.genre !== (playlist.genre || '')
+ setHasChanges(changed)
+ }
+ }, [formData, playlist])
+
+ const handleInputChange = (field: string, value: string) => {
+ setFormData(prev => ({
+ ...prev,
+ [field]: value
+ }))
+ }
+
+ const handleSave = async () => {
+ if (!playlist || !hasChanges) return
+
+ try {
+ setSaving(true)
+ await playlistsService.updatePlaylist(playlist.id, {
+ name: formData.name.trim() || undefined,
+ description: formData.description.trim() || undefined,
+ genre: formData.genre.trim() || undefined
+ })
+
+ toast.success('Playlist updated successfully')
+
+ // Refresh playlist data
+ await fetchPlaylist()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to update playlist'
+ toast.error(errorMessage)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleSetCurrent = async () => {
+ if (!playlist) return
+
+ try {
+ await playlistsService.setCurrentPlaylist(playlist.id)
+ toast.success(`"${playlist.name}" is now the current playlist`)
+
+ // Refresh playlist data
+ await fetchPlaylist()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to set current playlist'
+ toast.error(errorMessage)
+ }
+ }
+
+ const handleMoveSoundUp = async (index: number) => {
+ if (index === 0 || sounds.length < 2) return
+
+ const newSounds = [...sounds]
+ const [movedSound] = newSounds.splice(index, 1)
+ newSounds.splice(index - 1, 0, movedSound)
+
+ // Create sound positions array for the API
+ const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx])
+
+ try {
+ await playlistsService.reorderPlaylistSounds(playlistId, soundPositions)
+ setSounds(newSounds)
+ toast.success('Sound moved up')
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to reorder sounds'
+ toast.error(errorMessage)
+ }
+ }
+
+ const handleMoveSoundDown = async (index: number) => {
+ if (index === sounds.length - 1 || sounds.length < 2) return
+
+ const newSounds = [...sounds]
+ const [movedSound] = newSounds.splice(index, 1)
+ newSounds.splice(index + 1, 0, movedSound)
+
+ // Create sound positions array for the API
+ const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx])
+
+ try {
+ await playlistsService.reorderPlaylistSounds(playlistId, soundPositions)
+ setSounds(newSounds)
+ toast.success('Sound moved down')
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to reorder sounds'
+ toast.error(errorMessage)
+ }
+ }
+
+ const handleRemoveSound = async (soundId: number) => {
+ try {
+ await playlistsService.removeSoundFromPlaylist(playlistId, soundId)
+ setSounds(prev => prev.filter(sound => sound.id !== soundId))
+ toast.success('Sound removed from playlist')
+
+ // Refresh playlist data to update counts
+ await fetchPlaylist()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to remove sound'
+ toast.error(errorMessage)
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error || !playlist) {
+ return (
+
+
+
+
+
Failed to load playlist
+
{error}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
Edit Playlist
+
+ Modify playlist details and manage sounds
+
+
+
+
+
+ {!playlist.is_current && (
+
+ )}
+ {playlist.is_current && (
+ Current Playlist
+ )}
+
+
+
+
+ {/* Playlist Details Form */}
+
+
+
+
+ Playlist Details
+
+
+
+
+
+ handleInputChange('name', e.target.value)}
+ placeholder="Playlist name"
+ />
+
+
+
+
+
+
+
+
+ handleInputChange('genre', e.target.value)}
+ placeholder="Electronic, Rock, Comedy, etc."
+ />
+
+
+
+
+
+
+
+
+ {/* Playlist Stats */}
+
+
+
+
+ Playlist Statistics
+
+
+
+
+
+
{sounds.length}
+
Tracks
+
+
+
+ {formatDuration(sounds.reduce((total, sound) => total + (sound.duration || 0), 0))}
+
+
Duration
+
+
+
+
+
+ Created:
+ {new Date(playlist.created_at).toLocaleDateString()}
+
+ {playlist.updated_at && (
+
+ Updated:
+ {new Date(playlist.updated_at).toLocaleDateString()}
+
+ )}
+
+
Status:
+
+ {playlist.is_main && Main}
+ {playlist.is_current && Current}
+
+
+
+
+
+
+
+ {/* Playlist Sounds */}
+
+
+
+
+
+ Playlist Sounds ({sounds.length})
+
+
+
+
+
+ {soundsLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : sounds.length === 0 ? (
+
+
+
No sounds in this playlist
+
+ ) : (
+
+
+
+
+
+ Name
+ Duration
+ Type
+ Plays
+ Actions
+
+
+
+ {sounds.map((sound, index) => (
+
+
+ {index + 1}
+
+
+
+
+ {formatDuration(sound.duration || 0)}
+
+
+ {sound.type}
+
+
+ {sound.play_count}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/pages/PlaylistsPage.tsx b/src/pages/PlaylistsPage.tsx
index 0b471c2..749ba3c 100644
--- a/src/pages/PlaylistsPage.tsx
+++ b/src/pages/PlaylistsPage.tsx
@@ -1,6 +1,279 @@
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router'
import { AppLayout } from '@/components/AppLayout'
+import { playlistsService, type Playlist, type PlaylistSortField, type SortOrder } from '@/lib/api/services/playlists'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Input } from '@/components/ui/input'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Button } from '@/components/ui/button'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw, Music, User, Calendar, Clock, Plus, Play, Edit } from 'lucide-react'
+import { toast } from 'sonner'
+import { formatDuration } from '@/utils/format-duration'
export function PlaylistsPage() {
+ const navigate = useNavigate()
+ const [playlists, setPlaylists] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // Search and sorting state
+ const [searchQuery, setSearchQuery] = useState('')
+ const [sortBy, setSortBy] = useState('name')
+ const [sortOrder, setSortOrder] = useState('asc')
+
+ // Create playlist dialog state
+ const [showCreateDialog, setShowCreateDialog] = useState(false)
+ const [createLoading, setCreateLoading] = useState(false)
+ const [newPlaylist, setNewPlaylist] = useState({
+ name: '',
+ description: '',
+ genre: ''
+ })
+
+ // Debounce search query
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearchQuery(searchQuery)
+ }, 300)
+
+ return () => clearTimeout(handler)
+ }, [searchQuery])
+
+ const fetchPlaylists = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const playlistData = await playlistsService.getPlaylists({
+ search: debouncedSearchQuery.trim() || undefined,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ })
+ setPlaylists(playlistData)
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlists'
+ setError(errorMessage)
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchPlaylists()
+ }, [debouncedSearchQuery, sortBy, sortOrder])
+
+ const handleCreatePlaylist = async () => {
+ if (!newPlaylist.name.trim()) {
+ toast.error('Playlist name is required')
+ return
+ }
+
+ try {
+ setCreateLoading(true)
+ await playlistsService.createPlaylist({
+ name: newPlaylist.name.trim(),
+ description: newPlaylist.description.trim() || undefined,
+ genre: newPlaylist.genre.trim() || undefined,
+ })
+
+ toast.success(`Playlist "${newPlaylist.name}" created successfully`)
+
+ // Reset form and close dialog
+ setNewPlaylist({ name: '', description: '', genre: '' })
+ setShowCreateDialog(false)
+
+ // Refresh the playlists list
+ fetchPlaylists()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to create playlist'
+ toast.error(errorMessage)
+ } finally {
+ setCreateLoading(false)
+ }
+ }
+
+ const handleCancelCreate = () => {
+ setNewPlaylist({ name: '', description: '', genre: '' })
+ setShowCreateDialog(false)
+ }
+
+ const handleSetCurrent = async (playlist: Playlist) => {
+ try {
+ await playlistsService.setCurrentPlaylist(playlist.id)
+ toast.success(`"${playlist.name}" is now the current playlist`)
+
+ // Refresh the playlists list to update the current status
+ fetchPlaylists()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to set current playlist'
+ toast.error(errorMessage)
+ }
+ }
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString()
+ }
+
+ const renderContent = () => {
+ if (loading) {
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
Failed to load playlists
+
{error}
+
+
+ )
+ }
+
+ if (playlists.length === 0) {
+ return (
+
+
+
+
+
No playlists found
+
+ {searchQuery ? 'No playlists match your search criteria.' : 'No playlists are available.'}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Name
+ Genre
+ User
+ Tracks
+ Duration
+ Created
+ Status
+ Actions
+
+
+
+ {playlists.map((playlist) => (
+
+
+
+
+
+
{playlist.name}
+ {playlist.description && (
+
+ {playlist.description}
+
+ )}
+
+
+
+
+ {playlist.genre ? (
+ {playlist.genre}
+ ) : (
+ -
+ )}
+
+
+ {playlist.user_name ? (
+
+
+ {playlist.user_name}
+
+ ) : (
+ System
+ )}
+
+
+
+
+ {playlist.sound_count}
+
+
+
+
+
+ {formatDuration(playlist.total_duration || 0)}
+
+
+
+
+
+ {formatDate(playlist.created_at)}
+
+
+
+
+ {playlist.is_current && (
+ Current
+ )}
+ {playlist.is_main && (
+ Main
+ )}
+ {!playlist.is_current && !playlist.is_main && (
+ -
+ )}
+
+
+
+
+
+ {!playlist.is_current && (
+
+ )}
+
+
+
+ ))}
+
+
+
+ )
+ }
+
return (
-
Playlists
-
- Playlist management interface coming soon...
-
+
+
+
Playlists
+
+ Manage and browse your soundboard playlists
+
+
+
+
+ {!loading && !error && (
+
+ {playlists.length} playlist{playlists.length !== 1 ? 's' : ''}
+
+ )}
+
+
+
+ {/* Search and Sort Controls */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {searchQuery && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {renderContent()}
)