feat: Refactor playlist edit components for improved structure and functionality
- Added AvailableSound component for displaying and adding sounds to playlists. - Introduced DragOverlayComponents for drag-and-drop functionality with inline previews and drop areas. - Created PlaylistDetailsCard for editing playlist details with save and cancel options. - Implemented PlaylistEditHeader for displaying playlist title and current status. - Added PlaylistStatsCard to show statistics about the playlist. - Refactored PlaylistEditPage to utilize new components, enhancing readability and maintainability. - Introduced loading and error states with PlaylistEditLoading and PlaylistEditError components. - Updated SortableTableRow and SimpleSortableRow for better drag-and-drop handling.
This commit is contained in:
56
src/components/playlists/playlist-edit/AvailableSound.tsx
Normal file
56
src/components/playlists/playlist-edit/AvailableSound.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { Sound } from '@/lib/api/services/sounds'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { Music, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AvailableSoundProps {
|
||||||
|
sound: Sound
|
||||||
|
onAddToPlaylist: (soundId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvailableSound({ sound, onAddToPlaylist }: 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 group"
|
||||||
|
{...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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onAddToPlaylist(sound.id)
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 text-primary hover:text-primary flex-shrink-0"
|
||||||
|
title="Add to playlist"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { PlaylistSound } from '@/lib/api/services/playlists'
|
||||||
|
import type { Sound } from '@/lib/api/services/sounds'
|
||||||
|
import { useDroppable } from '@dnd-kit/core'
|
||||||
|
import { Music } from 'lucide-react'
|
||||||
|
|
||||||
|
// Simple drop area for the end of the playlist
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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">
|
||||||
|
<span className="text-sm font-mono text-primary min-w-[1.5rem] text-center flex-shrink-0">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag overlay component that shows the dragged item
|
||||||
|
interface DragOverlayContentProps {
|
||||||
|
sound: Sound | PlaylistSound
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DragOverlayContent({ sound, position }: DragOverlayContentProps) {
|
||||||
|
// If position is provided, show as current playlist style
|
||||||
|
if (position !== undefined) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background shadow-lg">
|
||||||
|
<span className="text-sm font-mono text-muted-foreground min-w-[1.5rem] text-center flex-shrink-0">
|
||||||
|
{position + 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default available sound style
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background shadow-lg">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
src/components/playlists/playlist-edit/PlaylistDetailsCard.tsx
Normal file
147
src/components/playlists/playlist-edit/PlaylistDetailsCard.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import type { Playlist } from '@/lib/api/services/playlists'
|
||||||
|
import { Edit, Music, Save, X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PlaylistDetailsCardProps {
|
||||||
|
playlist: Playlist
|
||||||
|
isEditMode: boolean
|
||||||
|
formData: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
genre: string
|
||||||
|
}
|
||||||
|
hasChanges: boolean
|
||||||
|
saving: boolean
|
||||||
|
onInputChange: (field: string, value: string) => void
|
||||||
|
onSave: () => void
|
||||||
|
onCancelEdit: () => void
|
||||||
|
onStartEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistDetailsCard({
|
||||||
|
playlist,
|
||||||
|
isEditMode,
|
||||||
|
formData,
|
||||||
|
hasChanges,
|
||||||
|
saving,
|
||||||
|
onInputChange,
|
||||||
|
onSave,
|
||||||
|
onCancelEdit,
|
||||||
|
onStartEdit,
|
||||||
|
}: PlaylistDetailsCardProps) {
|
||||||
|
return (
|
||||||
|
<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 => onInputChange('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 => onInputChange('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 => onInputChange('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={onCancelEdit}>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { Playlist } from '@/lib/api/services/playlists'
|
||||||
|
|
||||||
|
interface PlaylistEditHeaderProps {
|
||||||
|
playlist: Playlist
|
||||||
|
isEditMode: boolean
|
||||||
|
onSetCurrent: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistEditHeader({
|
||||||
|
playlist,
|
||||||
|
isEditMode,
|
||||||
|
onSetCurrent,
|
||||||
|
}: PlaylistEditHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<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={onSetCurrent}>
|
||||||
|
Set as Current
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{playlist.is_current && (
|
||||||
|
<Badge variant="default">Current Playlist</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PlaylistEditLoadingProps {
|
||||||
|
playlistName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistEditLoading({ playlistName }: PlaylistEditLoadingProps) {
|
||||||
|
return (
|
||||||
|
<AppLayout
|
||||||
|
breadcrumb={{
|
||||||
|
items: [
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Playlists', href: '/playlists' },
|
||||||
|
{ label: playlistName || '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistEditErrorProps {
|
||||||
|
error: string
|
||||||
|
onBackToPlaylists: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistEditError({ error, onBackToPlaylists }: PlaylistEditErrorProps) {
|
||||||
|
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={onBackToPlaylists}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Back to playlists
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/components/playlists/playlist-edit/PlaylistStatsCard.tsx
Normal file
74
src/components/playlists/playlist-edit/PlaylistStatsCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import type { Playlist, PlaylistSound } from '@/lib/api/services/playlists'
|
||||||
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
|
import { Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PlaylistStatsCardProps {
|
||||||
|
playlist: Playlist
|
||||||
|
sounds: PlaylistSound[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistStatsCard({ playlist, sounds }: PlaylistStatsCardProps) {
|
||||||
|
const totalDuration = sounds.reduce(
|
||||||
|
(total, sound) => total + (sound.duration || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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(totalDuration)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/components/playlists/playlist-edit/SimpleSortableRow.tsx
Normal file
65
src/components/playlists/playlist-edit/SimpleSortableRow.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { PlaylistSound } from '@/lib/api/services/playlists'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { Music, X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SimpleSortableRowProps {
|
||||||
|
sound: PlaylistSound
|
||||||
|
index: number
|
||||||
|
onRemoveSound: (soundId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 gap-3 p-3 border rounded-lg hover:bg-muted/50 cursor-grab active:cursor-grabbing group"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-mono text-muted-foreground min-w-[1.5rem] text-center flex-shrink-0">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemoveSound(sound.id)
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 text-destructive hover:text-destructive flex-shrink-0"
|
||||||
|
title="Remove from playlist"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/components/playlists/playlist-edit/SortableTableRow.tsx
Normal file
113
src/components/playlists/playlist-edit/SortableTableRow.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { TableCell, TableRow } from '@/components/ui/table'
|
||||||
|
import type { PlaylistSound } from '@/lib/api/services/playlists'
|
||||||
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { ChevronDown, ChevronUp, Music, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SortableTableRowProps {
|
||||||
|
sound: PlaylistSound
|
||||||
|
index: number
|
||||||
|
onMoveSoundUp: (index: number) => void
|
||||||
|
onMoveSoundDown: (index: number) => void
|
||||||
|
onRemoveSound: (soundId: number) => void
|
||||||
|
totalSounds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { AvailableSound } from '@/components/playlists/playlist-edit/AvailableSound'
|
||||||
|
import {
|
||||||
|
DragOverlayContent,
|
||||||
|
EndDropArea,
|
||||||
|
InlinePreview,
|
||||||
|
} from '@/components/playlists/playlist-edit/DragOverlayComponents'
|
||||||
|
import { PlaylistDetailsCard } from '@/components/playlists/playlist-edit/PlaylistDetailsCard'
|
||||||
|
import {
|
||||||
|
PlaylistEditError,
|
||||||
|
PlaylistEditLoading,
|
||||||
|
} from '@/components/playlists/playlist-edit/PlaylistEditLoadingStates'
|
||||||
|
import { PlaylistEditHeader } from '@/components/playlists/playlist-edit/PlaylistEditHeader'
|
||||||
|
import { PlaylistStatsCard } from '@/components/playlists/playlist-edit/PlaylistStatsCard'
|
||||||
|
import { SimpleSortableRow } from '@/components/playlists/playlist-edit/SimpleSortableRow'
|
||||||
|
import { SortableTableRow } from '@/components/playlists/playlist-edit/SortableTableRow'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import {
|
import {
|
||||||
type Playlist,
|
type Playlist,
|
||||||
type PlaylistSound,
|
type PlaylistSound,
|
||||||
playlistsService,
|
playlistsService,
|
||||||
} from '@/lib/api/services/playlists'
|
} from '@/lib/api/services/playlists'
|
||||||
import { type Sound, soundsService } from '@/lib/api/services/sounds'
|
import { type Sound, soundsService } from '@/lib/api/services/sounds'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
@@ -32,322 +41,15 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import { useDroppable } from '@dnd-kit/core'
|
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { Minus, Music, Plus, RefreshCw } from 'lucide-react'
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Clock,
|
|
||||||
Edit,
|
|
||||||
Minus,
|
|
||||||
Music,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Save,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
// 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 gap-3 p-3 border rounded-lg hover:bg-muted/50 cursor-grab active:cursor-grabbing group"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-mono text-muted-foreground min-w-[1.5rem] text-center flex-shrink-0">
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onRemoveSound(sound.id)
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 p-0 text-destructive hover:text-destructive flex-shrink-0"
|
|
||||||
title="Remove from playlist"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available sound component for dragging
|
|
||||||
interface AvailableSoundProps {
|
|
||||||
sound: Sound
|
|
||||||
onAddToPlaylist: (soundId: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvailableSound({ sound, onAddToPlaylist }: 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 group"
|
|
||||||
{...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>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onAddToPlaylist(sound.id)
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 p-0 text-primary hover:text-primary flex-shrink-0"
|
|
||||||
title="Add to playlist"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</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">
|
|
||||||
<span className="text-sm font-mono text-primary min-w-[1.5rem] text-center flex-shrink-0">
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag overlay component that shows the dragged item
|
|
||||||
interface DragOverlayContentProps {
|
|
||||||
sound: Sound | PlaylistSound
|
|
||||||
position?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function DragOverlayContent({ sound, position }: DragOverlayContentProps) {
|
|
||||||
// If position is provided, show as current playlist style
|
|
||||||
if (position !== undefined) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background shadow-lg">
|
|
||||||
<span className="text-sm font-mono text-muted-foreground min-w-[1.5rem] text-center flex-shrink-0">
|
|
||||||
{position + 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default available sound style
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background shadow-lg">
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaylistEditPage() {
|
export function PlaylistEditPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -827,55 +529,11 @@ export function PlaylistEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <PlaylistEditLoading playlistName={playlist?.name} />
|
||||||
<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) {
|
if (error || !playlist) {
|
||||||
return (
|
return <PlaylistEditError error={error || 'Playlist not found'} onBackToPlaylists={() => navigate('/playlists')} />
|
||||||
<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 (
|
return (
|
||||||
@@ -896,207 +554,26 @@ export function PlaylistEditPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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">
|
<PlaylistEditHeader
|
||||||
<div className="flex items-center gap-4">
|
playlist={playlist}
|
||||||
<div>
|
isEditMode={isEditMode}
|
||||||
<h1 className="text-2xl font-bold">{playlist.name}</h1>
|
onSetCurrent={handleSetCurrent}
|
||||||
<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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Playlist Details */}
|
<PlaylistDetailsCard
|
||||||
<Card>
|
playlist={playlist}
|
||||||
<CardHeader>
|
isEditMode={isEditMode}
|
||||||
<CardTitle className="flex items-center gap-2">
|
formData={formData}
|
||||||
<Music className="h-5 w-5" />
|
hasChanges={hasChanges}
|
||||||
Playlist Details
|
saving={saving}
|
||||||
</CardTitle>
|
onInputChange={handleInputChange}
|
||||||
</CardHeader>
|
onSave={handleSave}
|
||||||
<CardContent className="space-y-4">
|
onCancelEdit={handleCancelEdit}
|
||||||
{isEditMode ? (
|
onStartEdit={() => setIsEditMode(true)}
|
||||||
<>
|
/>
|
||||||
<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">
|
<PlaylistStatsCard playlist={playlist} sounds={sounds} />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Playlist Sounds */}
|
{/* Playlist Sounds */}
|
||||||
@@ -1254,9 +731,7 @@ export function PlaylistEditPage() {
|
|||||||
<AvailableSound
|
<AvailableSound
|
||||||
key={sound.id}
|
key={sound.id}
|
||||||
sound={sound}
|
sound={sound}
|
||||||
onAddToPlaylist={soundId =>
|
onAddToPlaylist={handleAddSoundToPlaylist}
|
||||||
handleAddSoundToPlaylist(soundId)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user