Compare commits

...

2 Commits

Author SHA1 Message Date
JSC
9c01cd538e feat: enhance PlaylistEditPage with edit mode functionality; add cancel and save options for playlist details 2025-08-10 19:41:59 +02:00
JSC
6eb023a63c feat: add playlist editing functionality; implement PlaylistEditPage and integrate with playlists service
feat: enhance PlaylistsPage with search, sorting, and playlist creation features; improve UI components and state management
2025-08-10 19:30:08 +02:00
6 changed files with 1179 additions and 9 deletions

View File

@@ -8,6 +8,7 @@ import { AuthCallbackPage } from './pages/AuthCallbackPage'
import { DashboardPage } from './pages/DashboardPage' import { DashboardPage } from './pages/DashboardPage'
import { SoundsPage } from './pages/SoundsPage' import { SoundsPage } from './pages/SoundsPage'
import { PlaylistsPage } from './pages/PlaylistsPage' import { PlaylistsPage } from './pages/PlaylistsPage'
import { PlaylistEditPage } from './pages/PlaylistEditPage'
import { ExtractionsPage } from './pages/ExtractionsPage' import { ExtractionsPage } from './pages/ExtractionsPage'
import { UsersPage } from './pages/admin/UsersPage' import { UsersPage } from './pages/admin/UsersPage'
import { SettingsPage } from './pages/admin/SettingsPage' import { SettingsPage } from './pages/admin/SettingsPage'
@@ -69,6 +70,11 @@ function AppRoutes() {
<PlaylistsPage /> <PlaylistsPage />
</ProtectedRoute> </ProtectedRoute>
} /> } />
<Route path="/playlists/:id/edit" element={
<ProtectedRoute>
<PlaylistEditPage />
</ProtectedRoute>
} />
<Route path="/extractions" element={ <Route path="/extractions" element={
<ProtectedRoute> <ProtectedRoute>
<ExtractionsPage /> <ExtractionsPage />

View File

@@ -97,10 +97,10 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
} }
}, [state.volume, executeAction]) }, [state.volume, executeAction])
// Don't show if no current sound // // Don't show if no current sound
if (!state.current_sound) { // if (!state.current_sound) {
return null // return null
} // }
return ( return (
<div className={cn("w-full", className)}> <div className={cn("w-full", className)}>
@@ -150,7 +150,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{state.current_sound.name} {state.current_sound?.name || 'No track selected'}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{state.playlist?.name} {state.playlist?.name}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -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<Playlist[]> {
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<Playlist[]>(url)
}
/**
* Get current user's playlists
*/
async getUserPlaylists(): Promise<Playlist[]> {
return apiClient.get<Playlist[]>('/api/v1/playlists/user')
}
/**
* Get main playlist
*/
async getMainPlaylist(): Promise<Playlist> {
return apiClient.get<Playlist>('/api/v1/playlists/main')
}
/**
* Get current playlist
*/
async getCurrentPlaylist(): Promise<Playlist> {
return apiClient.get<Playlist>('/api/v1/playlists/current')
}
/**
* Create a new playlist
*/
async createPlaylist(data: {
name: string
description?: string
genre?: string
}): Promise<Playlist> {
return apiClient.post<Playlist>('/api/v1/playlists/', data)
}
/**
* Update a playlist
*/
async updatePlaylist(id: number, data: {
name?: string
description?: string
genre?: string
}): Promise<Playlist> {
return apiClient.put<Playlist>(`/api/v1/playlists/${id}`, data)
}
/**
* Delete a playlist
*/
async deletePlaylist(id: number): Promise<void> {
await apiClient.delete(`/api/v1/playlists/${id}`)
}
/**
* Set playlist as current
*/
async setCurrentPlaylist(id: number): Promise<Playlist> {
return apiClient.put<Playlist>(`/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<Playlist> {
return apiClient.get<Playlist>(`/api/v1/playlists/${id}`)
}
/**
* Get sounds in a playlist
*/
async getPlaylistSounds(id: number): Promise<PlaylistSound[]> {
return apiClient.get<PlaylistSound[]>(`/api/v1/playlists/${id}/sounds`)
}
/**
* Add sound to playlist
*/
async addSoundToPlaylist(playlistId: number, soundId: number, position?: number): Promise<void> {
await apiClient.post(`/api/v1/playlists/${playlistId}/sounds`, {
sound_id: soundId,
position
})
}
/**
* Remove sound from playlist
*/
async removeSoundFromPlaylist(playlistId: number, soundId: number): Promise<void> {
await apiClient.delete(`/api/v1/playlists/${playlistId}/sounds/${soundId}`)
}
/**
* Reorder sounds in playlist
*/
async reorderPlaylistSounds(playlistId: number, soundPositions: Array<[number, number]>): Promise<void> {
await apiClient.put(`/api/v1/playlists/${playlistId}/sounds/reorder`, {
sound_positions: soundPositions
})
}
}
export const playlistsService = new PlaylistsService()

View File

@@ -0,0 +1,558 @@
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, Music, Clock, ChevronUp, ChevronDown, Trash2, RefreshCw, Edit, X, ArrowLeft } 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<Playlist | null>(null)
const [sounds, setSounds] = useState<PlaylistSound[]>([])
const [loading, setLoading] = useState(true)
const [soundsLoading, setSoundsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [isEditMode, setIsEditMode] = 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 and exit edit mode
await fetchPlaylist()
setIsEditMode(false)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update playlist'
toast.error(errorMessage)
} finally {
setSaving(false)
}
}
const handleCancelEdit = () => {
if (playlist) {
// Reset form data to original values
setFormData({
name: playlist.name,
description: playlist.description || '',
genre: playlist.genre || ''
})
setIsEditMode(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 (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Playlists', href: '/playlists' },
{ label: 'Edit' }
]
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4 space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-96" />
<Skeleton className="h-96" />
</div>
</div>
</AppLayout>
)
}
if (error || !playlist) {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Playlists', href: '/playlists' },
{ label: 'Edit' }
]
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to load playlist</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={() => navigate('/playlists')}
className="text-primary hover:underline"
>
Back to playlists
</button>
</div>
</div>
</AppLayout>
)
}
return (
<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 items-center justify-between mb-6">
<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>
<h1 className="text-2xl font-bold">{playlist.name}</h1>
<p className="text-muted-foreground">
View and manage your playlist
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!playlist.is_current && !isEditMode && (
<Button
variant="outline"
onClick={handleSetCurrent}
>
Set as Current
</Button>
)}
{playlist.is_current && (
<Badge variant="default">Current Playlist</Badge>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Playlist Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Music className="h-5 w-5" />
Playlist Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isEditMode ? (
<>
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Playlist name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Playlist description"
className="min-h-[100px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="genre">Genre</Label>
<Input
id="genre"
value={formData.genre}
onChange={(e) => handleInputChange('genre', e.target.value)}
placeholder="Electronic, Rock, Comedy, etc."
/>
</div>
</>
) : (
<>
<div className="space-y-3">
<div>
<Label className="text-sm font-medium text-muted-foreground">Name</Label>
<p className="text-lg font-semibold">{playlist.name}</p>
</div>
{playlist.description && (
<div>
<Label className="text-sm font-medium text-muted-foreground">Description</Label>
<p className="text-sm">{playlist.description}</p>
</div>
)}
{playlist.genre && (
<div>
<Label className="text-sm font-medium text-muted-foreground">Genre</Label>
<Badge variant="secondary" className="mt-1">{playlist.genre}</Badge>
</div>
)}
{!playlist.description && !playlist.genre && (
<p className="text-sm text-muted-foreground italic">No additional details provided</p>
)}
</div>
</>
)}
{/* Edit/Save/Cancel buttons */}
<div className="pt-4 border-t">
{isEditMode ? (
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleCancelEdit}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
) : (
<Button
onClick={() => setIsEditMode(true)}
className="w-full"
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
)}
</div>
</CardContent>
</Card>
{/* Playlist Stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Playlist Statistics
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold">{sounds.length}</div>
<div className="text-sm text-muted-foreground">Tracks</div>
</div>
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold">
{formatDuration(sounds.reduce((total, sound) => total + (sound.duration || 0), 0))}
</div>
<div className="text-sm text-muted-foreground">Duration</div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Created:</span>
<span>{new Date(playlist.created_at).toLocaleDateString()}</span>
</div>
{playlist.updated_at && (
<div className="flex justify-between text-sm">
<span>Updated:</span>
<span>{new Date(playlist.updated_at).toLocaleDateString()}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span>Status:</span>
<div className="flex gap-1">
{playlist.is_main && <Badge variant="outline" className="text-xs">Main</Badge>}
{playlist.is_current && <Badge variant="default" className="text-xs">Current</Badge>}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Playlist Sounds */}
<Card className="mt-6">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Music className="h-5 w-5" />
Playlist Sounds ({sounds.length})
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={fetchSounds}
disabled={soundsLoading}
>
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
{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}>
<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>
</Card>
</div>
</AppLayout>
)
}

View File

@@ -1,6 +1,279 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { AppLayout } from '@/components/AppLayout' 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() { export function PlaylistsPage() {
const navigate = useNavigate()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and sorting state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('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 (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to load playlists</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={fetchPlaylists}
className="text-primary hover:underline"
>
Try again
</button>
</div>
)
}
if (playlists.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Music className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No playlists found</h3>
<p className="text-muted-foreground">
{searchQuery ? 'No playlists match your search criteria.' : 'No playlists are available.'}
</p>
</div>
)
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Genre</TableHead>
<TableHead>User</TableHead>
<TableHead className="text-center">Tracks</TableHead>
<TableHead className="text-center">Duration</TableHead>
<TableHead className="text-center">Created</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{playlists.map((playlist) => (
<TableRow key={playlist.id} className="hover:bg-muted/50">
<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">{playlist.name}</div>
{playlist.description && (
<div className="text-sm text-muted-foreground truncate">
{playlist.description}
</div>
)}
</div>
</div>
</TableCell>
<TableCell>
{playlist.genre ? (
<Badge variant="secondary">{playlist.genre}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{playlist.user_name ? (
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{playlist.user_name}</span>
</div>
) : (
<span className="text-muted-foreground">System</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Music className="h-3 w-3 text-muted-foreground" />
{playlist.sound_count}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground" />
{formatDuration(playlist.total_duration || 0)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(playlist.created_at)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
{playlist.is_current && (
<Badge variant="default">Current</Badge>
)}
{playlist.is_main && (
<Badge variant="outline">Main</Badge>
)}
{!playlist.is_current && !playlist.is_main && (
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/playlists/${playlist.id}/edit`)}
className="h-8 w-8 p-0"
title={`Edit "${playlist.name}"`}
>
<Edit className="h-4 w-4" />
</Button>
{!playlist.is_current && (
<Button
size="sm"
variant="ghost"
onClick={() => handleSetCurrent(playlist)}
className="h-8 w-8 p-0"
title={`Set "${playlist.name}" as current playlist`}
>
<Play className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
@@ -11,11 +284,145 @@ export function PlaylistsPage() {
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
<h1 className="text-2xl font-bold mb-4">Playlists</h1> <div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Playlists</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Playlist management interface coming soon... Manage and browse your soundboard playlists
</p> </p>
</div> </div>
<div className="flex items-center gap-4">
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Playlist
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Playlist</DialogTitle>
<DialogDescription>
Add a new playlist to organize your sounds. Give it a name and optionally add a description and genre.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
placeholder="My awesome playlist"
value={newPlaylist.name}
onChange={(e) => setNewPlaylist(prev => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleCreatePlaylist()
}
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="A collection of my favorite sounds..."
value={newPlaylist.description}
onChange={(e) => setNewPlaylist(prev => ({ ...prev, description: e.target.value }))}
className="min-h-[80px]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="genre">Genre</Label>
<Input
id="genre"
placeholder="Electronic, Rock, Comedy, etc."
value={newPlaylist.genre}
onChange={(e) => setNewPlaylist(prev => ({ ...prev, genre: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelCreate} disabled={createLoading}>
Cancel
</Button>
<Button onClick={handleCreatePlaylist} disabled={createLoading || !newPlaylist.name.trim()}>
{createLoading ? 'Creating...' : 'Create Playlist'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}
</div>
)}
</div>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search playlists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery('')}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
title="Clear search"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => setSortBy(value as PlaylistSortField)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="genre">Genre</SelectItem>
<SelectItem value="sound_count">Track Count</SelectItem>
<SelectItem value="total_duration">Duration</SelectItem>
<SelectItem value="created_at">Created Date</SelectItem>
<SelectItem value="updated_at">Updated Date</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
>
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={fetchPlaylists}
disabled={loading}
title="Refresh playlists"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{renderContent()}
</div>
</AppLayout> </AppLayout>
) )
} }