Refactor and enhance UI components across multiple pages
- Improved import organization and formatting in PlaylistsPage, RegisterPage, SoundsPage, SettingsPage, and UsersPage for better readability. - Added error handling and user feedback with toast notifications in SoundsPage and SettingsPage. - Enhanced user experience by implementing debounced search functionality in PlaylistsPage and SoundsPage. - Updated the layout and structure of forms in SettingsPage and UsersPage for better usability. - Improved accessibility and semantics by ensuring proper labeling and descriptions in forms. - Fixed minor bugs related to state management and API calls in various components.
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -10,6 +6,14 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useState } from 'react'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { Player, type PlayerDisplayMode } from './player/Player'
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -23,14 +27,21 @@ interface AppLayoutProps {
|
||||
}
|
||||
|
||||
export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
|
||||
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(
|
||||
() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(
|
||||
'playerDisplayMode',
|
||||
) as PlayerDisplayMode
|
||||
return saved &&
|
||||
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
||||
? saved
|
||||
: 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
},
|
||||
)
|
||||
|
||||
// Note: localStorage is managed by the Player component
|
||||
|
||||
@@ -66,13 +77,9 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||
</SidebarInset>
|
||||
<Player
|
||||
onPlayerModeChange={setPlayerDisplayMode}
|
||||
/>
|
||||
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Home,
|
||||
Music,
|
||||
Users,
|
||||
Settings,
|
||||
Download,
|
||||
PlayCircle
|
||||
} from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -13,13 +6,20 @@ import {
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
Download,
|
||||
Home,
|
||||
Music,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { CreditsNav } from './nav/CreditsNav'
|
||||
import { NavGroup } from './nav/NavGroup'
|
||||
import { NavItem } from './nav/NavItem'
|
||||
import { UserNav } from './nav/UserNav'
|
||||
import { CreditsNav } from './nav/CreditsNav'
|
||||
import { CompactPlayer } from './player/CompactPlayer'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface AppSidebarProps {
|
||||
showCompactPlayer?: boolean
|
||||
@@ -35,7 +35,9 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
<Music className="h-6 w-6" />
|
||||
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">SDB v2</span>
|
||||
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">
|
||||
SDB v2
|
||||
</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -47,7 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
||||
</NavGroup>
|
||||
|
||||
{user.role === "admin" && (
|
||||
{user.role === 'admin' && (
|
||||
<NavGroup label="Admin">
|
||||
<NavItem href="/admin/users" icon={Users} title="Users" />
|
||||
<NavItem href="/admin/settings" icon={Settings} title="Settings" />
|
||||
@@ -73,4 +75,4 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@ export function SocketBadge() {
|
||||
const { isConnected, isReconnecting } = useSocket()
|
||||
|
||||
if (isReconnecting) {
|
||||
return <Badge variant="secondary" className="text-xs">Reconnecting</Badge>
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Reconnecting
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={isConnected ? 'default' : 'destructive'} className="text-xs">
|
||||
<Badge
|
||||
variant={isConnected ? 'default' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Theme, ThemeProviderContext } from '@/contexts/ThemeContext'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ThemeProviderContext, type Theme } from '@/contexts/ThemeContext'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useState } from 'react'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
|
||||
export function LoginForm() {
|
||||
const { login } = useAuth()
|
||||
@@ -44,7 +50,9 @@ export function LoginForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">Sign in</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Sign in
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your email and password to sign in to your account
|
||||
</CardDescription>
|
||||
@@ -63,7 +71,7 @@ export function LoginForm() {
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
@@ -83,11 +91,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -96,4 +100,4 @@ export function LoginForm() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { api } from '@/lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function OAuthButtons() {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
@@ -24,10 +24,10 @@ export function OAuthButtons() {
|
||||
setLoading(provider)
|
||||
try {
|
||||
const response = await api.auth.getOAuthUrl(provider)
|
||||
|
||||
|
||||
// Store state in sessionStorage for verification
|
||||
sessionStorage.setItem('oauth_state', response.state)
|
||||
|
||||
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = response.authorization_url
|
||||
} catch (error) {
|
||||
@@ -90,9 +90,9 @@ export function OAuthButtons() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{providers.map((provider) => (
|
||||
{providers.map(provider => (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
@@ -107,14 +107,13 @@ export function OAuthButtons() {
|
||||
getProviderIcon(provider)
|
||||
)}
|
||||
<span className="ml-2">
|
||||
{loading === provider
|
||||
? 'Connecting...'
|
||||
: `Continue with ${getProviderName(provider)}`
|
||||
}
|
||||
{loading === provider
|
||||
? 'Connecting...'
|
||||
: `Continue with ${getProviderName(provider)}`}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useState } from 'react'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { register } = useAuth()
|
||||
@@ -62,7 +68,9 @@ export function RegisterForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">Create account</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Create account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your information to create your account
|
||||
</CardDescription>
|
||||
@@ -94,7 +102,7 @@ export function RegisterForm() {
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
@@ -128,11 +136,7 @@ export function RegisterForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -141,4 +145,4 @@ export function RegisterForm() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CircleDollarSign } from 'lucide-react'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
|
||||
import { userEvents, USER_EVENTS } from '@/lib/events'
|
||||
import { USER_EVENTS, userEvents } from '@/lib/events'
|
||||
import type { User } from '@/types/auth'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { CircleDollarSign } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
|
||||
|
||||
interface CreditsNavProps {
|
||||
user: User
|
||||
@@ -30,7 +30,7 @@ export function CreditsNav({ user }: CreditsNavProps) {
|
||||
}, [user.credits])
|
||||
|
||||
const tooltipText = `Credits: ${credits} / ${user.plan.max_credits}`
|
||||
|
||||
|
||||
// Determine icon color based on credit levels
|
||||
const getIconColor = () => {
|
||||
if (credits === 0) return 'text-red-500'
|
||||
@@ -41,16 +41,21 @@ export function CreditsNav({ user }: CreditsNavProps) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" className="cursor-default group-data-[collapsible=icon]:justify-center" tooltip={tooltipText}>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="cursor-default group-data-[collapsible=icon]:justify-center"
|
||||
tooltip={tooltipText}
|
||||
>
|
||||
<CircleDollarSign className={`h-5 w-5 ${getIconColor()}`} />
|
||||
<div className="flex flex-1 items-center justify-between text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold">Credits:</span>
|
||||
<span className="text-muted-foreground">
|
||||
<NumberFlow value={credits} /> / <NumberFlow value={user.plan.max_credits} />
|
||||
<NumberFlow value={credits} /> /{' '}
|
||||
<NumberFlow value={user.plan.max_credits} />
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,8 @@ export function NavGroup({ label, children }: NavGroupProps) {
|
||||
<SidebarGroup>
|
||||
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{children}
|
||||
</SidebarMenu>
|
||||
<SidebarMenu>{children}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
@@ -31,4 +28,4 @@ export function NavItem({ href, icon: Icon, title, badge }: NavItemProps) {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '../ui/sidebar'
|
||||
import type { User } from '@/types/auth'
|
||||
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '../ui/sidebar'
|
||||
|
||||
interface UserNavProps {
|
||||
user: User
|
||||
@@ -78,4 +83,4 @@ export function UserNav({ user, logout }: UserNavProps) {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Music,
|
||||
Maximize2
|
||||
} from 'lucide-react'
|
||||
import { playerService, type PlayerState, type MessageResponse } from '@/lib/api/services/player'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerState,
|
||||
playerService,
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
Maximize2,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface CompactPlayerProps {
|
||||
className?: string
|
||||
@@ -28,7 +32,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
mode: 'continuous',
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0
|
||||
position: 0,
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -58,18 +62,23 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
const executeAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<void | MessageResponse>,
|
||||
actionName: string,
|
||||
) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state.status === 'playing') {
|
||||
@@ -103,7 +112,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Collapsed state - only play/pause button */}
|
||||
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
|
||||
<Button
|
||||
@@ -128,11 +137,11 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
@@ -141,11 +150,11 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
<Music
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground",
|
||||
state.current_sound?.thumbnail ? "hidden" : "block"
|
||||
)}
|
||||
'h-4 w-4 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -160,7 +169,9 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const expandFn = (window as unknown as { __expandPlayerFromSidebar?: () => void }).__expandPlayerFromSidebar
|
||||
const expandFn = (
|
||||
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
).__expandPlayerFromSidebar
|
||||
if (expandFn) expandFn()
|
||||
}}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
@@ -194,7 +205,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -238,4 +249,4 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,47 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerMode,
|
||||
type PlayerState,
|
||||
playerService,
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowRightToLine,
|
||||
Download,
|
||||
ExternalLink,
|
||||
List,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreVertical,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
List,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Music,
|
||||
ExternalLink,
|
||||
Download,
|
||||
MoreVertical,
|
||||
ArrowRight,
|
||||
ArrowRightToLine
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { playerService, type PlayerState, type PlayerMode, type MessageResponse } from '@/lib/api/services/player'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Playlist } from './Playlist'
|
||||
|
||||
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
|
||||
@@ -47,17 +57,22 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
mode: 'continuous',
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0
|
||||
position: 0,
|
||||
})
|
||||
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
|
||||
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
|
||||
const saved = localStorage.getItem(
|
||||
'playerDisplayMode',
|
||||
) as PlayerDisplayMode
|
||||
return saved &&
|
||||
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
||||
? saved
|
||||
: 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
|
||||
// Notify parent when display mode changes and save to localStorage
|
||||
useEffect(() => {
|
||||
onPlayerModeChange?.(displayMode)
|
||||
@@ -111,17 +126,23 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}
|
||||
}, [displayMode])
|
||||
|
||||
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
const executeAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<void | MessageResponse>,
|
||||
actionName: string,
|
||||
) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state.status === 'playing') {
|
||||
@@ -143,15 +164,21 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
executeAction(playerService.next, 'go to next track')
|
||||
}, [executeAction])
|
||||
|
||||
const handleSeek = useCallback((position: number[]) => {
|
||||
const newPosition = position[0]
|
||||
executeAction(() => playerService.seek(newPosition), 'seek')
|
||||
}, [executeAction])
|
||||
const handleSeek = useCallback(
|
||||
(position: number[]) => {
|
||||
const newPosition = position[0]
|
||||
executeAction(() => playerService.seek(newPosition), 'seek')
|
||||
},
|
||||
[executeAction],
|
||||
)
|
||||
|
||||
const handleVolumeChange = useCallback((volume: number[]) => {
|
||||
const newVolume = volume[0]
|
||||
executeAction(() => playerService.setVolume(newVolume), 'change volume')
|
||||
}, [executeAction])
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number[]) => {
|
||||
const newVolume = volume[0]
|
||||
executeAction(() => playerService.setVolume(newVolume), 'change volume')
|
||||
},
|
||||
[executeAction],
|
||||
)
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (state.volume === 0) {
|
||||
@@ -164,7 +191,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}, [state.volume, executeAction])
|
||||
|
||||
const handleModeChange = useCallback(() => {
|
||||
const modes: PlayerMode[] = ['continuous', 'loop', 'loop_one', 'random', 'single']
|
||||
const modes: PlayerMode[] = [
|
||||
'continuous',
|
||||
'loop',
|
||||
'loop_one',
|
||||
'random',
|
||||
'single',
|
||||
]
|
||||
const currentIndex = modes.indexOf(state.mode)
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length]
|
||||
executeAction(() => playerService.setMode(nextMode), 'change mode')
|
||||
@@ -172,7 +205,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
|
||||
const handleDownloadSound = useCallback(async () => {
|
||||
if (!state.current_sound) return
|
||||
|
||||
|
||||
try {
|
||||
await filesService.downloadSound(state.current_sound.id)
|
||||
toast.success('Download started')
|
||||
@@ -185,7 +218,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
const getModeIcon = () => {
|
||||
switch (state.mode) {
|
||||
case 'continuous':
|
||||
return <ArrowRight className='h-4 w-4' />
|
||||
return <ArrowRight className="h-4 w-4" />
|
||||
case 'loop':
|
||||
return <Repeat className="h-4 w-4" />
|
||||
case 'loop_one':
|
||||
@@ -300,11 +333,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<div className="mb-4">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
@@ -312,11 +345,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
/>
|
||||
<Music
|
||||
<Music
|
||||
className={cn(
|
||||
"h-8 w-8 text-muted-foreground",
|
||||
state.current_sound?.thumbnail ? "hidden" : "block"
|
||||
)}
|
||||
'h-8 w-8 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -328,38 +361,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<h3 className="font-medium text-sm truncate">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</h3>
|
||||
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
{state.current_sound &&
|
||||
(state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,7 +401,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
@@ -474,10 +507,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Playlist */}
|
||||
{showPlaylist && state.playlist && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<Playlist
|
||||
<Playlist
|
||||
playlist={state.playlist}
|
||||
currentIndex={state.index}
|
||||
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
|
||||
onTrackSelect={index =>
|
||||
executeAction(
|
||||
() => playerService.playAtIndex(index),
|
||||
'play track',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,11 +544,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Large Album Art */}
|
||||
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
@@ -519,11 +557,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
<Music
|
||||
className={cn(
|
||||
"h-32 w-32 text-muted-foreground",
|
||||
state.current_sound?.thumbnail ? "hidden" : "block"
|
||||
)}
|
||||
'h-32 w-32 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -533,38 +571,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<h1 className="text-2xl font-bold">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</h1>
|
||||
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
{state.current_sound &&
|
||||
(state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -573,11 +611,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-3 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
const newPosition = Math.round(percentage * (state.duration || 0))
|
||||
const newPosition = Math.round(
|
||||
percentage * (state.duration || 0),
|
||||
)
|
||||
handleSeek([newPosition])
|
||||
}}
|
||||
/>
|
||||
@@ -630,24 +670,14 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleModeChange}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={handleModeChange}>
|
||||
{getModeIcon()}
|
||||
</Button>
|
||||
<Badge variant="secondary">
|
||||
{state.mode.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleMute}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={handleMute}>
|
||||
{state.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
@@ -680,10 +710,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Playlist
|
||||
<Playlist
|
||||
playlist={state.playlist}
|
||||
currentIndex={state.index}
|
||||
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
|
||||
onTrackSelect={index =>
|
||||
executeAction(
|
||||
() => playerService.playAtIndex(index),
|
||||
'play track',
|
||||
)
|
||||
}
|
||||
variant="maximized"
|
||||
/>
|
||||
</div>
|
||||
@@ -696,7 +731,9 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
// Expose expand function for external use
|
||||
useEffect(() => {
|
||||
// Store expand function globally so sidebar can access it
|
||||
const windowWithExpand = window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
const windowWithExpand = window as unknown as {
|
||||
__expandPlayerFromSidebar?: () => void
|
||||
}
|
||||
windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar
|
||||
return () => {
|
||||
delete windowWithExpand.__expandPlayerFromSidebar
|
||||
@@ -712,4 +749,4 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{displayMode === 'maximized' && renderMaximizedPlayer()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Music, Play } from 'lucide-react'
|
||||
import { type PlayerPlaylist } from '@/lib/api/services/player'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { type PlayerPlaylist } from '@/lib/api/services/player'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Music, Play } from 'lucide-react'
|
||||
|
||||
interface PlaylistProps {
|
||||
playlist: PlayerPlaylist
|
||||
@@ -13,33 +13,33 @@ interface PlaylistProps {
|
||||
variant?: 'normal' | 'maximized'
|
||||
}
|
||||
|
||||
export function Playlist({
|
||||
playlist,
|
||||
currentIndex,
|
||||
export function Playlist({
|
||||
playlist,
|
||||
currentIndex,
|
||||
onTrackSelect,
|
||||
variant = 'normal'
|
||||
variant = 'normal',
|
||||
}: PlaylistProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-sm truncate">
|
||||
{playlist.name}
|
||||
</h4>
|
||||
<h4 className="font-medium text-sm truncate">{playlist.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{playlist.sounds.length} tracks
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Track List */}
|
||||
<ScrollArea className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}>
|
||||
<ScrollArea
|
||||
className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}
|
||||
>
|
||||
<div className="w-full">
|
||||
{playlist.sounds.map((sound, index) => (
|
||||
<div
|
||||
key={sound.id}
|
||||
className={cn(
|
||||
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
|
||||
currentIndex === index && 'bg-primary/10 text-primary'
|
||||
currentIndex === index && 'bg-primary/10 text-primary',
|
||||
)}
|
||||
onClick={() => onTrackSelect(index)}
|
||||
>
|
||||
@@ -51,16 +51,18 @@ export function Playlist({
|
||||
<span className="text-muted-foreground">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Thumbnail - 1 column */}
|
||||
<div className="col-span-1">
|
||||
<div className={cn(
|
||||
'bg-muted rounded flex items-center justify-center overflow-hidden',
|
||||
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded flex items-center justify-center overflow-hidden',
|
||||
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5',
|
||||
)}
|
||||
>
|
||||
{sound.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(sound.id)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -69,18 +71,20 @@ export function Playlist({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Track name - 6 columns (takes most space) */}
|
||||
<div className="col-span-6">
|
||||
<span className={cn(
|
||||
'font-medium truncate block',
|
||||
variant === 'maximized' ? 'text-sm' : 'text-xs',
|
||||
currentIndex === index ? 'text-primary' : 'text-foreground'
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium truncate block',
|
||||
variant === 'maximized' ? 'text-sm' : 'text-xs',
|
||||
currentIndex === index ? 'text-primary' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{sound.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Duration - 2 columns */}
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
@@ -101,4 +105,4 @@ export function Playlist({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Play, Clock, Weight } from 'lucide-react'
|
||||
import { type Sound } from '@/lib/api/services/sounds'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { formatSize } from '@/utils/format-size'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Clock, Play, Weight } from 'lucide-react'
|
||||
|
||||
interface SoundCardProps {
|
||||
sound: Sound
|
||||
@@ -44,4 +44,4 @@ export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user