feat: enhance PlaylistsPage with search, sorting, and playlist creation features; improve UI components and state management
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
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<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 (
|
|
<AppLayout
|
|
breadcrumb={{
|
|
items: [
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Playlists' }
|
|
]
|
|
}}
|
|
>
|
|
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Playlists</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage and browse your soundboard playlists
|
|
</p>
|
|
</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>
|
|
)
|
|
} |