Refactor and enhance UI components across multiple pages
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped

- 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:
JSC
2025-08-14 23:51:47 +02:00
parent 8358aa16aa
commit 4e50e7e79d
53 changed files with 2477 additions and 1520 deletions

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -15,10 +15,8 @@ export function NavGroup({ label, children }: NavGroupProps) {
<SidebarGroup>
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{children}
</SidebarMenu>
<SidebarMenu>{children}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}