From 4e50e7e79dfc56ef6c75424284cb4e6c0d6c2b37 Mon Sep 17 00:00:00 2001 From: JSC Date: Thu, 14 Aug 2025 23:51:47 +0200 Subject: [PATCH] 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. --- components.json | 2 +- src/App.tsx | 160 ++-- src/components/AppLayout.tsx | 45 +- src/components/AppSidebar.tsx | 30 +- src/components/SocketBadge.tsx | 13 +- src/components/ThemeProvider.tsx | 2 +- src/components/auth/LoginForm.tsx | 28 +- src/components/auth/OAuthButtons.tsx | 19 +- src/components/auth/RegisterForm.tsx | 28 +- src/components/nav/CreditsNav.tsx | 23 +- src/components/nav/NavGroup.tsx | 6 +- src/components/nav/NavItem.tsx | 7 +- src/components/nav/UserNav.tsx | 13 +- src/components/player/CompactPlayer.tsx | 87 +- src/components/player/Player.tsx | 341 ++++--- src/components/player/Playlist.tsx | 60 +- src/components/sounds/SoundCard.tsx | 4 +- src/contexts/AuthContext.tsx | 19 +- src/contexts/SocketContext.tsx | 50 +- src/contexts/ThemeContext.tsx | 5 +- src/hooks/use-mobile.ts | 6 +- src/hooks/use-theme.ts | 4 +- src/index.css | 6 +- src/lib/api.ts | 2 +- src/lib/api/client.ts | 75 +- src/lib/api/config.ts | 7 +- src/lib/api/errors.ts | 31 +- src/lib/api/index.ts | 10 +- src/lib/api/services/admin.ts | 41 +- src/lib/api/services/auth.ts | 79 +- src/lib/api/services/extractions.ts | 14 +- src/lib/api/services/files.ts | 32 +- src/lib/api/services/index.ts | 2 +- src/lib/api/services/player.ts | 9 +- src/lib/api/services/playlists.ts | 51 +- src/lib/api/services/sounds.ts | 31 +- src/lib/api/types.ts | 21 +- src/lib/events.ts | 2 +- src/lib/token-refresh-manager.ts | 22 +- src/lib/utils.ts | 4 +- src/pages/AccountPage.tsx | 294 ++++-- src/pages/AuthCallbackPage.tsx | 53 +- src/pages/DashboardPage.tsx | 312 +++--- src/pages/ExtractionsPage.tsx | 146 ++- src/pages/LoginPage.tsx | 10 +- src/pages/PlaylistEditPage.tsx | 1145 +++++++++++++---------- src/pages/PlaylistsPage.tsx | 188 ++-- src/pages/RegisterPage.tsx | 10 +- src/pages/SoundsPage.tsx | 106 ++- src/pages/admin/SettingsPage.tsx | 146 ++- src/pages/admin/UsersPage.tsx | 185 +++- src/types/auth.ts | 2 +- src/utils/format-size.ts | 9 +- 53 files changed, 2477 insertions(+), 1520 deletions(-) diff --git a/components.json b/components.json index 73afbdb..13e1db0 100644 --- a/components.json +++ b/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index e837aa2..3f7686b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,100 +1,138 @@ -import { Routes, Route, Navigate } from 'react-router' +import { Navigate, Route, Routes } from 'react-router' import { ThemeProvider } from './components/ThemeProvider' +import { Toaster } from './components/ui/sonner' import { AuthProvider, useAuth } from './contexts/AuthContext' import { SocketProvider } from './contexts/SocketContext' -import { LoginPage } from './pages/LoginPage' -import { RegisterPage } from './pages/RegisterPage' +import { AccountPage } from './pages/AccountPage' import { AuthCallbackPage } from './pages/AuthCallbackPage' import { DashboardPage } from './pages/DashboardPage' -import { SoundsPage } from './pages/SoundsPage' -import { PlaylistsPage } from './pages/PlaylistsPage' -import { PlaylistEditPage } from './pages/PlaylistEditPage' import { ExtractionsPage } from './pages/ExtractionsPage' -import { UsersPage } from './pages/admin/UsersPage' +import { LoginPage } from './pages/LoginPage' +import { PlaylistEditPage } from './pages/PlaylistEditPage' +import { PlaylistsPage } from './pages/PlaylistsPage' +import { RegisterPage } from './pages/RegisterPage' +import { SoundsPage } from './pages/SoundsPage' import { SettingsPage } from './pages/admin/SettingsPage' -import { AccountPage } from './pages/AccountPage' -import { Toaster } from './components/ui/sonner' +import { UsersPage } from './pages/admin/UsersPage' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth() - + if (loading) { - return
Loading...
+ return ( +
+ Loading... +
+ ) } - + if (!user) { return } - + return <>{children} } function AdminRoute({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth() - + if (loading) { - return
Loading...
+ return ( +
+ Loading... +
+ ) } - + if (!user) { return } - + if (user.role !== 'admin') { return } - + return <>{children} } function AppRoutes() { const { user } = useAuth() - + return ( - : } /> - : } /> + : } + /> + : } + /> } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> ) } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 0db5a8b..a1fde25 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -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(() => { - // 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( + () => { + // 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) { )} -
- {children} -
+
{children}
- + ) -} \ No newline at end of file +} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 457d44e..d57abb8 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -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) {
- SDB v2 + + SDB v2 +
@@ -47,7 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) { - {user.role === "admin" && ( + {user.role === 'admin' && ( @@ -73,4 +75,4 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) { ) -} \ No newline at end of file +} diff --git a/src/components/SocketBadge.tsx b/src/components/SocketBadge.tsx index 88dbf95..3c70922 100644 --- a/src/components/SocketBadge.tsx +++ b/src/components/SocketBadge.tsx @@ -5,12 +5,19 @@ export function SocketBadge() { const { isConnected, isReconnecting } = useSocket() if (isReconnecting) { - return Reconnecting + return ( + + Reconnecting + + ) } return ( - + {isConnected ? 'Connected' : 'Disconnected'} ) -} \ No newline at end of file +} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 80717fc..87b16b3 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -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 diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 2b5cc7a..7e28a4e 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -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 ( - Sign in + + Sign in + Enter your email and password to sign in to your account @@ -63,7 +71,7 @@ export function LoginForm() { disabled={loading} /> - +
)} - @@ -96,4 +100,4 @@ export function LoginForm() { ) -} \ No newline at end of file +} diff --git a/src/components/auth/OAuthButtons.tsx b/src/components/auth/OAuthButtons.tsx index ae33ec3..177ceb6 100644 --- a/src/components/auth/OAuthButtons.tsx +++ b/src/components/auth/OAuthButtons.tsx @@ -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([]) @@ -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() {
- +
- {providers.map((provider) => ( + {providers.map(provider => ( ))}
) -} \ No newline at end of file +} diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx index bb966bc..31b59a0 100644 --- a/src/components/auth/RegisterForm.tsx +++ b/src/components/auth/RegisterForm.tsx @@ -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 ( - Create account + + Create account + Enter your information to create your account @@ -94,7 +102,7 @@ export function RegisterForm() { disabled={loading} /> - +
)} - @@ -141,4 +145,4 @@ export function RegisterForm() { ) -} \ No newline at end of file +} diff --git a/src/components/nav/CreditsNav.tsx b/src/components/nav/CreditsNav.tsx index b3b1ee1..73efd1d 100644 --- a/src/components/nav/CreditsNav.tsx +++ b/src/components/nav/CreditsNav.tsx @@ -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 ( - +
Credits: - / + /{' '} +
) -} \ No newline at end of file +} diff --git a/src/components/nav/NavGroup.tsx b/src/components/nav/NavGroup.tsx index 9e8aacd..670e33a 100644 --- a/src/components/nav/NavGroup.tsx +++ b/src/components/nav/NavGroup.tsx @@ -15,10 +15,8 @@ export function NavGroup({ label, children }: NavGroupProps) { {label && {label}} - - {children} - + {children} ) -} \ No newline at end of file +} diff --git a/src/components/nav/NavItem.tsx b/src/components/nav/NavItem.tsx index e87531a..5a2ecb8 100644 --- a/src/components/nav/NavItem.tsx +++ b/src/components/nav/NavItem.tsx @@ -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) { ) -} \ No newline at end of file +} diff --git a/src/components/nav/UserNav.tsx b/src/components/nav/UserNav.tsx index fc3e55c..4dc29d8 100644 --- a/src/components/nav/UserNav.tsx +++ b/src/components/nav/UserNav.tsx @@ -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) { ) -} \ No newline at end of file +} diff --git a/src/components/player/CompactPlayer.tsx b/src/components/player/CompactPlayer.tsx index 56735db..aed9c02 100644 --- a/src/components/player/CompactPlayer.tsx +++ b/src/components/player/CompactPlayer.tsx @@ -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, 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, + 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 ( -
+
{/* Collapsed state - only play/pause button */}
- +
) -} \ No newline at end of file +} diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx index 809915a..62774cd 100644 --- a/src/components/player/Player.tsx +++ b/src/components/player/Player.tsx @@ -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(() => { // 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, 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, + 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 + return case 'loop': return case 'loop_one': @@ -300,11 +333,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{state.current_sound?.thumbnail ? (
- {state.current_sound.name} { + 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' }} /> -
) : null} @@ -328,38 +361,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {

{state.current_sound?.name || 'No track selected'}

- {state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && ( - - - - - - {state.current_sound.extract_url && ( - - - - Source - + {state.current_sound && + (state.current_sound.extract_url || state.current_sound.id) && ( + + + + + + {state.current_sound.extract_url && ( + + + + Source + + + )} + + + File - )} - - - File - - - - )} + + + )}
@@ -368,7 +401,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) { { + 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 && (
- executeAction(() => playerService.playAtIndex(index), 'play track')} + onTrackSelect={index => + executeAction( + () => playerService.playAtIndex(index), + 'play track', + ) + } />
)} @@ -506,11 +544,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) { {/* Large Album Art */}
{state.current_sound?.thumbnail ? ( - {state.current_sound.name} { + 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} -
@@ -533,38 +571,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {

{state.current_sound?.name || 'No track selected'}

- {state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && ( - - - - - - {state.current_sound.extract_url && ( - - - - Source - + {state.current_sound && + (state.current_sound.extract_url || state.current_sound.id) && ( + + + + + + {state.current_sound.extract_url && ( + + + + Source + + + )} + + + File - )} - - - File - - - - )} + + + )}
@@ -573,11 +611,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) { { + 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 */}
- - - {state.mode.replace('_', ' ')} - + {state.mode.replace('_', ' ')}
-
- executeAction(() => playerService.playAtIndex(index), 'play track')} + onTrackSelect={index => + executeAction( + () => playerService.playAtIndex(index), + 'play track', + ) + } variant="maximized" />
@@ -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()}
) -} \ No newline at end of file +} diff --git a/src/components/player/Playlist.tsx b/src/components/player/Playlist.tsx index 373876d..b65b838 100644 --- a/src/components/player/Playlist.tsx +++ b/src/components/player/Playlist.tsx @@ -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 (
{/* Header */}
-

- {playlist.name} -

+

{playlist.name}

{playlist.sounds.length} tracks
- + {/* Track List */} - +
{playlist.sounds.map((sound, index) => (
onTrackSelect(index)} > @@ -51,16 +51,18 @@ export function Playlist({ {index + 1} )}
- + {/* Thumbnail - 1 column */}
-
+
{sound.thumbnail ? ( - @@ -69,18 +71,20 @@ export function Playlist({ )}
- + {/* Track name - 6 columns (takes most space) */}
- + {sound.name}
- + {/* Duration - 2 columns */}
@@ -101,4 +105,4 @@ export function Playlist({
) -} \ No newline at end of file +} diff --git a/src/components/sounds/SoundCard.tsx b/src/components/sounds/SoundCard.tsx index a0bec32..dbf285d 100644 --- a/src/components/sounds/SoundCard.tsx +++ b/src/components/sounds/SoundCard.tsx @@ -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) { ) -} \ No newline at end of file +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 0320407..84978ba 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,8 +1,19 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import { api } from '@/lib/api' -import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth' -import { authEvents, AUTH_EVENTS } from '@/lib/events' +import { AUTH_EVENTS, authEvents } from '@/lib/events' import { tokenRefreshManager } from '@/lib/token-refresh-manager' +import type { + AuthContextType, + LoginRequest, + RegisterRequest, + User, +} from '@/types/auth' +import { + type ReactNode, + createContext, + useContext, + useEffect, + useState, +} from 'react' const AuthContext = createContext(null) @@ -76,4 +87,4 @@ export function AuthProvider({ children }: AuthProviderProps) { } return {children} -} \ No newline at end of file +} diff --git a/src/contexts/SocketContext.tsx b/src/contexts/SocketContext.tsx index fd1017c..41151e4 100644 --- a/src/contexts/SocketContext.tsx +++ b/src/contexts/SocketContext.tsx @@ -1,8 +1,23 @@ -import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' -import { io, Socket } from 'socket.io-client' +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { Socket, io } from 'socket.io-client' import { toast } from 'sonner' +import { + AUTH_EVENTS, + PLAYER_EVENTS, + SOUND_EVENTS, + USER_EVENTS, + authEvents, + playerEvents, + soundEvents, + userEvents, +} from '../lib/events' import { useAuth } from './AuthContext' -import { authEvents, AUTH_EVENTS, soundEvents, SOUND_EVENTS, userEvents, USER_EVENTS, playerEvents, PLAYER_EVENTS } from '../lib/events' interface SocketContextType { socket: Socket | null @@ -28,9 +43,9 @@ export function SocketProvider({ children }: SocketProviderProps) { if (!user) return null // Get socket URL - use relative URL in production with reverse proxy - const socketUrl = import.meta.env.PROD + const socketUrl = import.meta.env.PROD ? '' // Use relative URL in production (same origin as frontend) - : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000') + : import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' const newSocket = io(socketUrl, { withCredentials: true, @@ -50,37 +65,37 @@ export function SocketProvider({ children }: SocketProviderProps) { setIsConnected(false) }) - newSocket.on('connect_error', (error) => { + newSocket.on('connect_error', error => { setConnectionError(`Connection failed: ${error.message}`) setIsConnected(false) setIsReconnecting(false) }) // Listen for message events - newSocket.on('user_message', (data) => { + newSocket.on('user_message', data => { toast.info(`Message from ${data.from_user_name}`, { description: data.message, }) }) - newSocket.on('broadcast_message', (data) => { + newSocket.on('broadcast_message', data => { toast.warning(`Broadcast from ${data.from_user_name}`, { description: data.message, }) }) // Listen for player events and emit them locally - newSocket.on('player_state', (data) => { + newSocket.on('player_state', data => { playerEvents.emit(PLAYER_EVENTS.PLAYER_STATE, data) }) // Listen for sound events and emit them locally - newSocket.on('sound_played', (data) => { + newSocket.on('sound_played', data => { soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data) }) // Listen for user events and emit them locally - newSocket.on('user_credits_changed', (data) => { + newSocket.on('user_credits_changed', data => { userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data) }) @@ -92,10 +107,10 @@ export function SocketProvider({ children }: SocketProviderProps) { if (!user || !socket) return setIsReconnecting(true) - + // Disconnect current socket socket.disconnect() - + // Create new socket with fresh token const newSocket = createSocket() if (newSocket) { @@ -106,13 +121,12 @@ export function SocketProvider({ children }: SocketProviderProps) { // Listen for token refresh events useEffect(() => { authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh) - + return () => { authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh) } }, [handleTokenRefresh]) - // Initial socket connection useEffect(() => { if (loading) return @@ -146,9 +160,7 @@ export function SocketProvider({ children }: SocketProviderProps) { } return ( - - {children} - + {children} ) } @@ -158,4 +170,4 @@ export function useSocket() { throw new Error('useSocket must be used within a SocketProvider') } return context -} \ No newline at end of file +} diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index ef5d063..f60a88a 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -12,5 +12,6 @@ const initialState: ThemeProviderState = { setTheme: () => null, } -export const ThemeProviderContext = createContext(initialState) -export type { Theme, ThemeProviderState } \ No newline at end of file +export const ThemeProviderContext = + createContext(initialState) +export type { Theme, ThemeProviderState } diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index 2b0fe1d..4331d5c 100644 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -1,4 +1,4 @@ -import * as React from "react" +import * as React from 'react' const MOBILE_BREAKPOINT = 768 @@ -10,9 +10,9 @@ export function useIsMobile() { const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) } - mql.addEventListener("change", onChange) + mql.addEventListener('change', onChange) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) + return () => mql.removeEventListener('change', onChange) }, []) return !!isMobile diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 295dbf6..2840671 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -1,5 +1,5 @@ -import { useContext } from 'react' import { ThemeProviderContext } from '@/contexts/ThemeContext' +import { useContext } from 'react' export const useTheme = () => { const context = useContext(ThemeProviderContext) @@ -8,4 +8,4 @@ export const useTheme = () => { throw new Error('useTheme must be used within a ThemeProvider') return context -} \ No newline at end of file +} diff --git a/src/index.css b/src/index.css index 7550e24..dc288e7 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,5 @@ -@import "tailwindcss"; -@import "tw-animate-css"; +@import 'tailwindcss'; +@import 'tw-animate-css'; @custom-variant dark (&:is(.dark *)); @@ -117,4 +117,4 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} diff --git a/src/lib/api.ts b/src/lib/api.ts index d177983..9e19f45 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,4 +2,4 @@ export * from './api/index' // Export the main API object as default -export { default as api } from './api/index' \ No newline at end of file +export { default as api } from './api/index' diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 9bf84f9..b8df3a2 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,7 +1,7 @@ +import { AUTH_EVENTS, authEvents } from '../events' import { API_CONFIG } from './config' -import { createApiError, NetworkError, TimeoutError } from './errors' +import { NetworkError, TimeoutError, createApiError } from './errors' import type { ApiClient, ApiRequestConfig, HttpMethod } from './types' -import { authEvents, AUTH_EVENTS } from '../events' export class BaseApiClient implements ApiClient { private refreshPromise: Promise | null = null @@ -11,9 +11,12 @@ export class BaseApiClient implements ApiClient { this.baseURL = baseURL } - private buildURL(endpoint: string, params?: Record): string { + private buildURL( + endpoint: string, + params?: Record, + ): string { let url: URL - + if (this.baseURL) { // Full base URL provided url = new URL(endpoint, this.baseURL) @@ -21,7 +24,7 @@ export class BaseApiClient implements ApiClient { // Use relative URL (for reverse proxy) url = new URL(endpoint, window.location.origin) } - + if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { @@ -29,7 +32,7 @@ export class BaseApiClient implements ApiClient { } }) } - + return this.baseURL ? url.toString() : url.pathname + url.search } @@ -37,7 +40,7 @@ export class BaseApiClient implements ApiClient { method: HttpMethod, endpoint: string, data?: unknown, - config: ApiRequestConfig = {} + config: ApiRequestConfig = {}, ): Promise { const { params, @@ -84,40 +87,43 @@ export class BaseApiClient implements ApiClient { await this.handleTokenRefresh() // Retry the original request const retryResponse = await fetch(url, requestConfig) - + if (!retryResponse.ok) { const errorData = await this.safeParseJSON(retryResponse) throw createApiError(retryResponse, errorData) } - - return await this.safeParseJSON(retryResponse) as T + + return (await this.safeParseJSON(retryResponse)) as T } catch (refreshError) { this.handleAuthenticationFailure() throw refreshError } } - + const errorData = await this.safeParseJSON(response) throw createApiError(response, errorData) } // Handle empty responses (204 No Content, etc.) - if (response.status === 204 || response.headers.get('content-length') === '0') { + if ( + response.status === 204 || + response.headers.get('content-length') === '0' + ) { return {} as T } - return await this.safeParseJSON(response) as T + return (await this.safeParseJSON(response)) as T } catch (error) { clearTimeout(timeoutId) - + if ((error as Error).name === 'AbortError') { throw new TimeoutError() } - + if (error instanceof TypeError && error.message.includes('fetch')) { throw new NetworkError() } - + throw error } } @@ -138,7 +144,7 @@ export class BaseApiClient implements ApiClient { } this.refreshPromise = this.performTokenRefresh() - + try { await this.refreshPromise } finally { @@ -147,11 +153,14 @@ export class BaseApiClient implements ApiClient { } private async performTokenRefresh(): Promise { - const response = await fetch(`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - }) + const response = await fetch( + `${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }, + ) if (!response.ok) { throw createApiError(response, await this.safeParseJSON(response)) @@ -165,7 +174,7 @@ export class BaseApiClient implements ApiClient { // Only redirect if we're not already on auth pages to prevent infinite loops const currentPath = window.location.pathname const authPaths = ['/login', '/register', '/auth/callback'] - + if (!authPaths.includes(currentPath)) { window.location.href = '/login' } @@ -176,15 +185,27 @@ export class BaseApiClient implements ApiClient { return this.request('GET', endpoint, undefined, config) } - async post(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise { + async post( + endpoint: string, + data?: unknown, + config?: ApiRequestConfig, + ): Promise { return this.request('POST', endpoint, data, config) } - async put(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise { + async put( + endpoint: string, + data?: unknown, + config?: ApiRequestConfig, + ): Promise { return this.request('PUT', endpoint, data, config) } - async patch(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise { + async patch( + endpoint: string, + data?: unknown, + config?: ApiRequestConfig, + ): Promise { return this.request('PATCH', endpoint, data, config) } @@ -203,4 +224,4 @@ export class BaseApiClient implements ApiClient { } // Default API client instance -export const apiClient = new BaseApiClient() \ No newline at end of file +export const apiClient = new BaseApiClient() diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 9982efc..4420392 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -16,7 +16,7 @@ export const API_CONFIG = { BASE_URL: getApiBaseUrl(), TIMEOUT: 30000, // 30 seconds RETRY_ATTEMPTS: 1, - + // API Endpoints ENDPOINTS: { AUTH: { @@ -26,7 +26,8 @@ export const API_CONFIG = { REFRESH: '/api/v1/auth/refresh', ME: '/api/v1/auth/me', PROVIDERS: '/api/v1/auth/providers', - OAUTH_AUTHORIZE: (provider: string) => `/api/v1/auth/${provider}/authorize`, + OAUTH_AUTHORIZE: (provider: string) => + `/api/v1/auth/${provider}/authorize`, OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`, EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token', API_TOKEN: '/api/v1/auth/api-token', @@ -35,4 +36,4 @@ export const API_CONFIG = { }, } as const -export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS \ No newline at end of file +export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index edd0efa..d18bbcd 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -4,7 +4,12 @@ export class ApiError extends Error { public response?: unknown public detail?: string - constructor(message: string, status: number, response?: unknown, detail?: string) { + constructor( + message: string, + status: number, + response?: unknown, + detail?: string, + ) { super(message) this.name = 'ApiError' this.status = status @@ -14,8 +19,16 @@ export class ApiError extends Error { static fromResponse(response: Response, data?: unknown): ApiError { const errorData = data as Record - const message = errorData?.detail as string || errorData?.message as string || `HTTP ${response.status}: ${response.statusText}` - return new ApiError(message, response.status, data, errorData?.detail as string) + const message = + (errorData?.detail as string) || + (errorData?.message as string) || + `HTTP ${response.status}: ${response.statusText}` + return new ApiError( + message, + response.status, + data, + errorData?.detail as string, + ) } } @@ -75,7 +88,10 @@ export class ServerError extends ApiError { export function createApiError(response: Response, data?: unknown): ApiError { const status = response.status const errorData = data as Record - const message = errorData?.detail as string || errorData?.message as string || `HTTP ${status}: ${response.statusText}` + const message = + (errorData?.detail as string) || + (errorData?.message as string) || + `HTTP ${status}: ${response.statusText}` switch (status) { case 401: @@ -85,7 +101,10 @@ export function createApiError(response: Response, data?: unknown): ApiError { case 404: return new NotFoundError(message) case 422: - return new ValidationError(message, errorData?.fields as Record) + return new ValidationError( + message, + errorData?.fields as Record, + ) case 500: case 501: case 502: @@ -95,4 +114,4 @@ export function createApiError(response: Response, data?: unknown): ApiError { default: return new ApiError(message, status, data, errorData?.detail as string) } -} \ No newline at end of file +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 002d8b1..f3d0fac 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,3 +1,7 @@ +// Main API object for convenient access +import { apiClient } from './client' +import { authService } from './services/auth' + // Re-export all API services and utilities export * from './client' export * from './config' @@ -7,14 +11,10 @@ export * from './errors' // Services export * from './services/auth' -// Main API object for convenient access -import { authService } from './services/auth' -import { apiClient } from './client' - export const api = { auth: authService, client: apiClient, } as const // Default export for convenience -export default api \ No newline at end of file +export default api diff --git a/src/lib/api/services/admin.ts b/src/lib/api/services/admin.ts index 27ad961..958ee8e 100644 --- a/src/lib/api/services/admin.ts +++ b/src/lib/api/services/admin.ts @@ -1,5 +1,5 @@ -import { apiClient } from '../client' import type { User } from '@/types/auth' +import { apiClient } from '../client' export interface Plan { id: number @@ -56,7 +56,7 @@ export interface NormalizationResponse { export class AdminService { async listUsers(limit = 100, offset = 0): Promise { return apiClient.get(`/api/v1/admin/users/`, { - params: { limit, offset } + params: { limit, offset }, }) } @@ -69,11 +69,15 @@ export class AdminService { } async disableUser(userId: number): Promise { - return apiClient.post(`/api/v1/admin/users/${userId}/disable`) + return apiClient.post( + `/api/v1/admin/users/${userId}/disable`, + ) } async enableUser(userId: number): Promise { - return apiClient.post(`/api/v1/admin/users/${userId}/enable`) + return apiClient.post( + `/api/v1/admin/users/${userId}/enable`, + ) } async listPlans(): Promise { @@ -85,27 +89,38 @@ export class AdminService { return apiClient.post(`/api/v1/admin/sounds/scan`) } - async normalizeAllSounds(force = false, onePass?: boolean): Promise { + async normalizeAllSounds( + force = false, + onePass?: boolean, + ): Promise { const params = new URLSearchParams() if (force) params.append('force', 'true') if (onePass !== undefined) params.append('one_pass', onePass.toString()) - + const queryString = params.toString() - const url = queryString ? `/api/v1/admin/sounds/normalize/all?${queryString}` : `/api/v1/admin/sounds/normalize/all` - + const url = queryString + ? `/api/v1/admin/sounds/normalize/all?${queryString}` + : `/api/v1/admin/sounds/normalize/all` + return apiClient.post(url) } - async normalizeSoundsByType(soundType: 'SDB' | 'TTS' | 'EXT', force = false, onePass?: boolean): Promise { + async normalizeSoundsByType( + soundType: 'SDB' | 'TTS' | 'EXT', + force = false, + onePass?: boolean, + ): Promise { const params = new URLSearchParams() if (force) params.append('force', 'true') if (onePass !== undefined) params.append('one_pass', onePass.toString()) - + const queryString = params.toString() - const url = queryString ? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}` : `/api/v1/admin/sounds/normalize/type/${soundType}` - + const url = queryString + ? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}` + : `/api/v1/admin/sounds/normalize/type/${soundType}` + return apiClient.post(url) } } -export const adminService = new AdminService() \ No newline at end of file +export const adminService = new AdminService() diff --git a/src/lib/api/services/auth.ts b/src/lib/api/services/auth.ts index cfd1335..61f21c3 100644 --- a/src/lib/api/services/auth.ts +++ b/src/lib/api/services/auth.ts @@ -72,12 +72,15 @@ export class AuthService { */ async login(credentials: LoginRequest): Promise { // Using direct fetch for auth endpoints to avoid circular dependency with token refresh - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - credentials: 'include', - }) + const response = await fetch( + `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + credentials: 'include', + }, + ) if (!response.ok) { const errorData = await response.json().catch(() => null) @@ -91,12 +94,15 @@ export class AuthService { * Register a new user account */ async register(userData: RegisterRequest): Promise { - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(userData), - credentials: 'include', - }) + const response = await fetch( + `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userData), + credentials: 'include', + }, + ) if (!response.ok) { const errorData = await response.json().catch(() => null) @@ -133,7 +139,7 @@ export class AuthService { async getOAuthUrl(provider: string): Promise { return apiClient.get( API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider), - { skipAuth: true } + { skipAuth: true }, ) } @@ -143,21 +149,26 @@ export class AuthService { async getOAuthProviders(): Promise { return apiClient.get( API_CONFIG.ENDPOINTS.AUTH.PROVIDERS, - { skipAuth: true } + { skipAuth: true }, ) } /** * Exchange OAuth temporary code for auth cookies */ - async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise { + async exchangeOAuthToken( + request: ExchangeOAuthTokenRequest, + ): Promise { // Use direct fetch for OAuth token exchange to avoid auth loops and ensure cookies are set - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - credentials: 'include', // Essential for receiving auth cookies - }) + const response = await fetch( + `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + credentials: 'include', // Essential for receiving auth cookies + }, + ) if (!response.ok) { const errorData = await response.json().catch(() => null) @@ -171,10 +182,13 @@ export class AuthService { * Refresh authentication token */ async refreshToken(): Promise { - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, { - method: 'POST', - credentials: 'include', - }) + const response = await fetch( + `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, + { + method: 'POST', + credentials: 'include', + }, + ) if (!response.ok) { throw new Error('Token refresh failed') @@ -198,15 +212,22 @@ export class AuthService { /** * Generate a new API token */ - async generateApiToken(request: ApiTokenRequest = {}): Promise { - return apiClient.post(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN, request) + async generateApiToken( + request: ApiTokenRequest = {}, + ): Promise { + return apiClient.post( + API_CONFIG.ENDPOINTS.AUTH.API_TOKEN, + request, + ) } /** * Get API token status */ async getApiTokenStatus(): Promise { - return apiClient.get(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS) + return apiClient.get( + API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS, + ) } /** @@ -224,4 +245,4 @@ export class AuthService { } } -export const authService = new AuthService() \ No newline at end of file +export const authService = new AuthService() diff --git a/src/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts index 78e127a..a45a66e 100644 --- a/src/lib/api/services/extractions.ts +++ b/src/lib/api/services/extractions.ts @@ -28,7 +28,9 @@ export class ExtractionsService { * Create a new extraction job */ async createExtraction(url: string): Promise { - const response = await apiClient.post(`/api/v1/extractions/?url=${encodeURIComponent(url)}`) + const response = await apiClient.post( + `/api/v1/extractions/?url=${encodeURIComponent(url)}`, + ) return response } @@ -36,7 +38,9 @@ export class ExtractionsService { * Get extraction by ID */ async getExtraction(extractionId: number): Promise { - const response = await apiClient.get(`/api/v1/extractions/${extractionId}`) + const response = await apiClient.get( + `/api/v1/extractions/${extractionId}`, + ) return response } @@ -44,9 +48,11 @@ export class ExtractionsService { * Get user's extractions */ async getUserExtractions(): Promise { - const response = await apiClient.get('/api/v1/extractions/') + const response = await apiClient.get( + '/api/v1/extractions/', + ) return response.extractions } } -export const extractionsService = new ExtractionsService() \ No newline at end of file +export const extractionsService = new ExtractionsService() diff --git a/src/lib/api/services/files.ts b/src/lib/api/services/files.ts index 9e68c17..76e417d 100644 --- a/src/lib/api/services/files.ts +++ b/src/lib/api/services/files.ts @@ -7,10 +7,13 @@ export class FilesService { async downloadSound(soundId: number): Promise { try { // Use fetch directly to handle file download - const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`, { - method: 'GET', - credentials: 'include', - }) + const response = await fetch( + `${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`, + { + method: 'GET', + credentials: 'include', + }, + ) if (!response.ok) { throw new Error(`Download failed: ${response.statusText}`) @@ -19,7 +22,7 @@ export class FilesService { // Get filename from Content-Disposition header or use default const contentDisposition = response.headers.get('Content-Disposition') let filename = `sound_${soundId}.mp3` - + if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename="(.+)"/) if (filenameMatch) { @@ -30,14 +33,14 @@ export class FilesService { // Create blob and download const blob = await response.blob() const url = window.URL.createObjectURL(blob) - + // Create temporary download link const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() - + // Cleanup document.body.removeChild(link) window.URL.revokeObjectURL(url) @@ -59,10 +62,13 @@ export class FilesService { */ async hasThumbnail(soundId: number): Promise { try { - const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`, { - method: 'HEAD', // Only check headers, don't download - credentials: 'include', - }) + const response = await fetch( + `${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`, + { + method: 'HEAD', // Only check headers, don't download + credentials: 'include', + }, + ) return response.ok } catch { return false @@ -73,7 +79,7 @@ export class FilesService { * Preload a thumbnail image */ async preloadThumbnail(soundId: number): Promise { - return new Promise((resolve) => { + return new Promise(resolve => { const img = new Image() img.onload = () => resolve(true) img.onerror = () => resolve(false) @@ -82,4 +88,4 @@ export class FilesService { } } -export const filesService = new FilesService() \ No newline at end of file +export const filesService = new FilesService() diff --git a/src/lib/api/services/index.ts b/src/lib/api/services/index.ts index 0a79720..c2b1d7f 100644 --- a/src/lib/api/services/index.ts +++ b/src/lib/api/services/index.ts @@ -2,4 +2,4 @@ export * from './auth' export * from './sounds' export * from './player' export * from './files' -export * from './extractions' \ No newline at end of file +export * from './extractions' diff --git a/src/lib/api/services/player.ts b/src/lib/api/services/player.ts index 8eb31cd..e8c90e6 100644 --- a/src/lib/api/services/player.ts +++ b/src/lib/api/services/player.ts @@ -1,7 +1,12 @@ import { apiClient } from '../client' export type PlayerStatus = 'playing' | 'paused' | 'stopped' -export type PlayerMode = 'continuous' | 'loop' | 'loop_one' | 'random' | 'single' +export type PlayerMode = + | 'continuous' + | 'loop' + | 'loop_one' + | 'random' + | 'single' export interface PlayerSound { id: number @@ -144,4 +149,4 @@ export class PlayerService { } } -export const playerService = new PlayerService() \ No newline at end of file +export const playerService = new PlayerService() diff --git a/src/lib/api/services/playlists.ts b/src/lib/api/services/playlists.ts index 43a2125..21ee17c 100644 --- a/src/lib/api/services/playlists.ts +++ b/src/lib/api/services/playlists.ts @@ -1,6 +1,12 @@ import { apiClient } from '../client' -export type PlaylistSortField = 'name' | 'genre' | 'created_at' | 'updated_at' | 'sound_count' | 'total_duration' +export type PlaylistSortField = + | 'name' + | 'genre' + | 'created_at' + | 'updated_at' + | 'sound_count' + | 'total_duration' export type SortOrder = 'asc' | 'desc' export interface Playlist { @@ -47,7 +53,7 @@ export class PlaylistsService { */ async getPlaylists(params?: GetPlaylistsParams): Promise { const searchParams = new URLSearchParams() - + // Handle parameters if (params?.search) { searchParams.append('search', params.search) @@ -64,8 +70,10 @@ export class PlaylistsService { if (params?.offset) { searchParams.append('offset', params.offset.toString()) } - - const url = searchParams.toString() ? `/api/v1/playlists/?${searchParams.toString()}` : '/api/v1/playlists/' + + const url = searchParams.toString() + ? `/api/v1/playlists/?${searchParams.toString()}` + : '/api/v1/playlists/' return apiClient.get(url) } @@ -104,11 +112,14 @@ export class PlaylistsService { /** * Update a playlist */ - async updatePlaylist(id: number, data: { - name?: string - description?: string - genre?: string - }): Promise { + async updatePlaylist( + id: number, + data: { + name?: string + description?: string + genre?: string + }, + ): Promise { return apiClient.put(`/api/v1/playlists/${id}`, data) } @@ -154,28 +165,38 @@ export class PlaylistsService { /** * Add sound to playlist */ - async addSoundToPlaylist(playlistId: number, soundId: number, position?: number): Promise { + async addSoundToPlaylist( + playlistId: number, + soundId: number, + position?: number, + ): Promise { await apiClient.post(`/api/v1/playlists/${playlistId}/sounds`, { sound_id: soundId, - position + position, }) } /** * Remove sound from playlist */ - async removeSoundFromPlaylist(playlistId: number, soundId: number): Promise { + async removeSoundFromPlaylist( + playlistId: number, + soundId: number, + ): Promise { await apiClient.delete(`/api/v1/playlists/${playlistId}/sounds/${soundId}`) } /** * Reorder sounds in playlist */ - async reorderPlaylistSounds(playlistId: number, soundPositions: Array<[number, number]>): Promise { + async reorderPlaylistSounds( + playlistId: number, + soundPositions: Array<[number, number]>, + ): Promise { await apiClient.put(`/api/v1/playlists/${playlistId}/sounds/reorder`, { - sound_positions: soundPositions + sound_positions: soundPositions, }) } } -export const playlistsService = new PlaylistsService() \ No newline at end of file +export const playlistsService = new PlaylistsService() diff --git a/src/lib/api/services/sounds.ts b/src/lib/api/services/sounds.ts index 1f0addc..4d590f2 100644 --- a/src/lib/api/services/sounds.ts +++ b/src/lib/api/services/sounds.ts @@ -21,7 +21,15 @@ export interface Sound { updated_at: string } -export type SoundSortField = 'name' | 'filename' | 'duration' | 'size' | 'type' | 'play_count' | 'created_at' | 'updated_at' +export type SoundSortField = + | 'name' + | 'filename' + | 'duration' + | 'size' + | 'type' + | 'play_count' + | 'created_at' + | 'updated_at' export type SortOrder = 'asc' | 'desc' export interface GetSoundsParams { @@ -43,14 +51,14 @@ export class SoundsService { */ async getSounds(params?: GetSoundsParams): Promise { const searchParams = new URLSearchParams() - + // Handle multiple types if (params?.types) { params.types.forEach(type => { searchParams.append('types', type) }) } - + // Handle other parameters if (params?.search) { searchParams.append('search', params.search) @@ -67,8 +75,10 @@ export class SoundsService { if (params?.offset) { searchParams.append('offset', params.offset.toString()) } - - const url = searchParams.toString() ? `/api/v1/sounds/?${searchParams.toString()}` : '/api/v1/sounds/' + + const url = searchParams.toString() + ? `/api/v1/sounds/?${searchParams.toString()}` + : '/api/v1/sounds/' const response = await apiClient.get(url) return response.sounds || [] } @@ -76,14 +86,19 @@ export class SoundsService { /** * Get sounds of a specific type */ - async getSoundsByType(type: string, params?: Omit): Promise { + async getSoundsByType( + type: string, + params?: Omit, + ): Promise { return this.getSounds({ ...params, types: [type] }) } /** * Get SDB type sounds */ - async getSDBSounds(params?: Omit): Promise { + async getSDBSounds( + params?: Omit, + ): Promise { return this.getSoundsByType('SDB', params) } @@ -102,4 +117,4 @@ export class SoundsService { } } -export const soundsService = new SoundsService() \ No newline at end of file +export const soundsService = new SoundsService() diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 28b9adc..956e6d8 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -20,15 +20,26 @@ export interface ApiRequestConfig extends RequestInit { timeout?: number } - // HTTP Methods export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' // Generic API client interface export interface ApiClient { get(endpoint: string, config?: ApiRequestConfig): Promise - post(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise - put(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise - patch(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise + post( + endpoint: string, + data?: unknown, + config?: ApiRequestConfig, + ): Promise + put( + endpoint: string, + data?: unknown, + config?: ApiRequestConfig, + ): Promise + patch( + endpoint: string, + data?: unknown, + config?: ApiRequestConfig, + ): Promise delete(endpoint: string, config?: ApiRequestConfig): Promise -} \ No newline at end of file +} diff --git a/src/lib/events.ts b/src/lib/events.ts index f6509ad..075cec0 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -58,4 +58,4 @@ export const SOUND_EVENTS = { // User event types export const USER_EVENTS = { USER_CREDITS_CHANGED: 'user_credits_changed', -} as const \ No newline at end of file +} as const diff --git a/src/lib/token-refresh-manager.ts b/src/lib/token-refresh-manager.ts index ba011b0..7304760 100644 --- a/src/lib/token-refresh-manager.ts +++ b/src/lib/token-refresh-manager.ts @@ -1,9 +1,8 @@ /** * Token refresh manager for proactive token refresh */ - -import { authEvents, AUTH_EVENTS } from './events' import { api } from './api' +import { AUTH_EVENTS, authEvents } from './events' export class TokenRefreshManager { private refreshTimer: NodeJS.Timeout | null = null @@ -22,10 +21,10 @@ export class TokenRefreshManager { this.isEnabled = true this.scheduleNextRefresh() - + // Listen for visibility changes to handle tab switching document.addEventListener('visibilitychange', this.handleVisibilityChange) - + // Listen for successful auth events to reschedule authEvents.on(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess) authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed) @@ -41,8 +40,11 @@ export class TokenRefreshManager { this.isEnabled = false this.clearRefreshTimer() - - document.removeEventListener('visibilitychange', this.handleVisibilityChange) + + document.removeEventListener( + 'visibilitychange', + this.handleVisibilityChange, + ) authEvents.off(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess) authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed) } @@ -73,10 +75,9 @@ export class TokenRefreshManager { await api.auth.refreshToken() authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED) - + // Schedule next refresh immediately since we just completed one this.scheduleNextRefresh() - } catch { // If refresh fails, try again in 1 minute this.refreshTimer = setTimeout(() => { @@ -87,7 +88,6 @@ export class TokenRefreshManager { } } - /** * Handle tab visibility changes */ @@ -105,7 +105,7 @@ export class TokenRefreshManager { try { // Try to make an API call to see if token is still valid await api.auth.getMe() - + // Token is still valid, reschedule based on remaining time this.scheduleNextRefresh() } catch (error: unknown) { @@ -146,4 +146,4 @@ export class TokenRefreshManager { } // Global token refresh manager instance -export const tokenRefreshManager = new TokenRefreshManager() \ No newline at end of file +export const tokenRefreshManager = new TokenRefreshManager() diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..d32b0fe 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,5 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 925ac0f..5142454 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -1,56 +1,72 @@ -import { useState, useEffect } from 'react' import { AppLayout } from '@/components/AppLayout' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { Badge } from '@/components/ui/badge' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { toast } from 'sonner' -import { - User, - Key, - Shield, - Palette, - Eye, - EyeOff, - Copy, - Trash2, - Github, - Mail, - CheckCircle2 -} from 'lucide-react' import { useAuth } from '@/contexts/AuthContext' import { useTheme } from '@/hooks/use-theme' -import { authService, type ApiTokenStatusResponse, type UserProvider } from '@/lib/api/services/auth' +import { + type ApiTokenStatusResponse, + type UserProvider, + authService, +} from '@/lib/api/services/auth' +import { + CheckCircle2, + Copy, + Eye, + EyeOff, + Github, + Key, + Mail, + Palette, + Shield, + Trash2, + User, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' export function AccountPage() { const { user, setUser } = useAuth() const { theme, setTheme } = useTheme() - + // Profile state const [profileName, setProfileName] = useState('') const [profileSaving, setProfileSaving] = useState(false) - + // Password state const [passwordData, setPasswordData] = useState({ current_password: '', new_password: '', - confirm_password: '' + confirm_password: '', }) const [passwordSaving, setPasswordSaving] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false) - + // API Token state - const [apiTokenStatus, setApiTokenStatus] = useState(null) + const [apiTokenStatus, setApiTokenStatus] = + useState(null) const [apiTokenLoading, setApiTokenLoading] = useState(true) const [generatedToken, setGeneratedToken] = useState('') const [showGeneratedToken, setShowGeneratedToken] = useState(false) const [tokenExpireDays, setTokenExpireDays] = useState('365') - + // Providers state const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) @@ -91,7 +107,9 @@ export function AccountPage() { setProfileSaving(true) try { - const updatedUser = await authService.updateProfile({ name: profileName.trim() }) + const updatedUser = await authService.updateProfile({ + name: profileName.trim(), + }) setUser?.(updatedUser) toast.success('Profile updated successfully') } catch (error) { @@ -104,14 +122,16 @@ export function AccountPage() { const handlePasswordChange = async () => { // Check if user has password authentication from providers - const hasPasswordProvider = providers.some(provider => provider.provider === 'password') - + const hasPasswordProvider = providers.some( + provider => provider.provider === 'password', + ) + // Validate required fields if (hasPasswordProvider && !passwordData.current_password) { toast.error('Current password is required') return } - + if (!passwordData.new_password) { toast.error('New password is required') return @@ -130,12 +150,22 @@ export function AccountPage() { setPasswordSaving(true) try { await authService.changePassword({ - current_password: hasPasswordProvider ? passwordData.current_password : undefined, - new_password: passwordData.new_password + current_password: hasPasswordProvider + ? passwordData.current_password + : undefined, + new_password: passwordData.new_password, }) - setPasswordData({ current_password: '', new_password: '', confirm_password: '' }) - toast.success(hasPasswordProvider ? 'Password changed successfully' : 'Password set successfully') - + setPasswordData({ + current_password: '', + new_password: '', + confirm_password: '', + }) + toast.success( + hasPasswordProvider + ? 'Password changed successfully' + : 'Password set successfully', + ) + // Reload providers since password status might have changed loadProviders() } catch (error) { @@ -148,8 +178,8 @@ export function AccountPage() { const handleGenerateApiToken = async () => { try { - const response = await authService.generateApiToken({ - expires_days: parseInt(tokenExpireDays) + const response = await authService.generateApiToken({ + expires_days: parseInt(tokenExpireDays), }) setGeneratedToken(response.api_token) setShowGeneratedToken(true) @@ -192,12 +222,9 @@ export function AccountPage() { if (!user) { return ( -
@@ -223,12 +250,9 @@ export function AccountPage() { } return ( -
@@ -264,7 +288,7 @@ export function AccountPage() { setProfileName(e.target.value)} + onChange={e => setProfileName(e.target.value)} placeholder="Enter your display name" />
@@ -272,15 +296,34 @@ export function AccountPage() {
-
Role: {user.role}
-
Credits: {user.credits.toLocaleString()}
-
Plan: {user.plan.name}
-
Member since: {new Date(user.created_at).toLocaleDateString()}
+
+ Role:{' '} + + {user.role} + +
+
+ Credits:{' '} + + {user.credits.toLocaleString()} + +
+
+ Plan: {user.plan.name} +
+
+ Member since:{' '} + {new Date(user.created_at).toLocaleDateString()} +
- ) : ( @@ -411,7 +481,8 @@ export function AccountPage() {

💡 Set up password authentication
- You signed up with OAuth and don't have a password yet. Set one now to enable password login. + You signed up with OAuth and don't have a password yet. + Set one now to enable password login.

@@ -422,7 +493,12 @@ export function AccountPage() { id="new-password" type={showNewPassword ? 'text' : 'password'} value={passwordData.new_password} - onChange={(e) => setPasswordData(prev => ({ ...prev, new_password: e.target.value }))} + onChange={e => + setPasswordData(prev => ({ + ...prev, + new_password: e.target.value, + })) + } placeholder="Enter your new password" />
-
- @@ -526,7 +617,8 @@ export function AccountPage() { )}
- API tokens allow external applications to access your account programmatically + API tokens allow external applications to access your account + programmatically
@@ -540,14 +632,18 @@ export function AccountPage() { Authentication Methods

- Available methods to sign in to your account. Use any of these to access your account. + Available methods to sign in to your account. Use any of these to + access your account.

{providersLoading ? (
{Array.from({ length: 2 }).map((_, i) => ( -
+
@@ -556,30 +652,41 @@ export function AccountPage() { ) : (
{/* All Authentication Providers from API */} - {providers.map((provider) => { + {providers.map(provider => { const isOAuth = provider.provider !== 'password' - + return ( -
+
{getProviderIcon(provider.provider)} - {provider.display_name} + + {provider.display_name} + {isOAuth ? 'OAuth' : 'Password Authentication'} {provider.connected_at && ( - Connected {new Date(provider.connected_at).toLocaleDateString()} + Connected{' '} + {new Date( + provider.connected_at, + ).toLocaleDateString()} )}
- + Available
) })} - + {/* API Token Provider */} {apiTokenStatus?.has_token && (
@@ -589,11 +696,17 @@ export function AccountPage() { API Access {apiTokenStatus.expires_at && ( - Expires {new Date(apiTokenStatus.expires_at).toLocaleDateString()} + Expires{' '} + {new Date( + apiTokenStatus.expires_at, + ).toLocaleDateString()} )}
- + Available
@@ -637,11 +750,14 @@ export function AccountPage() {

- ⚠️ Important: This token will only be shown once. - Copy it now and store it securely. + ⚠️ Important: This token will only be shown + once. Copy it now and store it securely.

-
@@ -649,4 +765,4 @@ export function AccountPage() { ) -} \ No newline at end of file +} diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx index 337b933..785df79 100644 --- a/src/pages/AuthCallbackPage.tsx +++ b/src/pages/AuthCallbackPage.tsx @@ -1,12 +1,14 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router' import { useAuth } from '@/contexts/AuthContext' import { api } from '@/lib/api' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' export function AuthCallbackPage() { const navigate = useNavigate() const { setUser } = useAuth() - const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing') + const [status, setStatus] = useState<'processing' | 'success' | 'error'>( + 'processing', + ) const [error, setError] = useState('') useEffect(() => { @@ -15,7 +17,7 @@ export function AuthCallbackPage() { // Get the code from URL parameters const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') - + if (!code) { throw new Error('No authorization code received') } @@ -25,22 +27,23 @@ export function AuthCallbackPage() { // Now get the user info const user = await api.auth.getMe() - + // Update auth context if (setUser) setUser(user) - + setStatus('success') - + // Redirect to dashboard after a short delay setTimeout(() => { navigate('/') }, 1000) - } catch (error) { console.error('OAuth callback failed:', error) - setError(error instanceof Error ? error.message : 'Authentication failed') + setError( + error instanceof Error ? error.message : 'Authentication failed', + ) setStatus('error') - + // Redirect to login after error setTimeout(() => { navigate('/login') @@ -57,28 +60,40 @@ export function AuthCallbackPage() { {status === 'processing' && (
-

Completing sign in...

-

Please wait while we set up your account.

+

+ Completing sign in... +

+

+ Please wait while we set up your account. +

)} - + {status === 'success' && (
-

Sign in successful!

-

Redirecting to dashboard...

+

+ Sign in successful! +

+

+ Redirecting to dashboard... +

)} - + {status === 'error' && (
-

Sign in failed

+

+ Sign in failed +

{error}

-

Redirecting to login page...

+

+ Redirecting to login page... +

)}
) -} \ No newline at end of file +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index cfd31f5..fcf954f 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,12 +1,27 @@ -import { useCallback, useEffect, useState } from 'react' import { AppLayout } from '@/components/AppLayout' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { Volume2, Play, Clock, HardDrive, Music, Trophy, Loader2, RefreshCw } from 'lucide-react' -import NumberFlow from '@number-flow/react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { NumberFlowDuration } from '@/components/ui/number-flow-duration' import { NumberFlowSize } from '@/components/ui/number-flow-size' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import NumberFlow from '@number-flow/react' +import { + Clock, + HardDrive, + Loader2, + Music, + Play, + RefreshCw, + Trophy, + Volume2, +} from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' interface SoundboardStatistics { sound_count: number @@ -32,11 +47,13 @@ interface TopSound { } export function DashboardPage() { - const [soundboardStatistics, setSoundboardStatistics] = useState(null) - const [trackStatistics, setTrackStatistics] = useState(null) + const [soundboardStatistics, setSoundboardStatistics] = + useState(null) + const [trackStatistics, setTrackStatistics] = + useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - + // Top sounds state const [topSounds, setTopSounds] = useState([]) const [topSoundsLoading, setTopSoundsLoading] = useState(false) @@ -48,19 +65,21 @@ export function DashboardPage() { const fetchStatistics = useCallback(async () => { try { const [soundboardResponse, trackResponse] = await Promise.all([ - fetch('/api/v1/dashboard/soundboard-statistics', { credentials: 'include' }), - fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }) + fetch('/api/v1/dashboard/soundboard-statistics', { + credentials: 'include', + }), + fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }), ]) - + if (!soundboardResponse.ok || !trackResponse.ok) { throw new Error('Failed to fetch statistics') } - + const [soundboardData, trackData] = await Promise.all([ soundboardResponse.json(), - trackResponse.json() + trackResponse.json(), ]) - + setSoundboardStatistics(soundboardData) setTrackStatistics(trackData) } catch (err) { @@ -68,61 +87,63 @@ export function DashboardPage() { } }, []) - const fetchTopSounds = useCallback(async (showLoading = false) => { - try { - if (showLoading) { - setTopSoundsLoading(true) - } - - const response = await fetch( - `/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`, - { credentials: 'include' } - ) - - if (!response.ok) { - throw new Error('Failed to fetch top sounds') - } - - const data = await response.json() - - // Graceful update: merge new data while preserving animations - setTopSounds(prevTopSounds => { - // Create a map of existing sounds for efficient lookup - const existingSoundsMap = new Map(prevTopSounds.map(sound => [sound.id, sound])) - - // Update existing sounds and add new ones - return data.map((newSound: TopSound) => { - const existingSound = existingSoundsMap.get(newSound.id) - if (existingSound) { - // Preserve object reference if data hasn't changed to avoid re-renders - if ( - existingSound.name === newSound.name && - existingSound.type === newSound.type && - existingSound.play_count === newSound.play_count && - existingSound.duration === newSound.duration - ) { - return existingSound + const fetchTopSounds = useCallback( + async (showLoading = false) => { + try { + if (showLoading) { + setTopSoundsLoading(true) + } + + const response = await fetch( + `/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`, + { credentials: 'include' }, + ) + + if (!response.ok) { + throw new Error('Failed to fetch top sounds') + } + + const data = await response.json() + + // Graceful update: merge new data while preserving animations + setTopSounds(prevTopSounds => { + // Create a map of existing sounds for efficient lookup + const existingSoundsMap = new Map( + prevTopSounds.map(sound => [sound.id, sound]), + ) + + // Update existing sounds and add new ones + return data.map((newSound: TopSound) => { + const existingSound = existingSoundsMap.get(newSound.id) + if (existingSound) { + // Preserve object reference if data hasn't changed to avoid re-renders + if ( + existingSound.name === newSound.name && + existingSound.type === newSound.type && + existingSound.play_count === newSound.play_count && + existingSound.duration === newSound.duration + ) { + return existingSound + } } - } - return newSound + return newSound + }) }) - }) - } catch (err) { - console.error('Failed to fetch top sounds:', err) - } finally { - if (showLoading) { - setTopSoundsLoading(false) + } catch (err) { + console.error('Failed to fetch top sounds:', err) + } finally { + if (showLoading) { + setTopSoundsLoading(false) + } } - } - }, [soundType, period, limit]) + }, + [soundType, period, limit], + ) const refreshAll = useCallback(async () => { setRefreshing(true) try { - await Promise.all([ - fetchStatistics(), - fetchTopSounds() - ]) + await Promise.all([fetchStatistics(), fetchTopSounds()]) } finally { setRefreshing(false) } @@ -149,18 +170,16 @@ export function DashboardPage() { return () => clearInterval(interval) }, [refreshAll]) - + useEffect(() => { fetchTopSounds(true) // Show loading on initial load and filter changes }, [fetchTopSounds]) if (loading) { return ( -
@@ -174,30 +193,42 @@ export function DashboardPage() {
-

Soundboard Statistics

+

+ Soundboard Statistics +

{[...Array(4)].map((_, i) => ( - Loading... + + Loading... + -
---
+
+ --- +
))}
-

Track Statistics

+

+ Track Statistics +

{[...Array(4)].map((_, i) => ( - Loading... + + Loading... + -
---
+
+ --- +
))} @@ -211,11 +242,9 @@ export function DashboardPage() { if (error || !soundboardStatistics || !trackStatistics) { return ( -
@@ -228,7 +257,9 @@ export function DashboardPage() {
-

Error loading statistics: {error}

+

+ Error loading statistics: {error} +

@@ -236,11 +267,9 @@ export function DashboardPage() { } return ( -
@@ -251,28 +280,36 @@ export function DashboardPage() { Overview of your soundboard and track statistics

-
- +
{/* Soundboard Statistics */}
-

Soundboard Statistics

+

+ Soundboard Statistics +

- Total Sounds + + Total Sounds + -
+
+ +

Soundboard audio files

@@ -281,11 +318,15 @@ export function DashboardPage() { - Total Plays + + Total Plays + -
+
+ +

All-time play count

@@ -294,12 +335,17 @@ export function DashboardPage() { - Total Duration + + Total Duration +
- +

Combined audio duration @@ -309,12 +355,17 @@ export function DashboardPage() { - Total Size + + Total Size +

- +

Original + normalized files @@ -326,15 +377,21 @@ export function DashboardPage() { {/* Track Statistics */}

-

Track Statistics

+

+ Track Statistics +

- Total Tracks + + Total Tracks + -
+
+ +

Extracted audio tracks

@@ -343,11 +400,15 @@ export function DashboardPage() { - Total Plays + + Total Plays + -
+
+ +

All-time play count

@@ -356,12 +417,17 @@ export function DashboardPage() { - Total Duration + + Total Duration +
- +

Combined track duration @@ -371,12 +437,17 @@ export function DashboardPage() { - Total Size + + Total Size +

- +

Original + normalized files @@ -385,7 +456,7 @@ export function DashboardPage() {

- + {/* Top Sounds Section */}
@@ -428,7 +499,10 @@ export function DashboardPage() {
Count: - setLimit(parseInt(value))} + > @@ -457,18 +531,26 @@ export function DashboardPage() { ) : (
{topSounds.map((sound, index) => ( -
+
{index + 1}
-
{sound.name}
+
+ {sound.name} +
{sound.duration && ( - + )} @@ -477,8 +559,12 @@ export function DashboardPage() {
-
-
plays
+
+ +
+
+ plays +
))} @@ -491,4 +577,4 @@ export function DashboardPage() {
) -} \ No newline at end of file +} diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx index 33936d8..88f255e 100644 --- a/src/pages/ExtractionsPage.tsx +++ b/src/pages/ExtractionsPage.tsx @@ -1,16 +1,41 @@ -import { useState, useEffect } from 'react' import { AppLayout } from '@/components/AppLayout' -import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react' -import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions' -import { toast } from 'sonner' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + type ExtractionInfo, + extractionsService, +} from '@/lib/api/services/extractions' import { formatDistanceToNow } from 'date-fns' +import { + AlertCircle, + Calendar, + CheckCircle, + Clock, + Download, + ExternalLink, + Loader2, + Plus, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' export function ExtractionsPage() { const [extractions, setExtractions] = useState([]) @@ -63,29 +88,53 @@ export function ExtractionsPage() { const getStatusBadge = (status: ExtractionInfo['status']) => { switch (status) { case 'pending': - return Pending + return ( + + + Pending + + ) case 'processing': - return Processing + return ( + + + Processing + + ) case 'completed': - return Completed + return ( + + + Completed + + ) case 'failed': - return Failed + return ( + + + Failed + + ) } } const getServiceBadge = (service: string | undefined) => { if (!service) return null - + const serviceColors: Record = { youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', - soundcloud: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', + soundcloud: + 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300', twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300', - instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300', + instagram: + 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300', } - const colorClass = serviceColors[service.toLowerCase()] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300' + const colorClass = + serviceColors[service.toLowerCase()] || + 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300' return ( @@ -95,12 +144,9 @@ export function ExtractionsPage() { } return ( -
@@ -111,7 +157,7 @@ export function ExtractionsPage() { Extract audio from YouTube, SoundCloud, and other platforms

- +
- - - {extraction.status === 'completed' && extraction.sound_id && ( - - )} + {extraction.status === 'completed' && + extraction.sound_id && ( + + )}
@@ -262,4 +332,4 @@ export function ExtractionsPage() {
) -} \ No newline at end of file +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 4b1fa35..3482008 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,17 +1,17 @@ -import { Link } from 'react-router' import { LoginForm } from '@/components/auth/LoginForm' +import { Link } from 'react-router' export function LoginPage() { return (
- +

Don't have an account?{' '} - Sign up @@ -21,4 +21,4 @@ export function LoginPage() {

) -} \ No newline at end of file +} diff --git a/src/pages/PlaylistEditPage.tsx b/src/pages/PlaylistEditPage.tsx index 79bad7d..4f0bc90 100644 --- a/src/pages/PlaylistEditPage.tsx +++ b/src/pages/PlaylistEditPage.tsx @@ -1,38 +1,61 @@ -import { useEffect, useState, useCallback } from 'react' -import { useParams, useNavigate } from 'react-router' +import { AppLayout } from '@/components/AppLayout' +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 { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Textarea } from '@/components/ui/textarea' +import { + type Playlist, + type PlaylistSound, + playlistsService, +} from '@/lib/api/services/playlists' +import { type Sound, soundsService } from '@/lib/api/services/sounds' +import { formatDuration } from '@/utils/format-duration' import { DndContext, type DragEndEvent, - type DragStartEvent, type DragOverEvent, - closestCenter, + DragOverlay, + type DragStartEvent, PointerSensor, + closestCenter, useSensor, useSensors, - DragOverlay, } from '@dnd-kit/core' +import { useDroppable } from '@dnd-kit/core' import { SortableContext, - verticalListSortingStrategy, useSortable, + verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { useDroppable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' -import { AppLayout } from '@/components/AppLayout' -import { playlistsService, type Playlist, type PlaylistSound } from '@/lib/api/services/playlists' -import { soundsService, type Sound } from '@/lib/api/services/sounds' -import { Skeleton } from '@/components/ui/skeleton' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { AlertCircle, Save, Music, Clock, ChevronUp, ChevronDown, Trash2, RefreshCw, Edit, X, Plus, Minus } 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 { useNavigate, useParams } from 'react-router' import { toast } from 'sonner' -import { formatDuration } from '@/utils/format-duration' - // Sortable table row component for normal table view interface SortableTableRowProps { @@ -44,13 +67,13 @@ interface SortableTableRowProps { totalSounds: number } -function SortableTableRow({ - sound, - index, - onMoveSoundUp, - onMoveSoundDown, - onRemoveSound, - totalSounds +function SortableTableRow({ + sound, + index, + onMoveSoundUp, + onMoveSoundDown, + onRemoveSound, + totalSounds, }: SortableTableRowProps) { const { attributes, @@ -68,14 +91,14 @@ function SortableTableRow({ } return ( - -
-
- {sound.name} -
+
{sound.name}
@@ -149,7 +170,11 @@ interface SimpleSortableRowProps { onRemoveSound: (soundId: number) => void } -function SimpleSortableRow({ sound, index, onRemoveSound }: SimpleSortableRowProps) { +function SimpleSortableRow({ + sound, + index, + onRemoveSound, +}: SimpleSortableRowProps) { const { attributes, listeners, @@ -176,19 +201,17 @@ function SimpleSortableRow({ sound, index, onRemoveSound }: SimpleSortableRowPro {index + 1} - + - +
-
- {sound.name} -
+
{sound.name}
) @@ -309,38 +327,33 @@ function DragOverlayContent({ sound, position }: DragOverlayContentProps) { {position + 1} - + - +
-
- {sound.name} -
+
{sound.name}
) } - + // Default available sound style return (
- +
-
- {sound.name} -
+
{sound.name}
) } - export function PlaylistEditPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const playlistId = parseInt(id!, 10) - + const [playlist, setPlaylist] = useState(null) const [sounds, setSounds] = useState([]) const [loading, setLoading] = useState(true) @@ -348,35 +361,36 @@ export function PlaylistEditPage() { const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [isEditMode, setIsEditMode] = useState(false) - + // Add mode state const [isAddMode, setIsAddMode] = useState(false) const [availableSounds, setAvailableSounds] = useState([]) const [availableSoundsLoading, setAvailableSoundsLoading] = useState(false) const [draggedItem, setDraggedItem] = useState(null) - const [draggedSound, setDraggedSound] = useState(null) + const [draggedSound, setDraggedSound] = useState< + Sound | PlaylistSound | null + >(null) const [dropPosition, setDropPosition] = useState(null) - - + // dnd-kit sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, - }) + }), ) - + // Form state const [formData, setFormData] = useState({ name: '', description: '', - genre: '' + genre: '', }) - + // Track if form has changes const [hasChanges, setHasChanges] = useState(false) - + const fetchPlaylist = useCallback(async () => { try { setLoading(true) @@ -386,10 +400,11 @@ export function PlaylistEditPage() { setFormData({ name: playlistData.name, description: playlistData.description || '', - genre: playlistData.genre || '' + genre: playlistData.genre || '', }) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlist' + const errorMessage = + err instanceof Error ? err.message : 'Failed to fetch playlist' setError(errorMessage) toast.error(errorMessage) } finally { @@ -403,7 +418,8 @@ export function PlaylistEditPage() { const soundsData = await playlistsService.getPlaylistSounds(playlistId) setSounds(soundsData) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlist sounds' + const errorMessage = + err instanceof Error ? err.message : 'Failed to fetch playlist sounds' toast.error(errorMessage) } finally { setSoundsLoading(false) @@ -416,10 +432,13 @@ export function PlaylistEditPage() { const soundsData = await soundsService.getSoundsByType('EXT') // Filter out sounds that are already in the playlist const playlistSoundIds = new Set(sounds.map(s => s.id)) - const filteredSounds = soundsData.filter(sound => !playlistSoundIds.has(sound.id)) + const filteredSounds = soundsData.filter( + sound => !playlistSoundIds.has(sound.id), + ) setAvailableSounds(filteredSounds) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch available sounds' + const errorMessage = + err instanceof Error ? err.message : 'Failed to fetch available sounds' toast.error(errorMessage) } finally { setAvailableSoundsLoading(false) @@ -446,7 +465,7 @@ export function PlaylistEditPage() { useEffect(() => { if (playlist) { - const changed = + const changed = formData.name !== playlist.name || formData.description !== (playlist.description || '') || formData.genre !== (playlist.genre || '') @@ -457,7 +476,7 @@ export function PlaylistEditPage() { const handleInputChange = (field: string, value: string) => { setFormData(prev => ({ ...prev, - [field]: value + [field]: value, })) } @@ -469,16 +488,17 @@ export function PlaylistEditPage() { await playlistsService.updatePlaylist(playlist.id, { name: formData.name.trim() || undefined, description: formData.description.trim() || undefined, - genre: formData.genre.trim() || undefined + genre: formData.genre.trim() || undefined, }) - + toast.success('Playlist updated successfully') - + // Refresh playlist data and exit edit mode await fetchPlaylist() setIsEditMode(false) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to update playlist' + const errorMessage = + err instanceof Error ? err.message : 'Failed to update playlist' toast.error(errorMessage) } finally { setSaving(false) @@ -491,7 +511,7 @@ export function PlaylistEditPage() { setFormData({ name: playlist.name, description: playlist.description || '', - genre: playlist.genre || '' + genre: playlist.genre || '', }) setIsEditMode(false) } @@ -503,11 +523,12 @@ export function PlaylistEditPage() { try { await playlistsService.setCurrentPlaylist(playlist.id) toast.success(`"${playlist.name}" is now the current playlist`) - + // Refresh playlist data await fetchPlaylist() } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to set current playlist' + const errorMessage = + err instanceof Error ? err.message : 'Failed to set current playlist' toast.error(errorMessage) } } @@ -520,14 +541,17 @@ export function PlaylistEditPage() { newSounds.splice(index - 1, 0, movedSound) // Create sound positions array for the API - const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) + const soundPositions: Array<[number, number]> = newSounds.map( + (sound, idx) => [sound.id, idx], + ) try { await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) setSounds(newSounds) toast.success('Sound moved up') } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to reorder sounds' + const errorMessage = + err instanceof Error ? err.message : 'Failed to reorder sounds' toast.error(errorMessage) } } @@ -540,14 +564,17 @@ export function PlaylistEditPage() { newSounds.splice(index + 1, 0, movedSound) // Create sound positions array for the API - const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) + const soundPositions: Array<[number, number]> = newSounds.map( + (sound, idx) => [sound.id, idx], + ) try { await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) setSounds(newSounds) toast.success('Sound moved down') } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to reorder sounds' + const errorMessage = + err instanceof Error ? err.message : 'Failed to reorder sounds' toast.error(errorMessage) } } @@ -556,10 +583,10 @@ export function PlaylistEditPage() { try { // Add at the end - backend should handle position gaps automatically const position = sounds.length - + await playlistsService.addSoundToPlaylist(playlistId, soundId, position) toast.success('Sound added to playlist') - + // Find the sound in available sounds to get its data const soundToAdd = availableSounds.find(s => s.id === soundId) if (soundToAdd) { @@ -569,17 +596,22 @@ export function PlaylistEditPage() { // Add any missing properties that PlaylistSound has but Sound doesn't } setSounds(prev => [...prev, newPlaylistSound]) - + // Remove from available sounds setAvailableSounds(prev => prev.filter(s => s.id !== soundId)) - + // Only update the playlist counter optimistically (avoid full refetch) if (playlist) { - setPlaylist(prev => prev ? { - ...prev, - sound_count: (prev.sound_count || 0) + 1, - total_duration: (prev.total_duration || 0) + (soundToAdd.duration || 0) - } : null) + setPlaylist(prev => + prev + ? { + ...prev, + sound_count: (prev.sound_count || 0) + 1, + total_duration: + (prev.total_duration || 0) + (soundToAdd.duration || 0), + } + : null, + ) } } else { // Fallback to refresh if we can't find the sound data @@ -587,7 +619,8 @@ export function PlaylistEditPage() { await fetchPlaylist() } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist' + const errorMessage = + err instanceof Error ? err.message : 'Failed to add sound to playlist' toast.error(errorMessage) } } @@ -597,13 +630,13 @@ export function PlaylistEditPage() { // Find the sound being removed const removedSound = sounds.find(s => s.id === soundId) if (!removedSound) return - + await playlistsService.removeSoundFromPlaylist(playlistId, soundId) - + // Optimistically update the sounds list (backend reorders automatically) setSounds(prev => prev.filter(sound => sound.id !== soundId)) toast.success('Sound removed from playlist') - + // If it's an EXT sound and we're in add mode, add it back to available sounds if (isAddMode && removedSound.type === 'EXT') { // Fetch the complete sound data to add back to available sounds @@ -611,24 +644,36 @@ export function PlaylistEditPage() { const allExtSounds = await soundsService.getSoundsByType('EXT') const soundToAddBack = allExtSounds.find(s => s.id === soundId) if (soundToAddBack) { - setAvailableSounds(prev => [...prev, soundToAddBack].sort((a, b) => a.name.localeCompare(b.name))) + setAvailableSounds(prev => + [...prev, soundToAddBack].sort((a, b) => + a.name.localeCompare(b.name), + ), + ) } } catch { // If we can't fetch the sound data, just refresh the available sounds await fetchAvailableSounds() } } - + // Optimistically update playlist stats if (playlist) { - setPlaylist(prev => prev ? { - ...prev, - sound_count: Math.max(0, (prev.sound_count || 0) - 1), - total_duration: Math.max(0, (prev.total_duration || 0) - (removedSound.duration || 0)) - } : null) + setPlaylist(prev => + prev + ? { + ...prev, + sound_count: Math.max(0, (prev.sound_count || 0) - 1), + total_duration: Math.max( + 0, + (prev.total_duration || 0) - (removedSound.duration || 0), + ), + } + : null, + ) } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to remove sound' + const errorMessage = + err instanceof Error ? err.message : 'Failed to remove sound' toast.error(errorMessage) // On error, refresh to get correct state await fetchSounds() @@ -636,20 +681,23 @@ export function PlaylistEditPage() { } } - const handleDragStart = (event: DragStartEvent) => { const draggedId = event.active.id.toString() setDraggedItem(draggedId) setDropPosition(null) // Clear any previous drop position - + // Find the sound being dragged if (draggedId.startsWith('available-sound-')) { const soundId = parseInt(draggedId.replace('available-sound-', ''), 10) const sound = availableSounds.find(s => s.id === soundId) setDraggedSound(sound || null) - } else if (draggedId.startsWith('playlist-sound-') || draggedId.startsWith('table-sound-')) { + } else if ( + draggedId.startsWith('playlist-sound-') || + draggedId.startsWith('table-sound-') + ) { const soundId = parseInt( - draggedId.replace('playlist-sound-', '').replace('table-sound-', ''), 10 + draggedId.replace('playlist-sound-', '').replace('table-sound-', ''), + 10, ) const sound = sounds.find(s => s.id === soundId) setDraggedSound(sound || null) @@ -661,73 +709,95 @@ export function PlaylistEditPage() { setDraggedItem(null) setDraggedSound(null) setDropPosition(null) - + if (!over) return - + const activeId = active.id as string const overId = over.id as string - + // Handle adding sound from available list to playlist - if (activeId.startsWith('available-sound-') && (overId.startsWith('playlist-sound-') || overId === 'playlist-end')) { + if ( + activeId.startsWith('available-sound-') && + (overId.startsWith('playlist-sound-') || overId === 'playlist-end') + ) { const soundId = parseInt(activeId.replace('available-sound-', ''), 10) - + let position = sounds.length // Default to end - + if (overId.startsWith('playlist-sound-')) { - const targetSoundId = parseInt(overId.replace('playlist-sound-', ''), 10) + const targetSoundId = parseInt( + overId.replace('playlist-sound-', ''), + 10, + ) const targetIndex = sounds.findIndex(s => s.id === targetSoundId) position = targetIndex } else if (overId === 'playlist-end') { position = sounds.length } - + try { await playlistsService.addSoundToPlaylist(playlistId, soundId, position) toast.success('Sound added to playlist') - + // Refresh playlist sounds first await fetchSounds() - + // Immediately remove the added sound from available sounds if (isAddMode) { setAvailableSounds(prev => prev.filter(s => s.id !== soundId)) } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist' + const errorMessage = + err instanceof Error ? err.message : 'Failed to add sound to playlist' toast.error(errorMessage) } return } - + // Handle reordering sounds within playlist (both table and simple view) - if ((activeId.startsWith('table-sound-') && overId.startsWith('table-sound-')) || - (activeId.startsWith('playlist-sound-') && overId.startsWith('playlist-sound-'))) { - + if ( + (activeId.startsWith('table-sound-') && + overId.startsWith('table-sound-')) || + (activeId.startsWith('playlist-sound-') && + overId.startsWith('playlist-sound-')) + ) { const draggedSoundId = parseInt( - activeId.replace('table-sound-', '').replace('playlist-sound-', ''), 10 + activeId.replace('table-sound-', '').replace('playlist-sound-', ''), + 10, ) const targetSoundId = parseInt( - overId.replace('table-sound-', '').replace('playlist-sound-', ''), 10 + overId.replace('table-sound-', '').replace('playlist-sound-', ''), + 10, ) - + const draggedIndex = sounds.findIndex(s => s.id === draggedSoundId) const targetIndex = sounds.findIndex(s => s.id === targetSoundId) - - if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) { + + if ( + draggedIndex !== -1 && + targetIndex !== -1 && + draggedIndex !== targetIndex + ) { // Reorder sounds in playlist const newSounds = [...sounds] const [draggedSound] = newSounds.splice(draggedIndex, 1) newSounds.splice(targetIndex, 0, draggedSound) - + // Create sound positions for API - const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) - + const soundPositions: Array<[number, number]> = newSounds.map( + (sound, idx) => [sound.id, idx], + ) + try { - await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) + await playlistsService.reorderPlaylistSounds( + playlistId, + soundPositions, + ) setSounds(newSounds) toast.success('Playlist reordered') } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to reorder playlist' + const errorMessage = + err instanceof Error ? err.message : 'Failed to reorder playlist' toast.error(errorMessage) } } @@ -736,15 +806,15 @@ export function PlaylistEditPage() { const handleDragOver = (event: DragOverEvent) => { const { active, over } = event - + // Only show preview when dragging available sounds if (!active.id.toString().startsWith('available-sound-') || !over) { setDropPosition(null) return } - + const overId = over.id.toString() - + if (overId.startsWith('playlist-sound-')) { const targetSoundId = parseInt(overId.replace('playlist-sound-', ''), 10) const targetIndex = sounds.findIndex(s => s.id === targetSoundId) @@ -758,13 +828,13 @@ export function PlaylistEditPage() { if (loading) { return ( -
@@ -780,22 +850,24 @@ export function PlaylistEditPage() { if (error || !playlist) { return ( -
-

Failed to load playlist

+

+ Failed to load playlist +

{error}

- - )} - {playlist.is_current && ( - Current Playlist - )} -
-
-
- {/* Playlist Details */} - - - - - Playlist Details - - - - {isEditMode ? ( - <> -
- - handleInputChange('name', e.target.value)} - placeholder="Playlist name" - /> -
- -
- -