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

- Improved import organization and formatting in PlaylistsPage, RegisterPage, SoundsPage, SettingsPage, and UsersPage for better readability.
- Added error handling and user feedback with toast notifications in SoundsPage and SettingsPage.
- Enhanced user experience by implementing debounced search functionality in PlaylistsPage and SoundsPage.
- Updated the layout and structure of forms in SettingsPage and UsersPage for better usability.
- Improved accessibility and semantics by ensuring proper labeling and descriptions in forms.
- Fixed minor bugs related to state management and API calls in various components.
This commit is contained in:
JSC
2025-08-14 23:51:47 +02:00
parent 8358aa16aa
commit 4e50e7e79d
53 changed files with 2477 additions and 1520 deletions

View File

@@ -1,25 +1,29 @@
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 <div className="min-h-screen flex items-center justify-center">Loading...</div>
return (
<div className="min-h-screen flex items-center justify-center">
Loading...
</div>
)
}
if (!user) {
@@ -33,7 +37,11 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
return (
<div className="min-h-screen flex items-center justify-center">
Loading...
</div>
)
}
if (!user) {
@@ -52,49 +60,79 @@ function AppRoutes() {
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
<Route
path="/login"
element={user ? <Navigate to="/" replace /> : <LoginPage />}
/>
<Route
path="/register"
element={user ? <Navigate to="/" replace /> : <RegisterPage />}
/>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/" element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
} />
<Route path="/sounds" element={
<ProtectedRoute>
<SoundsPage />
</ProtectedRoute>
} />
<Route path="/playlists" element={
<ProtectedRoute>
<PlaylistsPage />
</ProtectedRoute>
} />
<Route path="/playlists/:id/edit" element={
<ProtectedRoute>
<PlaylistEditPage />
</ProtectedRoute>
} />
<Route path="/extractions" element={
<ProtectedRoute>
<ExtractionsPage />
</ProtectedRoute>
} />
<Route path="/account" element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
} />
<Route path="/admin/users" element={
<AdminRoute>
<UsersPage />
</AdminRoute>
} />
<Route path="/admin/settings" element={
<AdminRoute>
<SettingsPage />
</AdminRoute>
} />
<Route
path="/"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/sounds"
element={
<ProtectedRoute>
<SoundsPage />
</ProtectedRoute>
}
/>
<Route
path="/playlists"
element={
<ProtectedRoute>
<PlaylistsPage />
</ProtectedRoute>
}
/>
<Route
path="/playlists/:id/edit"
element={
<ProtectedRoute>
<PlaylistEditPage />
</ProtectedRoute>
}
/>
<Route
path="/extractions"
element={
<ProtectedRoute>
<ExtractionsPage />
</ProtectedRoute>
}
/>
<Route
path="/account"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/users"
element={
<AdminRoute>
<UsersPage />
</AdminRoute>
}
/>
<Route
path="/admin/settings"
element={
<AdminRoute>
<SettingsPage />
</AdminRoute>
}
/>
</Routes>
)
}

View File

@@ -1,7 +1,3 @@
import { useState } from 'react'
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
import { AppSidebar } from './AppSidebar'
import { Separator } from '@/components/ui/separator'
import {
Breadcrumb,
BreadcrumbItem,
@@ -10,6 +6,14 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Separator } from '@/components/ui/separator'
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar'
import { useState } from 'react'
import { AppSidebar } from './AppSidebar'
import { Player, type PlayerDisplayMode } from './player/Player'
interface AppLayoutProps {
@@ -23,14 +27,21 @@ interface AppLayoutProps {
}
export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(() => {
// Initialize from localStorage or default to 'normal'
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
}
return 'normal'
})
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(
() => {
// Initialize from localStorage or default to 'normal'
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(
'playerDisplayMode',
) as PlayerDisplayMode
return saved &&
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
? saved
: 'normal'
}
return 'normal'
},
)
// Note: localStorage is managed by the Player component
@@ -66,13 +77,9 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
)}
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
{children}
</div>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
</SidebarInset>
<Player
onPlayerModeChange={setPlayerDisplayMode}
/>
<Player onPlayerModeChange={setPlayerDisplayMode} />
</SidebarProvider>
)
}

View File

@@ -1,11 +1,4 @@
import {
Home,
Music,
Users,
Settings,
Download,
PlayCircle
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import {
Sidebar,
SidebarContent,
@@ -13,13 +6,20 @@ import {
SidebarHeader,
SidebarRail,
} from '@/components/ui/sidebar'
import { useAuth } from '@/contexts/AuthContext'
import {
Download,
Home,
Music,
PlayCircle,
Settings,
Users,
} from 'lucide-react'
import { CreditsNav } from './nav/CreditsNav'
import { NavGroup } from './nav/NavGroup'
import { NavItem } from './nav/NavItem'
import { UserNav } from './nav/UserNav'
import { CreditsNav } from './nav/CreditsNav'
import { CompactPlayer } from './player/CompactPlayer'
import { Separator } from '@/components/ui/separator'
import { useAuth } from '@/contexts/AuthContext'
interface AppSidebarProps {
showCompactPlayer?: boolean
@@ -35,7 +35,9 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-2">
<Music className="h-6 w-6" />
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">SDB v2</span>
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">
SDB v2
</span>
</div>
</SidebarHeader>
@@ -47,7 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
<NavItem href="/extractions" icon={Download} title="Extractions" />
</NavGroup>
{user.role === "admin" && (
{user.role === 'admin' && (
<NavGroup label="Admin">
<NavItem href="/admin/users" icon={Users} title="Users" />
<NavItem href="/admin/settings" icon={Settings} title="Settings" />

View File

@@ -5,11 +5,18 @@ export function SocketBadge() {
const { isConnected, isReconnecting } = useSocket()
if (isReconnecting) {
return <Badge variant="secondary" className="text-xs">Reconnecting</Badge>
return (
<Badge variant="secondary" className="text-xs">
Reconnecting
</Badge>
)
}
return (
<Badge variant={isConnected ? 'default' : 'destructive'} className="text-xs">
<Badge
variant={isConnected ? 'default' : 'destructive'}
className="text-xs"
>
{isConnected ? 'Connected' : 'Disconnected'}
</Badge>
)

View File

@@ -1,5 +1,5 @@
import { type Theme, ThemeProviderContext } from '@/contexts/ThemeContext'
import { useContext, useEffect, useState } from 'react'
import { ThemeProviderContext, type Theme } from '@/contexts/ThemeContext'
type ThemeProviderProps = {
children: React.ReactNode

View File

@@ -1,11 +1,17 @@
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { OAuthButtons } from './OAuthButtons'
import { useAuth } from '@/contexts/AuthContext'
import { ApiError } from '@/lib/api'
import { useState } from 'react'
import { OAuthButtons } from './OAuthButtons'
export function LoginForm() {
const { login } = useAuth()
@@ -44,7 +50,9 @@ export function LoginForm() {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">Sign in</CardTitle>
<CardTitle className="text-2xl font-bold text-center">
Sign in
</CardTitle>
<CardDescription className="text-center">
Enter your email and password to sign in to your account
</CardDescription>
@@ -83,11 +91,7 @@ export function LoginForm() {
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { api } from '@/lib/api'
import { useEffect, useState } from 'react'
export function OAuthButtons() {
const [providers, setProviders] = useState<string[]>([])
@@ -92,7 +92,7 @@ export function OAuthButtons() {
</div>
<div className="grid grid-cols-1 gap-3">
{providers.map((provider) => (
{providers.map(provider => (
<Button
key={provider}
variant="outline"
@@ -109,8 +109,7 @@ export function OAuthButtons() {
<span className="ml-2">
{loading === provider
? 'Connecting...'
: `Continue with ${getProviderName(provider)}`
}
: `Continue with ${getProviderName(provider)}`}
</span>
</Button>
))}

View File

@@ -1,11 +1,17 @@
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { OAuthButtons } from './OAuthButtons'
import { useAuth } from '@/contexts/AuthContext'
import { ApiError } from '@/lib/api'
import { useState } from 'react'
import { OAuthButtons } from './OAuthButtons'
export function RegisterForm() {
const { register } = useAuth()
@@ -62,7 +68,9 @@ export function RegisterForm() {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">Create account</CardTitle>
<CardTitle className="text-2xl font-bold text-center">
Create account
</CardTitle>
<CardDescription className="text-center">
Enter your information to create your account
</CardDescription>
@@ -128,11 +136,7 @@ export function RegisterForm() {
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</Button>
</form>

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'
import { CircleDollarSign } from 'lucide-react'
import NumberFlow from '@number-flow/react'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
import { userEvents, USER_EVENTS } from '@/lib/events'
import { USER_EVENTS, userEvents } from '@/lib/events'
import type { User } from '@/types/auth'
import NumberFlow from '@number-flow/react'
import { CircleDollarSign } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
interface CreditsNavProps {
user: User
@@ -41,12 +41,17 @@ export function CreditsNav({ user }: CreditsNavProps) {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="cursor-default group-data-[collapsible=icon]:justify-center" tooltip={tooltipText}>
<SidebarMenuButton
size="lg"
className="cursor-default group-data-[collapsible=icon]:justify-center"
tooltip={tooltipText}
>
<CircleDollarSign className={`h-5 w-5 ${getIconColor()}`} />
<div className="flex flex-1 items-center justify-between text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold">Credits:</span>
<span className="text-muted-foreground">
<NumberFlow value={credits} /> / <NumberFlow value={user.plan.max_credits} />
<NumberFlow value={credits} /> /{' '}
<NumberFlow value={user.plan.max_credits} />
</span>
</div>
</SidebarMenuButton>

View File

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

View File

@@ -1,9 +1,6 @@
import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
import type { LucideIcon } from 'lucide-react'
import { Link, useLocation } from 'react-router'
import {
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
interface NavItemProps {
href: string

View File

@@ -1,4 +1,4 @@
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
@@ -8,10 +8,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '../ui/sidebar'
import type { User } from '@/types/auth'
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
import { Link } from 'react-router'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '../ui/sidebar'
interface UserNavProps {
user: User

View File

@@ -1,22 +1,26 @@
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { filesService } from '@/lib/api/services/files'
import {
Play,
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,
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 { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
interface CompactPlayerProps {
className?: string
@@ -28,7 +32,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
mode: 'continuous',
volume: 80,
previous_volume: 80,
position: 0
position: 0,
})
const [isLoading, setIsLoading] = useState(false)
@@ -58,18 +62,23 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
}
}, [])
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
}, [])
const executeAction = useCallback(
async (
action: () => Promise<void | MessageResponse>,
actionName: string,
) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
},
[],
)
const handlePlayPause = useCallback(() => {
if (state.status === 'playing') {
@@ -103,7 +112,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
// }
return (
<div className={cn("w-full", className)}>
<div className={cn('w-full', className)}>
{/* Collapsed state - only play/pause button */}
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
<Button
@@ -132,7 +141,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={(e) => {
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
@@ -143,8 +152,8 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
) : null}
<Music
className={cn(
"h-4 w-4 text-muted-foreground",
state.current_sound?.thumbnail ? "hidden" : "block"
'h-4 w-4 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
@@ -160,7 +169,9 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
size="sm"
variant="ghost"
onClick={() => {
const expandFn = (window as unknown as { __expandPlayerFromSidebar?: () => void }).__expandPlayerFromSidebar
const expandFn = (
window as unknown as { __expandPlayerFromSidebar?: () => void }
).__expandPlayerFromSidebar
if (expandFn) expandFn()
}}
className="h-6 w-6 p-0 flex-shrink-0"

View File

@@ -1,37 +1,47 @@
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Play,
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,
Square,
SkipBack,
SkipForward,
Volume2,
VolumeX,
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,13 +57,18 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
mode: 'continuous',
volume: 80,
previous_volume: 80,
position: 0
position: 0,
})
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
// Initialize from localStorage or default to 'normal'
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
const saved = localStorage.getItem(
'playerDisplayMode',
) as PlayerDisplayMode
return saved &&
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
? saved
: 'normal'
}
return 'normal'
})
@@ -111,17 +126,23 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}
}, [displayMode])
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
}, [])
const executeAction = useCallback(
async (
action: () => Promise<void | MessageResponse>,
actionName: string,
) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
},
[],
)
const handlePlayPause = useCallback(() => {
if (state.status === 'playing') {
@@ -143,15 +164,21 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
executeAction(playerService.next, 'go to next track')
}, [executeAction])
const handleSeek = useCallback((position: number[]) => {
const newPosition = position[0]
executeAction(() => playerService.seek(newPosition), 'seek')
}, [executeAction])
const handleSeek = useCallback(
(position: number[]) => {
const newPosition = position[0]
executeAction(() => playerService.seek(newPosition), 'seek')
},
[executeAction],
)
const handleVolumeChange = useCallback((volume: number[]) => {
const newVolume = volume[0]
executeAction(() => playerService.setVolume(newVolume), 'change volume')
}, [executeAction])
const handleVolumeChange = useCallback(
(volume: number[]) => {
const newVolume = volume[0]
executeAction(() => playerService.setVolume(newVolume), 'change volume')
},
[executeAction],
)
const handleMute = useCallback(() => {
if (state.volume === 0) {
@@ -164,7 +191,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}, [state.volume, executeAction])
const handleModeChange = useCallback(() => {
const modes: PlayerMode[] = ['continuous', 'loop', 'loop_one', 'random', 'single']
const modes: PlayerMode[] = [
'continuous',
'loop',
'loop_one',
'random',
'single',
]
const currentIndex = modes.indexOf(state.mode)
const nextMode = modes[(currentIndex + 1) % modes.length]
executeAction(() => playerService.setMode(nextMode), 'change mode')
@@ -185,7 +218,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
const getModeIcon = () => {
switch (state.mode) {
case 'continuous':
return <ArrowRight className='h-4 w-4' />
return <ArrowRight className="h-4 w-4" />
case 'loop':
return <Repeat className="h-4 w-4" />
case 'loop_one':
@@ -304,7 +337,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={(e) => {
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
@@ -314,8 +347,8 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
/>
<Music
className={cn(
"h-8 w-8 text-muted-foreground",
state.current_sound?.thumbnail ? "hidden" : "block"
'h-8 w-8 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
@@ -328,38 +361,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<h3 className="font-medium text-sm truncate">
{state.current_sound?.name || 'No track selected'}
</h3>
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
{state.current_sound &&
(state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -368,7 +401,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-2 cursor-pointer"
onClick={(e) => {
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
@@ -477,7 +510,12 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Playlist
playlist={state.playlist}
currentIndex={state.index}
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
onTrackSelect={index =>
executeAction(
() => playerService.playAtIndex(index),
'play track',
)
}
/>
</div>
)}
@@ -510,7 +548,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={(e) => {
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
@@ -521,8 +559,8 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
) : null}
<Music
className={cn(
"h-32 w-32 text-muted-foreground",
state.current_sound?.thumbnail ? "hidden" : "block"
'h-32 w-32 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
@@ -533,38 +571,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<h1 className="text-2xl font-bold">
{state.current_sound?.name || 'No track selected'}
</h1>
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
{state.current_sound &&
(state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -573,11 +611,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-3 cursor-pointer"
onClick={(e) => {
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
const newPosition = Math.round(percentage * (state.duration || 0))
const newPosition = Math.round(
percentage * (state.duration || 0),
)
handleSeek([newPosition])
}}
/>
@@ -630,24 +670,14 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{/* Secondary Controls */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleModeChange}
>
<Button size="sm" variant="ghost" onClick={handleModeChange}>
{getModeIcon()}
</Button>
<Badge variant="secondary">
{state.mode.replace('_', ' ')}
</Badge>
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleMute}
>
<Button size="sm" variant="ghost" onClick={handleMute}>
{state.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
@@ -683,7 +713,12 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Playlist
playlist={state.playlist}
currentIndex={state.index}
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
onTrackSelect={index =>
executeAction(
() => playerService.playAtIndex(index),
'play track',
)
}
variant="maximized"
/>
</div>
@@ -696,7 +731,9 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
// Expose expand function for external use
useEffect(() => {
// Store expand function globally so sidebar can access it
const windowWithExpand = window as unknown as { __expandPlayerFromSidebar?: () => void }
const windowWithExpand = window as unknown as {
__expandPlayerFromSidebar?: () => void
}
windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar
return () => {
delete windowWithExpand.__expandPlayerFromSidebar

View File

@@ -1,10 +1,10 @@
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Music, Play } from 'lucide-react'
import { type PlayerPlaylist } from '@/lib/api/services/player'
import { ScrollArea } from '@/components/ui/scroll-area'
import { filesService } from '@/lib/api/services/files'
import { type PlayerPlaylist } from '@/lib/api/services/player'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { Music, Play } from 'lucide-react'
interface PlaylistProps {
playlist: PlayerPlaylist
@@ -17,29 +17,29 @@ export function Playlist({
playlist,
currentIndex,
onTrackSelect,
variant = 'normal'
variant = 'normal',
}: PlaylistProps) {
return (
<div className="w-full">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-sm truncate">
{playlist.name}
</h4>
<h4 className="font-medium text-sm truncate">{playlist.name}</h4>
<Badge variant="secondary" className="text-xs">
{playlist.sounds.length} tracks
</Badge>
</div>
{/* Track List */}
<ScrollArea className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}>
<ScrollArea
className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}
>
<div className="w-full">
{playlist.sounds.map((sound, index) => (
<div
key={sound.id}
className={cn(
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
currentIndex === index && 'bg-primary/10 text-primary'
currentIndex === index && 'bg-primary/10 text-primary',
)}
onClick={() => onTrackSelect(index)}
>
@@ -54,10 +54,12 @@ export function Playlist({
{/* Thumbnail - 1 column */}
<div className="col-span-1">
<div className={cn(
'bg-muted rounded flex items-center justify-center overflow-hidden',
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5'
)}>
<div
className={cn(
'bg-muted rounded flex items-center justify-center overflow-hidden',
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5',
)}
>
{sound.thumbnail ? (
<img
src={filesService.getThumbnailUrl(sound.id)}
@@ -72,11 +74,13 @@ export function Playlist({
{/* Track name - 6 columns (takes most space) */}
<div className="col-span-6">
<span className={cn(
'font-medium truncate block',
variant === 'maximized' ? 'text-sm' : 'text-xs',
currentIndex === index ? 'text-primary' : 'text-foreground'
)}>
<span
className={cn(
'font-medium truncate block',
variant === 'maximized' ? 'text-sm' : 'text-xs',
currentIndex === index ? 'text-primary' : 'text-foreground',
)}
>
{sound.name}
</span>
</div>

View File

@@ -1,10 +1,10 @@
import { Card, CardContent } from '@/components/ui/card'
import { Play, Clock, Weight } from 'lucide-react'
import { type Sound } from '@/lib/api/services/sounds'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { formatSize } from '@/utils/format-size'
import NumberFlow from '@number-flow/react'
import { Clock, Play, Weight } from 'lucide-react'
interface SoundCardProps {
sound: Sound

View File

@@ -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<AuthContextType | null>(null)

View File

@@ -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
@@ -30,7 +45,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
// Get socket URL - use relative URL in production with reverse proxy
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)
})
@@ -112,7 +127,6 @@ export function SocketProvider({ children }: SocketProviderProps) {
}
}, [handleTokenRefresh])
// Initial socket connection
useEffect(() => {
if (loading) return
@@ -146,9 +160,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
}
return (
<SocketContext.Provider value={value}>
{children}
</SocketContext.Provider>
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
)
}

View File

@@ -12,5 +12,6 @@ const initialState: ThemeProviderState = {
setTheme: () => null,
}
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export const ThemeProviderContext =
createContext<ThemeProviderState>(initialState)
export type { Theme, ThemeProviderState }

View File

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

View File

@@ -1,5 +1,5 @@
import { useContext } from 'react'
import { ThemeProviderContext } from '@/contexts/ThemeContext'
import { useContext } from 'react'
export const useTheme = () => {
const context = useContext(ThemeProviderContext)

View File

@@ -1,5 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));

View File

@@ -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<void> | null = null
@@ -11,7 +11,10 @@ export class BaseApiClient implements ApiClient {
this.baseURL = baseURL
}
private buildURL(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
private buildURL(
endpoint: string,
params?: Record<string, string | number | boolean | undefined>,
): string {
let url: URL
if (this.baseURL) {
@@ -37,7 +40,7 @@ export class BaseApiClient implements ApiClient {
method: HttpMethod,
endpoint: string,
data?: unknown,
config: ApiRequestConfig = {}
config: ApiRequestConfig = {},
): Promise<T> {
const {
params,
@@ -90,7 +93,7 @@ export class BaseApiClient implements ApiClient {
throw createApiError(retryResponse, errorData)
}
return await this.safeParseJSON(retryResponse) as T
return (await this.safeParseJSON(retryResponse)) as T
} catch (refreshError) {
this.handleAuthenticationFailure()
throw refreshError
@@ -102,11 +105,14 @@ export class BaseApiClient implements ApiClient {
}
// 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)
@@ -147,11 +153,14 @@ export class BaseApiClient implements ApiClient {
}
private async performTokenRefresh(): Promise<void> {
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))
@@ -176,15 +185,27 @@ export class BaseApiClient implements ApiClient {
return this.request<T>('GET', endpoint, undefined, config)
}
async post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
async post<T>(
endpoint: string,
data?: unknown,
config?: ApiRequestConfig,
): Promise<T> {
return this.request<T>('POST', endpoint, data, config)
}
async put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
async put<T>(
endpoint: string,
data?: unknown,
config?: ApiRequestConfig,
): Promise<T> {
return this.request<T>('PUT', endpoint, data, config)
}
async patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
async patch<T>(
endpoint: string,
data?: unknown,
config?: ApiRequestConfig,
): Promise<T> {
return this.request<T>('PATCH', endpoint, data, config)
}

View File

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

View File

@@ -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<string, unknown>
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<string, unknown>
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<string, string[]>)
return new ValidationError(
message,
errorData?.fields as Record<string, string[]>,
)
case 500:
case 501:
case 502:

View File

@@ -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,10 +11,6 @@ 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,

View File

@@ -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<User[]> {
return apiClient.get<User[]>(`/api/v1/admin/users/`, {
params: { limit, offset }
params: { limit, offset },
})
}
@@ -69,11 +69,15 @@ export class AdminService {
}
async disableUser(userId: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/admin/users/${userId}/disable`)
return apiClient.post<MessageResponse>(
`/api/v1/admin/users/${userId}/disable`,
)
}
async enableUser(userId: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/admin/users/${userId}/enable`)
return apiClient.post<MessageResponse>(
`/api/v1/admin/users/${userId}/enable`,
)
}
async listPlans(): Promise<Plan[]> {
@@ -85,24 +89,35 @@ export class AdminService {
return apiClient.post<ScanResponse>(`/api/v1/admin/sounds/scan`)
}
async normalizeAllSounds(force = false, onePass?: boolean): Promise<NormalizationResponse> {
async normalizeAllSounds(
force = false,
onePass?: boolean,
): Promise<NormalizationResponse> {
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<NormalizationResponse>(url)
}
async normalizeSoundsByType(soundType: 'SDB' | 'TTS' | 'EXT', force = false, onePass?: boolean): Promise<NormalizationResponse> {
async normalizeSoundsByType(
soundType: 'SDB' | 'TTS' | 'EXT',
force = false,
onePass?: boolean,
): Promise<NormalizationResponse> {
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<NormalizationResponse>(url)
}

View File

@@ -72,12 +72,15 @@ export class AuthService {
*/
async login(credentials: LoginRequest): Promise<User> {
// 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<User> {
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<OAuthAuthorizationResponse> {
return apiClient.get<OAuthAuthorizationResponse>(
API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider),
{ skipAuth: true }
{ skipAuth: true },
)
}
@@ -143,21 +149,26 @@ export class AuthService {
async getOAuthProviders(): Promise<OAuthProvidersResponse> {
return apiClient.get<OAuthProvidersResponse>(
API_CONFIG.ENDPOINTS.AUTH.PROVIDERS,
{ skipAuth: true }
{ skipAuth: true },
)
}
/**
* Exchange OAuth temporary code for auth cookies
*/
async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise<ExchangeOAuthTokenResponse> {
async exchangeOAuthToken(
request: ExchangeOAuthTokenRequest,
): Promise<ExchangeOAuthTokenResponse> {
// 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<void> {
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<ApiTokenResponse> {
return apiClient.post<ApiTokenResponse>(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN, request)
async generateApiToken(
request: ApiTokenRequest = {},
): Promise<ApiTokenResponse> {
return apiClient.post<ApiTokenResponse>(
API_CONFIG.ENDPOINTS.AUTH.API_TOKEN,
request,
)
}
/**
* Get API token status
*/
async getApiTokenStatus(): Promise<ApiTokenStatusResponse> {
return apiClient.get<ApiTokenStatusResponse>(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS)
return apiClient.get<ApiTokenStatusResponse>(
API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS,
)
}
/**

View File

@@ -28,7 +28,9 @@ export class ExtractionsService {
* Create a new extraction job
*/
async createExtraction(url: string): Promise<CreateExtractionResponse> {
const response = await apiClient.post<CreateExtractionResponse>(`/api/v1/extractions/?url=${encodeURIComponent(url)}`)
const response = await apiClient.post<CreateExtractionResponse>(
`/api/v1/extractions/?url=${encodeURIComponent(url)}`,
)
return response
}
@@ -36,7 +38,9 @@ export class ExtractionsService {
* Get extraction by ID
*/
async getExtraction(extractionId: number): Promise<ExtractionInfo> {
const response = await apiClient.get<ExtractionInfo>(`/api/v1/extractions/${extractionId}`)
const response = await apiClient.get<ExtractionInfo>(
`/api/v1/extractions/${extractionId}`,
)
return response
}
@@ -44,7 +48,9 @@ export class ExtractionsService {
* Get user's extractions
*/
async getUserExtractions(): Promise<ExtractionInfo[]> {
const response = await apiClient.get<GetExtractionsResponse>('/api/v1/extractions/')
const response = await apiClient.get<GetExtractionsResponse>(
'/api/v1/extractions/',
)
return response.extractions
}
}

View File

@@ -7,10 +7,13 @@ export class FilesService {
async downloadSound(soundId: number): Promise<void> {
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}`)
@@ -59,10 +62,13 @@ export class FilesService {
*/
async hasThumbnail(soundId: number): Promise<boolean> {
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<boolean> {
return new Promise((resolve) => {
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => resolve(false)

View File

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

View File

@@ -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 {
@@ -65,7 +71,9 @@ export class PlaylistsService {
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<Playlist[]>(url)
}
@@ -104,11 +112,14 @@ export class PlaylistsService {
/**
* Update a playlist
*/
async updatePlaylist(id: number, data: {
name?: string
description?: string
genre?: string
}): Promise<Playlist> {
async updatePlaylist(
id: number,
data: {
name?: string
description?: string
genre?: string
},
): Promise<Playlist> {
return apiClient.put<Playlist>(`/api/v1/playlists/${id}`, data)
}
@@ -154,26 +165,36 @@ export class PlaylistsService {
/**
* Add sound to playlist
*/
async addSoundToPlaylist(playlistId: number, soundId: number, position?: number): Promise<void> {
async addSoundToPlaylist(
playlistId: number,
soundId: number,
position?: number,
): Promise<void> {
await apiClient.post(`/api/v1/playlists/${playlistId}/sounds`, {
sound_id: soundId,
position
position,
})
}
/**
* Remove sound from playlist
*/
async removeSoundFromPlaylist(playlistId: number, soundId: number): Promise<void> {
async removeSoundFromPlaylist(
playlistId: number,
soundId: number,
): Promise<void> {
await apiClient.delete(`/api/v1/playlists/${playlistId}/sounds/${soundId}`)
}
/**
* Reorder sounds in playlist
*/
async reorderPlaylistSounds(playlistId: number, soundPositions: Array<[number, number]>): Promise<void> {
async reorderPlaylistSounds(
playlistId: number,
soundPositions: Array<[number, number]>,
): Promise<void> {
await apiClient.put(`/api/v1/playlists/${playlistId}/sounds/reorder`, {
sound_positions: soundPositions
sound_positions: soundPositions,
})
}
}

View File

@@ -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 {
@@ -68,7 +76,9 @@ export class SoundsService {
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<GetSoundsResponse>(url)
return response.sounds || []
}
@@ -76,14 +86,19 @@ export class SoundsService {
/**
* Get sounds of a specific type
*/
async getSoundsByType(type: string, params?: Omit<GetSoundsParams, 'types'>): Promise<Sound[]> {
async getSoundsByType(
type: string,
params?: Omit<GetSoundsParams, 'types'>,
): Promise<Sound[]> {
return this.getSounds({ ...params, types: [type] })
}
/**
* Get SDB type sounds
*/
async getSDBSounds(params?: Omit<GetSoundsParams, 'types'>): Promise<Sound[]> {
async getSDBSounds(
params?: Omit<GetSoundsParams, 'types'>,
): Promise<Sound[]> {
return this.getSoundsByType('SDB', params)
}

View File

@@ -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<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
post<T>(
endpoint: string,
data?: unknown,
config?: ApiRequestConfig,
): Promise<T>
put<T>(
endpoint: string,
data?: unknown,
config?: ApiRequestConfig,
): Promise<T>
patch<T>(
endpoint: string,
data?: unknown,
config?: ApiRequestConfig,
): Promise<T>
delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
}

View File

@@ -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
@@ -42,7 +41,10 @@ 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)
}
@@ -76,7 +78,6 @@ export class TokenRefreshManager {
// 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
*/

View File

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

View File

@@ -1,30 +1,45 @@
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 { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
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'
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
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()
@@ -38,14 +53,15 @@ export function AccountPage() {
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<ApiTokenStatusResponse | null>(null)
const [apiTokenStatus, setApiTokenStatus] =
useState<ApiTokenStatusResponse | null>(null)
const [apiTokenLoading, setApiTokenLoading] = useState(true)
const [generatedToken, setGeneratedToken] = useState('')
const [showGeneratedToken, setShowGeneratedToken] = useState(false)
@@ -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,7 +122,9 @@ 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) {
@@ -130,11 +150,21 @@ 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()
@@ -149,7 +179,7 @@ export function AccountPage() {
const handleGenerateApiToken = async () => {
try {
const response = await authService.generateApiToken({
expires_days: parseInt(tokenExpireDays)
expires_days: parseInt(tokenExpireDays),
})
setGeneratedToken(response.api_token)
setShowGeneratedToken(true)
@@ -194,10 +224,7 @@ export function AccountPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Account' }
]
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -225,10 +252,7 @@ export function AccountPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Account' }
]
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
}}
>
<div className="flex-1 space-y-6">
@@ -264,7 +288,7 @@ export function AccountPage() {
<Input
id="name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
onChange={e => setProfileName(e.target.value)}
placeholder="Enter your display name"
/>
</div>
@@ -272,10 +296,29 @@ export function AccountPage() {
<div className="space-y-2">
<Label>Account Details</Label>
<div className="text-sm text-muted-foreground space-y-1">
<div>Role: <Badge variant={user.role === 'admin' ? 'destructive' : 'secondary'}>{user.role}</Badge></div>
<div>Credits: <span className="font-medium">{user.credits.toLocaleString()}</span></div>
<div>Plan: <span className="font-medium">{user.plan.name}</span></div>
<div>Member since: {new Date(user.created_at).toLocaleDateString()}</div>
<div>
Role:{' '}
<Badge
variant={
user.role === 'admin' ? 'destructive' : 'secondary'
}
>
{user.role}
</Badge>
</div>
<div>
Credits:{' '}
<span className="font-medium">
{user.credits.toLocaleString()}
</span>
</div>
<div>
Plan: <span className="font-medium">{user.plan.name}</span>
</div>
<div>
Member since:{' '}
{new Date(user.created_at).toLocaleDateString()}
</div>
</div>
</div>
@@ -300,7 +343,12 @@ export function AccountPage() {
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Theme Preference</Label>
<Select value={theme} onValueChange={(value: 'light' | 'dark' | 'system') => setTheme(value)}>
<Select
value={theme}
onValueChange={(value: 'light' | 'dark' | 'system') =>
setTheme(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -317,7 +365,8 @@ export function AccountPage() {
<div className="pt-4">
<div className="text-sm text-muted-foreground">
Current theme: <span className="font-medium capitalize">{theme}</span>
Current theme:{' '}
<span className="font-medium capitalize">{theme}</span>
</div>
</div>
</CardContent>
@@ -341,7 +390,12 @@ export function AccountPage() {
id="current-password"
type={showCurrentPassword ? 'text' : 'password'}
value={passwordData.current_password}
onChange={(e) => setPasswordData(prev => ({ ...prev, current_password: e.target.value }))}
onChange={e =>
setPasswordData(prev => ({
...prev,
current_password: e.target.value,
}))
}
placeholder="Enter current password"
/>
<Button
@@ -349,7 +403,9 @@ export function AccountPage() {
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
onClick={() =>
setShowCurrentPassword(!showCurrentPassword)
}
>
{showCurrentPassword ? (
<EyeOff className="h-4 w-4" />
@@ -367,7 +423,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 new password"
/>
<Button
@@ -387,12 +448,19 @@ export function AccountPage() {
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Label htmlFor="confirm-password">
Confirm New Password
</Label>
<Input
id="confirm-password"
type="password"
value={passwordData.confirm_password}
onChange={(e) => setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))}
onChange={e =>
setPasswordData(prev => ({
...prev,
confirm_password: e.target.value,
}))
}
placeholder="Confirm new password"
/>
</div>
@@ -402,7 +470,9 @@ export function AccountPage() {
disabled={passwordSaving}
className="w-full"
>
{passwordSaving ? 'Changing Password...' : 'Change Password'}
{passwordSaving
? 'Changing Password...'
: 'Change Password'}
</Button>
</>
) : (
@@ -411,7 +481,8 @@ export function AccountPage() {
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 <strong>Set up password authentication</strong>
<br />
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.
</p>
</div>
@@ -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"
/>
<Button
@@ -447,7 +523,12 @@ export function AccountPage() {
id="confirm-password"
type="password"
value={passwordData.confirm_password}
onChange={(e) => setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))}
onChange={e =>
setPasswordData(prev => ({
...prev,
confirm_password: e.target.value,
}))
}
placeholder="Confirm your password"
/>
</div>
@@ -487,7 +568,11 @@ export function AccountPage() {
<span>API Token Active</span>
{apiTokenStatus.expires_at && (
<span className="text-muted-foreground">
(Expires: {new Date(apiTokenStatus.expires_at).toLocaleDateString()})
(Expires:{' '}
{new Date(
apiTokenStatus.expires_at,
).toLocaleDateString()}
)
</span>
)}
</div>
@@ -505,7 +590,10 @@ export function AccountPage() {
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="expire-days">Token Expiration</Label>
<Select value={tokenExpireDays} onValueChange={setTokenExpireDays}>
<Select
value={tokenExpireDays}
onValueChange={setTokenExpireDays}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -517,7 +605,10 @@ export function AccountPage() {
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerateApiToken} className="w-full">
<Button
onClick={handleGenerateApiToken}
className="w-full"
>
Generate API Token
</Button>
</div>
@@ -526,7 +617,8 @@ export function AccountPage() {
)}
<div className="text-xs text-muted-foreground">
API tokens allow external applications to access your account programmatically
API tokens allow external applications to access your account
programmatically
</div>
</CardContent>
</Card>
@@ -540,14 +632,18 @@ export function AccountPage() {
Authentication Methods
</CardTitle>
<p className="text-sm text-muted-foreground">
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.
</p>
</CardHeader>
<CardContent>
{providersLoading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between p-3 border rounded-lg">
<div
key={i}
className="flex items-center justify-between p-3 border rounded-lg"
>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-20" />
</div>
@@ -556,24 +652,35 @@ export function AccountPage() {
) : (
<div className="space-y-3">
{/* All Authentication Providers from API */}
{providers.map((provider) => {
{providers.map(provider => {
const isOAuth = provider.provider !== 'password'
return (
<div key={provider.provider} className="flex items-center justify-between p-3 border rounded-lg">
<div
key={provider.provider}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-2">
{getProviderIcon(provider.provider)}
<span className="font-medium">{provider.display_name}</span>
<span className="font-medium">
{provider.display_name}
</span>
<Badge variant="secondary">
{isOAuth ? 'OAuth' : 'Password Authentication'}
</Badge>
{provider.connected_at && (
<span className="text-xs text-muted-foreground">
Connected {new Date(provider.connected_at).toLocaleDateString()}
Connected{' '}
{new Date(
provider.connected_at,
).toLocaleDateString()}
</span>
)}
</div>
<Badge variant="outline" className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20">
<Badge
variant="outline"
className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20"
>
Available
</Badge>
</div>
@@ -589,11 +696,17 @@ export function AccountPage() {
<Badge variant="secondary">API Access</Badge>
{apiTokenStatus.expires_at && (
<span className="text-xs text-muted-foreground">
Expires {new Date(apiTokenStatus.expires_at).toLocaleDateString()}
Expires{' '}
{new Date(
apiTokenStatus.expires_at,
).toLocaleDateString()}
</span>
)}
</div>
<Badge variant="outline" className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20">
<Badge
variant="outline"
className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20"
>
Available
</Badge>
</div>
@@ -637,11 +750,14 @@ export function AccountPage() {
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
⚠️ <strong>Important:</strong> This token will only be shown once.
Copy it now and store it securely.
⚠️ <strong>Important:</strong> This token will only be shown
once. Copy it now and store it securely.
</p>
</div>
<Button onClick={() => setShowGeneratedToken(false)} className="w-full">
<Button
onClick={() => setShowGeneratedToken(false)}
className="w-full"
>
I've Saved My Token
</Button>
</div>

View File

@@ -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<string>('')
useEffect(() => {
@@ -35,10 +37,11 @@ export function AuthCallbackPage() {
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
@@ -57,25 +60,37 @@ export function AuthCallbackPage() {
{status === 'processing' && (
<div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<h2 className="mt-4 text-xl font-semibold">Completing sign in...</h2>
<p className="text-gray-600 dark:text-gray-400">Please wait while we set up your account.</p>
<h2 className="mt-4 text-xl font-semibold">
Completing sign in...
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we set up your account.
</p>
</div>
)}
{status === 'success' && (
<div>
<div className="text-green-600 text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-green-600">Sign in successful!</h2>
<p className="text-gray-600 dark:text-gray-400">Redirecting to dashboard...</p>
<h2 className="text-xl font-semibold text-green-600">
Sign in successful!
</h2>
<p className="text-gray-600 dark:text-gray-400">
Redirecting to dashboard...
</p>
</div>
)}
{status === 'error' && (
<div>
<div className="text-red-600 text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-red-600">Sign in failed</h2>
<h2 className="text-xl font-semibold text-red-600">
Sign in failed
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">{error}</p>
<p className="text-sm text-gray-500">Redirecting to login page...</p>
<p className="text-sm text-gray-500">
Redirecting to login page...
</p>
</div>
)}
</div>

View File

@@ -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,8 +47,10 @@ interface TopSound {
}
export function DashboardPage() {
const [soundboardStatistics, setSoundboardStatistics] = useState<SoundboardStatistics | null>(null)
const [trackStatistics, setTrackStatistics] = useState<TrackStatistics | null>(null)
const [soundboardStatistics, setSoundboardStatistics] =
useState<SoundboardStatistics | null>(null)
const [trackStatistics, setTrackStatistics] =
useState<TrackStatistics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -48,8 +65,10 @@ 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) {
@@ -58,7 +77,7 @@ export function DashboardPage() {
const [soundboardData, trackData] = await Promise.all([
soundboardResponse.json(),
trackResponse.json()
trackResponse.json(),
])
setSoundboardStatistics(soundboardData)
@@ -68,61 +87,63 @@ export function DashboardPage() {
}
}, [])
const fetchTopSounds = useCallback(async (showLoading = false) => {
try {
if (showLoading) {
setTopSoundsLoading(true)
}
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' }
)
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')
}
if (!response.ok) {
throw new Error('Failed to fetch top sounds')
}
const data = await response.json()
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]))
// 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
// 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)
}
@@ -158,9 +179,7 @@ export function DashboardPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard' }
]
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -174,30 +193,42 @@ export function DashboardPage() {
</div>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Soundboard Statistics</h2>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Soundboard Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
<CardTitle className="text-sm font-medium">
Loading...
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold animate-pulse">---</div>
<div className="text-2xl font-bold animate-pulse">
---
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Track Statistics</h2>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Track Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i + 4}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
<CardTitle className="text-sm font-medium">
Loading...
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold animate-pulse">---</div>
<div className="text-2xl font-bold animate-pulse">
---
</div>
</CardContent>
</Card>
))}
@@ -213,9 +244,7 @@ export function DashboardPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard' }
]
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -228,7 +257,9 @@ export function DashboardPage() {
</div>
</div>
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4">
<p className="text-destructive">Error loading statistics: {error}</p>
<p className="text-destructive">
Error loading statistics: {error}
</p>
</div>
</div>
</AppLayout>
@@ -238,9 +269,7 @@ export function DashboardPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard' }
]
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -257,22 +286,30 @@ export function DashboardPage() {
size="sm"
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="space-y-6">
{/* Soundboard Statistics */}
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Soundboard Statistics</h2>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Soundboard Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sounds</CardTitle>
<CardTitle className="text-sm font-medium">
Total Sounds
</CardTitle>
<Volume2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"><NumberFlow value={soundboardStatistics.sound_count} /></div>
<div className="text-2xl font-bold">
<NumberFlow value={soundboardStatistics.sound_count} />
</div>
<p className="text-xs text-muted-foreground">
Soundboard audio files
</p>
@@ -281,11 +318,15 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Plays</CardTitle>
<CardTitle className="text-sm font-medium">
Total Plays
</CardTitle>
<Play className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"><NumberFlow value={soundboardStatistics.total_play_count} /></div>
<div className="text-2xl font-bold">
<NumberFlow value={soundboardStatistics.total_play_count} />
</div>
<p className="text-xs text-muted-foreground">
All-time play count
</p>
@@ -294,12 +335,17 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Duration</CardTitle>
<CardTitle className="text-sm font-medium">
Total Duration
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowDuration duration={soundboardStatistics.total_duration} variant='wordy' />
<NumberFlowDuration
duration={soundboardStatistics.total_duration}
variant="wordy"
/>
</div>
<p className="text-xs text-muted-foreground">
Combined audio duration
@@ -309,12 +355,17 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Size</CardTitle>
<CardTitle className="text-sm font-medium">
Total Size
</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowSize size={soundboardStatistics.total_size} binary={true} />
<NumberFlowSize
size={soundboardStatistics.total_size}
binary={true}
/>
</div>
<p className="text-xs text-muted-foreground">
Original + normalized files
@@ -326,15 +377,21 @@ export function DashboardPage() {
{/* Track Statistics */}
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Track Statistics</h2>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
Track Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Tracks</CardTitle>
<CardTitle className="text-sm font-medium">
Total Tracks
</CardTitle>
<Music className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"><NumberFlow value={trackStatistics.track_count} /></div>
<div className="text-2xl font-bold">
<NumberFlow value={trackStatistics.track_count} />
</div>
<p className="text-xs text-muted-foreground">
Extracted audio tracks
</p>
@@ -343,11 +400,15 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Plays</CardTitle>
<CardTitle className="text-sm font-medium">
Total Plays
</CardTitle>
<Play className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"><NumberFlow value={trackStatistics.total_play_count} /></div>
<div className="text-2xl font-bold">
<NumberFlow value={trackStatistics.total_play_count} />
</div>
<p className="text-xs text-muted-foreground">
All-time play count
</p>
@@ -356,12 +417,17 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Duration</CardTitle>
<CardTitle className="text-sm font-medium">
Total Duration
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowDuration duration={trackStatistics.total_duration} variant='wordy' />
<NumberFlowDuration
duration={trackStatistics.total_duration}
variant="wordy"
/>
</div>
<p className="text-xs text-muted-foreground">
Combined track duration
@@ -371,12 +437,17 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Size</CardTitle>
<CardTitle className="text-sm font-medium">
Total Size
</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<NumberFlowSize size={trackStatistics.total_size} binary={true} />
<NumberFlowSize
size={trackStatistics.total_size}
binary={true}
/>
</div>
<p className="text-xs text-muted-foreground">
Original + normalized files
@@ -428,7 +499,10 @@ export function DashboardPage() {
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Count:</span>
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
<Select
value={limit.toString()}
onValueChange={value => setLimit(parseInt(value))}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
@@ -457,18 +531,26 @@ export function DashboardPage() {
) : (
<div className="space-y-3">
{topSounds.map((sound, index) => (
<div key={sound.id} className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg">
<div
key={sound.id}
className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg"
>
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
{index + 1}
</div>
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{sound.name}</div>
<div className="font-medium truncate">
{sound.name}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
{sound.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<NumberFlowDuration duration={sound.duration} variant='wordy' />
<NumberFlowDuration
duration={sound.duration}
variant="wordy"
/>
</span>
)}
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
@@ -477,8 +559,12 @@ export function DashboardPage() {
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-primary"><NumberFlow value={sound.play_count} /></div>
<div className="text-xs text-muted-foreground">plays</div>
<div className="text-2xl font-bold text-primary">
<NumberFlow value={sound.play_count} />
</div>
<div className="text-xs text-muted-foreground">
plays
</div>
</div>
</div>
))}

View File

@@ -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<ExtractionInfo[]>([])
@@ -63,13 +88,33 @@ export function ExtractionsPage() {
const getStatusBadge = (status: ExtractionInfo['status']) => {
switch (status) {
case 'pending':
return <Badge variant="secondary" className="gap-1"><Clock className="h-3 w-3" />Pending</Badge>
return (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
Pending
</Badge>
)
case 'processing':
return <Badge variant="outline" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" />Processing</Badge>
return (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Processing
</Badge>
)
case 'completed':
return <Badge variant="default" className="gap-1"><CheckCircle className="h-3 w-3" />Completed</Badge>
return (
<Badge variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
Completed
</Badge>
)
case 'failed':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Failed</Badge>
return (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Failed
</Badge>
)
}
}
@@ -78,14 +123,18 @@ export function ExtractionsPage() {
const serviceColors: Record<string, string> = {
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 (
<Badge variant="outline" className={colorClass}>
@@ -97,10 +146,7 @@ export function ExtractionsPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Extractions' }
]
items: [{ label: 'Dashboard', href: '/' }, { label: 'Extractions' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -130,22 +176,29 @@ export function ExtractionsPage() {
id="url"
placeholder="https://www.youtube.com/watch?v=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !isCreating) {
handleCreateExtraction()
}
}}
/>
<p className="text-sm text-muted-foreground mt-1">
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, Instagram, and more
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
Instagram, and more
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleCreateExtraction} disabled={isCreating}>
<Button
onClick={handleCreateExtraction}
disabled={isCreating}
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -171,9 +224,12 @@ export function ExtractionsPage() {
<CardContent className="py-8">
<div className="text-center">
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No extractions yet</h3>
<h3 className="text-lg font-semibold mb-2">
No extractions yet
</h3>
<p className="text-muted-foreground mb-4">
Start by adding a URL to extract audio from your favorite platforms
Start by adding a URL to extract audio from your favorite
platforms
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
@@ -199,7 +255,7 @@ export function ExtractionsPage() {
</TableRow>
</TableHeader>
<TableBody>
{extractions.map((extraction) => (
{extractions.map(extraction => (
<TableRow key={extraction.id}>
<TableCell>
<div>
@@ -217,7 +273,10 @@ export function ExtractionsPage() {
<TableCell>
{getStatusBadge(extraction.status)}
{extraction.error && (
<div className="text-xs text-destructive mt-1 max-w-48 truncate" title={extraction.error}>
<div
className="text-xs text-destructive mt-1 max-w-48 truncate"
title={extraction.error}
>
{extraction.error}
</div>
)}
@@ -231,7 +290,9 @@ export function ExtractionsPage() {
if (isNaN(date.getTime())) {
return 'Invalid date'
}
return formatDistanceToNow(date, { addSuffix: true })
return formatDistanceToNow(date, {
addSuffix: true,
})
} catch {
return 'Invalid date'
}
@@ -241,15 +302,24 @@ export function ExtractionsPage() {
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" asChild>
<a href={extraction.url} target="_blank" rel="noopener noreferrer">
<a
href={extraction.url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
{extraction.status === 'completed' && extraction.sound_id && (
<Button variant="ghost" size="sm" title="View in Sounds">
<Download className="h-4 w-4" />
</Button>
)}
{extraction.status === 'completed' &&
extraction.sound_id && (
<Button
variant="ghost"
size="sm"
title="View in Sounds"
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router'
import { LoginForm } from '@/components/auth/LoginForm'
import { Link } from 'react-router'
export function LoginPage() {
return (

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,59 @@
import { AppLayout } from '@/components/AppLayout'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 PlaylistSortField,
type SortOrder,
playlistsService,
} from '@/lib/api/services/playlists'
import { formatDuration } from '@/utils/format-duration'
import {
AlertCircle,
Calendar,
Clock,
Edit,
Music,
Play,
Plus,
RefreshCw,
Search,
SortAsc,
SortDesc,
User,
X,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { AppLayout } from '@/components/AppLayout'
import { playlistsService, type Playlist, type PlaylistSortField, type SortOrder } from '@/lib/api/services/playlists'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw, Music, User, Calendar, Clock, Plus, Play, Edit } from 'lucide-react'
import { toast } from 'sonner'
import { formatDuration } from '@/utils/format-duration'
export function PlaylistsPage() {
const navigate = useNavigate()
@@ -32,7 +72,7 @@ export function PlaylistsPage() {
const [newPlaylist, setNewPlaylist] = useState({
name: '',
description: '',
genre: ''
genre: '',
})
// Debounce search query
@@ -57,7 +97,8 @@ export function PlaylistsPage() {
})
setPlaylists(playlistData)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlists'
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch playlists'
setError(errorMessage)
toast.error(errorMessage)
} finally {
@@ -92,7 +133,8 @@ export function PlaylistsPage() {
// Refresh the playlists list
fetchPlaylists()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create playlist'
const errorMessage =
err instanceof Error ? err.message : 'Failed to create playlist'
toast.error(errorMessage)
} finally {
setCreateLoading(false)
@@ -112,7 +154,8 @@ export function PlaylistsPage() {
// Refresh the playlists list to update the current status
fetchPlaylists()
} 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)
}
}
@@ -137,7 +180,9 @@ export function PlaylistsPage() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to load playlists</h3>
<h3 className="text-lg font-semibold mb-2">
Failed to load playlists
</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={fetchPlaylists}
@@ -157,7 +202,9 @@ export function PlaylistsPage() {
</div>
<h3 className="text-lg font-semibold mb-2">No playlists found</h3>
<p className="text-muted-foreground">
{searchQuery ? 'No playlists match your search criteria.' : 'No playlists are available.'}
{searchQuery
? 'No playlists match your search criteria.'
: 'No playlists are available.'}
</p>
</div>
)
@@ -179,13 +226,15 @@ export function PlaylistsPage() {
</TableRow>
</TableHeader>
<TableBody>
{playlists.map((playlist) => (
{playlists.map(playlist => (
<TableRow key={playlist.id} className="hover:bg-muted/50">
<TableCell>
<div className="flex items-center gap-2">
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<div className="font-medium truncate">{playlist.name}</div>
<div className="font-medium truncate">
{playlist.name}
</div>
{playlist.description && (
<div className="text-sm text-muted-foreground truncate">
{playlist.description}
@@ -234,9 +283,7 @@ export function PlaylistsPage() {
{playlist.is_current && (
<Badge variant="default">Current</Badge>
)}
{playlist.is_main && (
<Badge variant="outline">Main</Badge>
)}
{playlist.is_main && <Badge variant="outline">Main</Badge>}
{!playlist.is_current && !playlist.is_main && (
<span className="text-muted-foreground">-</span>
)}
@@ -277,10 +324,7 @@ export function PlaylistsPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Playlists' }
]
items: [{ label: 'Dashboard', href: '/' }, { label: 'Playlists' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -303,7 +347,8 @@ export function PlaylistsPage() {
<DialogHeader>
<DialogTitle>Create New Playlist</DialogTitle>
<DialogDescription>
Add a new playlist to organize your sounds. Give it a name and optionally add a description and genre.
Add a new playlist to organize your sounds. Give it a name
and optionally add a description and genre.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
@@ -313,8 +358,13 @@ export function PlaylistsPage() {
id="name"
placeholder="My awesome playlist"
value={newPlaylist.name}
onChange={(e) => setNewPlaylist(prev => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => {
onChange={e =>
setNewPlaylist(prev => ({
...prev,
name: e.target.value,
}))
}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleCreatePlaylist()
@@ -328,7 +378,12 @@ export function PlaylistsPage() {
id="description"
placeholder="A collection of my favorite sounds..."
value={newPlaylist.description}
onChange={(e) => setNewPlaylist(prev => ({ ...prev, description: e.target.value }))}
onChange={e =>
setNewPlaylist(prev => ({
...prev,
description: e.target.value,
}))
}
className="min-h-[80px]"
/>
</div>
@@ -338,15 +393,27 @@ export function PlaylistsPage() {
id="genre"
placeholder="Electronic, Rock, Comedy, etc."
value={newPlaylist.genre}
onChange={(e) => setNewPlaylist(prev => ({ ...prev, genre: e.target.value }))}
onChange={e =>
setNewPlaylist(prev => ({
...prev,
genre: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelCreate} disabled={createLoading}>
<Button
variant="outline"
onClick={handleCancelCreate}
disabled={createLoading}
>
Cancel
</Button>
<Button onClick={handleCreatePlaylist} disabled={createLoading || !newPlaylist.name.trim()}>
<Button
onClick={handleCreatePlaylist}
disabled={createLoading || !newPlaylist.name.trim()}
>
{createLoading ? 'Creating...' : 'Create Playlist'}
</Button>
</DialogFooter>
@@ -368,7 +435,7 @@ export function PlaylistsPage() {
<Input
placeholder="Search playlists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={e => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
@@ -386,7 +453,10 @@ export function PlaylistsPage() {
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => setSortBy(value as PlaylistSortField)}>
<Select
value={sortBy}
onValueChange={value => setSortBy(value as PlaylistSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
@@ -406,7 +476,11 @@ export function PlaylistsPage() {
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
>
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
{sortOrder === 'asc' ? (
<SortAsc className="h-4 w-4" />
) : (
<SortDesc className="h-4 w-4" />
)}
</Button>
<Button
@@ -416,7 +490,9 @@ export function PlaylistsPage() {
disabled={loading}
title="Refresh playlists"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router'
import { RegisterForm } from '@/components/auth/RegisterForm'
import { Link } from 'react-router'
export function RegisterPage() {
return (

View File

@@ -1,15 +1,33 @@
import { useEffect, useState } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { SoundCard } from '@/components/sounds/SoundCard'
import { soundsService, type Sound, type SoundSortField, type SortOrder } from '@/lib/api/services/sounds'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useTheme } from '@/hooks/use-theme'
import { soundEvents, SOUND_EVENTS } from '@/lib/events'
import {
type SortOrder,
type Sound,
type SoundSortField,
soundsService,
} from '@/lib/api/services/sounds'
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
import {
AlertCircle,
RefreshCw,
Search,
SortAsc,
SortDesc,
X,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
interface SoundPlayedEventData {
sound_id: number
@@ -65,7 +83,9 @@ export function SoundsPage() {
await soundsService.playSound(sound.id)
toast.success(`Playing: ${sound.name || sound.filename}`)
} catch (error) {
toast.error(`Failed to play sound: ${error instanceof Error ? error.message : 'Unknown error'}`)
toast.error(
`Failed to play sound: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}
@@ -95,7 +115,8 @@ export function SoundsPage() {
})
setSounds(sdbSounds)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch sounds'
setError(errorMessage)
toast.error(errorMessage)
} finally {
@@ -125,8 +146,8 @@ export function SoundsPage() {
prevSounds.map(sound =>
sound.id === eventData.sound_id
? { ...sound, play_count: eventData.play_count }
: sound
)
: sound,
),
)
}
@@ -183,7 +204,12 @@ export function SoundsPage() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{sounds.map((sound, idx) => (
<SoundCard key={sound.id} sound={sound} playSound={handlePlaySound} colorClasses={getSoundColor(idx)} />
<SoundCard
key={sound.id}
sound={sound}
playSound={handlePlaySound}
colorClasses={getSoundColor(idx)}
/>
))}
</div>
)
@@ -192,10 +218,7 @@ export function SoundsPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Sounds' }
]
items: [{ label: 'Dashboard', href: '/' }, { label: 'Sounds' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -221,7 +244,7 @@ export function SoundsPage() {
<Input
placeholder="Search sounds..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={e => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
@@ -239,7 +262,10 @@ export function SoundsPage() {
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SoundSortField)}>
<Select
value={sortBy}
onValueChange={value => setSortBy(value as SoundSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
@@ -259,7 +285,11 @@ export function SoundsPage() {
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
>
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
{sortOrder === 'asc' ? (
<SortAsc className="h-4 w-4" />
) : (
<SortDesc className="h-4 w-4" />
)}
</Button>
<Button
@@ -269,7 +299,9 @@ export function SoundsPage() {
disabled={loading}
title="Refresh sounds"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>

View File

@@ -1,41 +1,56 @@
import { useState } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from 'sonner'
import {
Scan,
Volume2,
Settings as SettingsIcon,
Loader2,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
type NormalizationResponse,
type ScanResponse,
adminService,
} from '@/lib/api/services/admin'
import {
AudioWaveform,
FolderSync,
AudioWaveform
Loader2,
Scan,
Settings as SettingsIcon,
Volume2,
} from 'lucide-react'
import { adminService, type ScanResponse, type NormalizationResponse } from '@/lib/api/services/admin'
import { useState } from 'react'
import { toast } from 'sonner'
export function SettingsPage() {
// Sound scanning state
const [scanningInProgress, setScanningInProgress] = useState(false)
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(null)
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(
null,
)
// Sound normalization state
const [normalizationInProgress, setNormalizationInProgress] = useState(false)
const [normalizationOptions, setNormalizationOptions] = useState({
force: false,
onePass: false,
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT'
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT',
})
const [lastNormalizationResults, setLastNormalizationResults] = useState<NormalizationResponse | null>(null)
const [lastNormalizationResults, setLastNormalizationResults] =
useState<NormalizationResponse | null>(null)
const handleScanSounds = async () => {
setScanningInProgress(true)
try {
const response = await adminService.scanSounds()
setLastScanResults(response)
toast.success(`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`)
toast.success(
`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`,
)
} catch (error) {
toast.error('Failed to scan sounds')
console.error('Sound scan error:', error)
@@ -52,18 +67,20 @@ export function SettingsPage() {
if (normalizationOptions.soundType === 'all') {
response = await adminService.normalizeAllSounds(
normalizationOptions.force,
normalizationOptions.onePass
normalizationOptions.onePass,
)
} else {
response = await adminService.normalizeSoundsByType(
normalizationOptions.soundType,
normalizationOptions.force,
normalizationOptions.onePass
normalizationOptions.onePass,
)
}
setLastNormalizationResults(response)
toast.success(`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`)
toast.success(
`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`,
)
} catch (error) {
toast.error('Failed to normalize sounds')
console.error('Sound normalization error:', error)
@@ -78,8 +95,8 @@ export function SettingsPage() {
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Admin' },
{ label: 'Settings' }
]
{ label: 'Settings' },
],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -104,7 +121,8 @@ export function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Scan the sound directories to synchronize new, updated, and deleted audio files with the database.
Scan the sound directories to synchronize new, updated, and
deleted audio files with the database.
</p>
<Button
@@ -134,7 +152,9 @@ export function SettingsPage() {
<div>🗑 Deleted: {lastScanResults.results.deleted}</div>
<div> Skipped: {lastScanResults.results.skipped}</div>
{lastScanResults.results.errors.length > 0 && (
<div> Errors: {lastScanResults.results.errors.length}</div>
<div>
Errors: {lastScanResults.results.errors.length}
</div>
)}
</div>
</div>
@@ -152,7 +172,8 @@ export function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Normalize audio levels across all sounds using FFmpeg's loudnorm filter for consistent volume.
Normalize audio levels across all sounds using FFmpeg's loudnorm
filter for consistent volume.
</p>
<div className="space-y-3">
@@ -161,7 +182,10 @@ export function SettingsPage() {
<Select
value={normalizationOptions.soundType}
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
setNormalizationOptions(prev => ({ ...prev, soundType: value }))
setNormalizationOptions(prev => ({
...prev,
soundType: value,
}))
}
>
<SelectTrigger>
@@ -180,8 +204,11 @@ export function SettingsPage() {
<Checkbox
id="force-normalize"
checked={normalizationOptions.force}
onCheckedChange={(checked) =>
setNormalizationOptions(prev => ({ ...prev, force: !!checked }))
onCheckedChange={checked =>
setNormalizationOptions(prev => ({
...prev,
force: !!checked,
}))
}
/>
<Label htmlFor="force-normalize" className="text-sm">
@@ -193,8 +220,11 @@ export function SettingsPage() {
<Checkbox
id="one-pass"
checked={normalizationOptions.onePass}
onCheckedChange={(checked) =>
setNormalizationOptions(prev => ({ ...prev, onePass: !!checked }))
onCheckedChange={checked =>
setNormalizationOptions(prev => ({
...prev,
onePass: !!checked,
}))
}
/>
<Label htmlFor="one-pass" className="text-sm">
@@ -223,19 +253,35 @@ export function SettingsPage() {
{lastNormalizationResults && (
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium">Last Normalization Results:</div>
<div className="text-sm font-medium">
Last Normalization Results:
</div>
<div className="text-xs text-muted-foreground space-y-1">
<div>🔄 Processed: {lastNormalizationResults.results.processed}</div>
<div> Normalized: {lastNormalizationResults.results.normalized}</div>
<div> Skipped: {lastNormalizationResults.results.skipped}</div>
<div> Errors: {lastNormalizationResults.results.errors}</div>
{lastNormalizationResults.results.error_details.length > 0 && (
<div>
🔄 Processed: {lastNormalizationResults.results.processed}
</div>
<div>
✅ Normalized:{' '}
{lastNormalizationResults.results.normalized}
</div>
<div>
Skipped: {lastNormalizationResults.results.skipped}
</div>
<div>
Errors: {lastNormalizationResults.results.errors}
</div>
{lastNormalizationResults.results.error_details.length >
0 && (
<details className="mt-2">
<summary className="cursor-pointer text-red-600">View Error Details</summary>
<summary className="cursor-pointer text-red-600">
View Error Details
</summary>
<div className="mt-1 text-xs text-red-600 space-y-1">
{lastNormalizationResults.results.error_details.map((error, index) => (
<div key={index}> {error}</div>
))}
{lastNormalizationResults.results.error_details.map(
(error, index) => (
<div key={index}> {error}</div>
),
)}
</div>
</details>
)}

View File

@@ -1,19 +1,32 @@
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Edit, UserCheck, UserX } from 'lucide-react'
import { adminService, type Plan } from '@/lib/api/services/admin'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { type Plan, adminService } from '@/lib/api/services/admin'
import type { User } from '@/types/auth'
import { Edit, UserCheck, UserX } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
interface EditUserData {
name: string
@@ -31,7 +44,7 @@ export function UsersPage() {
name: '',
plan_id: 0,
credits: 0,
is_active: true
is_active: true,
})
const [saving, setSaving] = useState(false)
@@ -43,7 +56,7 @@ export function UsersPage() {
try {
const [usersData, plansData] = await Promise.all([
adminService.listUsers(),
adminService.listPlans()
adminService.listPlans(),
])
setUsers(usersData)
setPlans(plansData)
@@ -61,7 +74,7 @@ export function UsersPage() {
name: user.name,
plan_id: user.plan.id,
credits: user.credits,
is_active: user.is_active
is_active: user.is_active,
})
}
@@ -70,8 +83,13 @@ export function UsersPage() {
setSaving(true)
try {
const updatedUser = await adminService.updateUser(editingUser.id, editData)
setUsers(prev => prev.map(u => u.id === editingUser.id ? updatedUser : u))
const updatedUser = await adminService.updateUser(
editingUser.id,
editData,
)
setUsers(prev =>
prev.map(u => (u.id === editingUser.id ? updatedUser : u)),
)
setEditingUser(null)
toast.success('User updated successfully')
} catch (error) {
@@ -122,8 +140,8 @@ export function UsersPage() {
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Admin' },
{ label: 'Users' }
]
{ label: 'Users' },
],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -157,8 +175,8 @@ export function UsersPage() {
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Admin' },
{ label: 'Users' }
]
{ label: 'Users' },
],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -192,7 +210,7 @@ export function UsersPage() {
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
{users.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
@@ -231,7 +249,10 @@ export function UsersPage() {
</div>
{/* Edit User Sheet */}
<Sheet open={!!editingUser} onOpenChange={(open) => !open && setEditingUser(null)}>
<Sheet
open={!!editingUser}
onOpenChange={open => !open && setEditingUser(null)}
>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<div className="px-6">
<div className="pt-4 pb-6">
@@ -246,24 +267,44 @@ export function UsersPage() {
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-3 gap-2 text-sm">
<span className="text-muted-foreground font-medium">User ID:</span>
<span className="col-span-2 font-mono">{editingUser.id}</span>
<span className="text-muted-foreground font-medium">
User ID:
</span>
<span className="col-span-2 font-mono">
{editingUser.id}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<span className="text-muted-foreground font-medium">Email:</span>
<span className="col-span-2 break-all">{editingUser.email}</span>
<span className="text-muted-foreground font-medium">
Email:
</span>
<span className="col-span-2 break-all">
{editingUser.email}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<span className="text-muted-foreground font-medium">Role:</span>
<span className="col-span-2">{getRoleBadge(editingUser.role)}</span>
<span className="text-muted-foreground font-medium">
Role:
</span>
<span className="col-span-2">
{getRoleBadge(editingUser.role)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<span className="text-muted-foreground font-medium">Created:</span>
<span className="col-span-2">{new Date(editingUser.created_at).toLocaleDateString()}</span>
<span className="text-muted-foreground font-medium">
Created:
</span>
<span className="col-span-2">
{new Date(editingUser.created_at).toLocaleDateString()}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<span className="text-muted-foreground font-medium">Last Updated:</span>
<span className="col-span-2">{new Date(editingUser.updated_at).toLocaleDateString()}</span>
<span className="text-muted-foreground font-medium">
Last Updated:
</span>
<span className="col-span-2">
{new Date(editingUser.updated_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
@@ -274,11 +315,18 @@ export function UsersPage() {
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">Display Name</Label>
<Label htmlFor="name" className="text-sm font-medium">
Display Name
</Label>
<Input
id="name"
value={editData.name}
onChange={(e) => setEditData(prev => ({ ...prev, name: e.target.value }))}
onChange={e =>
setEditData(prev => ({
...prev,
name: e.target.value,
}))
}
placeholder="Enter user's display name"
className="h-10"
/>
@@ -288,21 +336,32 @@ export function UsersPage() {
</div>
<div className="space-y-2">
<Label htmlFor="plan" className="text-sm font-medium">Subscription Plan</Label>
<Label htmlFor="plan" className="text-sm font-medium">
Subscription Plan
</Label>
<Select
value={editData.plan_id.toString()}
onValueChange={(value) => setEditData(prev => ({ ...prev, plan_id: parseInt(value) }))}
onValueChange={value =>
setEditData(prev => ({
...prev,
plan_id: parseInt(value),
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id.toString()}>
{plans.map(plan => (
<SelectItem
key={plan.id}
value={plan.id.toString()}
>
<div className="flex flex-col items-start">
<span className="font-medium">{plan.name}</span>
<span className="text-xs text-muted-foreground">
{plan.max_credits.toLocaleString()} max credits
{plan.max_credits.toLocaleString()} max
credits
</span>
</div>
</SelectItem>
@@ -310,40 +369,64 @@ export function UsersPage() {
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Current plan: <span className="font-medium">{editingUser.plan.name}</span>
Current plan:{' '}
<span className="font-medium">
{editingUser.plan.name}
</span>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="credits" className="text-sm font-medium">Current Credits</Label>
<Label htmlFor="credits" className="text-sm font-medium">
Current Credits
</Label>
<Input
id="credits"
type="number"
min="0"
step="1"
value={editData.credits}
onChange={(e) => setEditData(prev => ({ ...prev, credits: parseInt(e.target.value) || 0 }))}
onChange={e =>
setEditData(prev => ({
...prev,
credits: parseInt(e.target.value) || 0,
}))
}
placeholder="Enter credit amount"
className="h-10"
/>
<p className="text-xs text-muted-foreground">
Maximum allowed: <span className="font-medium">{editingUser.plan.max_credits.toLocaleString()}</span>
Maximum allowed:{' '}
<span className="font-medium">
{editingUser.plan.max_credits.toLocaleString()}
</span>
</p>
</div>
<div className="space-y-3">
<Label className="text-sm font-medium">Account Status</Label>
<Label className="text-sm font-medium">
Account Status
</Label>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex flex-col">
<span className="text-sm font-medium">Allow Login Access</span>
<span className="text-sm font-medium">
Allow Login Access
</span>
<span className="text-xs text-muted-foreground">
{editData.is_active ? 'User can log in and use the platform' : 'User is blocked from logging in and accessing the platform'}
{editData.is_active
? 'User can log in and use the platform'
: 'User is blocked from logging in and accessing the platform'}
</span>
</div>
<Switch
id="active"
checked={editData.is_active}
onCheckedChange={(checked) => setEditData(prev => ({ ...prev, is_active: checked }))}
onCheckedChange={checked =>
setEditData(prev => ({
...prev,
is_active: checked,
}))
}
/>
</div>
</div>

View File

@@ -39,7 +39,7 @@ function parseSize(bytes: number, binary: boolean = false): FileSize {
return {
value: Math.round(value * 100) / 100, // Round to 2 decimal places
unit: FILE_SIZE_UNITS[safeUnitIndex]
unit: FILE_SIZE_UNITS[safeUnitIndex],
}
}
@@ -60,6 +60,9 @@ export function formatSize(bytes: number, binary: boolean = false): string {
* @param binary Whether to use binary (1024) or decimal (1000) units
* @returns Object with numeric value and unit string
*/
export function formatSizeObject(bytes: number, binary: boolean = false): FileSize {
export function formatSizeObject(
bytes: number,
binary: boolean = false,
): FileSize {
return parseSize(bytes, binary)
}