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

@@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}

View File

@@ -1,100 +1,138 @@
import { Routes, Route, Navigate } from 'react-router'
import { Navigate, Route, Routes } from 'react-router'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { SocketProvider } from './contexts/SocketContext'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { AccountPage } from './pages/AccountPage'
import { AuthCallbackPage } from './pages/AuthCallbackPage'
import { DashboardPage } from './pages/DashboardPage'
import { SoundsPage } from './pages/SoundsPage'
import { PlaylistsPage } from './pages/PlaylistsPage'
import { PlaylistEditPage } from './pages/PlaylistEditPage'
import { ExtractionsPage } from './pages/ExtractionsPage'
import { UsersPage } from './pages/admin/UsersPage'
import { LoginPage } from './pages/LoginPage'
import { PlaylistEditPage } from './pages/PlaylistEditPage'
import { PlaylistsPage } from './pages/PlaylistsPage'
import { RegisterPage } from './pages/RegisterPage'
import { SoundsPage } from './pages/SoundsPage'
import { SettingsPage } from './pages/admin/SettingsPage'
import { AccountPage } from './pages/AccountPage'
import { Toaster } from './components/ui/sonner'
import { UsersPage } from './pages/admin/UsersPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return <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) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
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) {
return <Navigate to="/login" replace />
}
if (user.role !== 'admin') {
return <Navigate to="/" replace />
}
return <>{children}</>
}
function AppRoutes() {
const { user } = useAuth()
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" />
@@ -73,4 +75,4 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
<SidebarRail />
</Sidebar>
)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { api } from '@/lib/api'
import { useEffect, useState } from 'react'
export function OAuthButtons() {
const [providers, setProviders] = useState<string[]>([])
@@ -24,10 +24,10 @@ export function OAuthButtons() {
setLoading(provider)
try {
const response = await api.auth.getOAuthUrl(provider)
// Store state in sessionStorage for verification
sessionStorage.setItem('oauth_state', response.state)
// Redirect to OAuth provider
window.location.href = response.authorization_url
} catch (error) {
@@ -90,9 +90,9 @@ export function OAuthButtons() {
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-3">
{providers.map((provider) => (
{providers.map(provider => (
<Button
key={provider}
variant="outline"
@@ -107,14 +107,13 @@ export function OAuthButtons() {
getProviderIcon(provider)
)}
<span className="ml-2">
{loading === provider
? 'Connecting...'
: `Continue with ${getProviderName(provider)}`
}
{loading === provider
? 'Connecting...'
: `Continue with ${getProviderName(provider)}`}
</span>
</Button>
))}
</div>
</div>
)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
import type { LucideIcon } from 'lucide-react'
import { Link, useLocation } from 'react-router'
import {
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
interface NavItemProps {
href: string
@@ -31,4 +28,4 @@ export function NavItem({ href, icon: Icon, title, badge }: NavItemProps) {
</SidebarMenuButton>
</SidebarMenuItem>
)
}
}

View File

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

View File

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

View File

@@ -1,37 +1,47 @@
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
Play,
Pause,
Square,
SkipBack,
SkipForward,
Volume2,
VolumeX,
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Progress } from '@/components/ui/progress'
import { Slider } from '@/components/ui/slider'
import { filesService } from '@/lib/api/services/files'
import {
type MessageResponse,
type PlayerMode,
type PlayerState,
playerService,
} from '@/lib/api/services/player'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import {
ArrowRight,
ArrowRightToLine,
Download,
ExternalLink,
List,
Maximize2,
Minimize2,
MoreVertical,
Music,
Pause,
Play,
Repeat,
Repeat1,
Shuffle,
List,
Minimize2,
Maximize2,
Music,
ExternalLink,
Download,
MoreVertical,
ArrowRight,
ArrowRightToLine
SkipBack,
SkipForward,
Square,
Volume2,
VolumeX,
} from 'lucide-react'
import { playerService, type PlayerState, type PlayerMode, type MessageResponse } from '@/lib/api/services/player'
import { filesService } from '@/lib/api/services/files'
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { Playlist } from './Playlist'
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
@@ -47,17 +57,22 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
mode: 'continuous',
volume: 80,
previous_volume: 80,
position: 0
position: 0,
})
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
// Initialize from localStorage or default to 'normal'
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
const saved = localStorage.getItem(
'playerDisplayMode',
) as PlayerDisplayMode
return saved &&
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
? saved
: 'normal'
}
return 'normal'
})
// Notify parent when display mode changes and save to localStorage
useEffect(() => {
onPlayerModeChange?.(displayMode)
@@ -111,17 +126,23 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}
}, [displayMode])
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
}, [])
const executeAction = useCallback(
async (
action: () => Promise<void | MessageResponse>,
actionName: string,
) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
},
[],
)
const handlePlayPause = useCallback(() => {
if (state.status === 'playing') {
@@ -143,15 +164,21 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
executeAction(playerService.next, 'go to next track')
}, [executeAction])
const handleSeek = useCallback((position: number[]) => {
const newPosition = position[0]
executeAction(() => playerService.seek(newPosition), 'seek')
}, [executeAction])
const handleSeek = useCallback(
(position: number[]) => {
const newPosition = position[0]
executeAction(() => playerService.seek(newPosition), 'seek')
},
[executeAction],
)
const handleVolumeChange = useCallback((volume: number[]) => {
const newVolume = volume[0]
executeAction(() => playerService.setVolume(newVolume), 'change volume')
}, [executeAction])
const handleVolumeChange = useCallback(
(volume: number[]) => {
const newVolume = volume[0]
executeAction(() => playerService.setVolume(newVolume), 'change volume')
},
[executeAction],
)
const handleMute = useCallback(() => {
if (state.volume === 0) {
@@ -164,7 +191,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}, [state.volume, executeAction])
const handleModeChange = useCallback(() => {
const modes: PlayerMode[] = ['continuous', 'loop', 'loop_one', 'random', 'single']
const modes: PlayerMode[] = [
'continuous',
'loop',
'loop_one',
'random',
'single',
]
const currentIndex = modes.indexOf(state.mode)
const nextMode = modes[(currentIndex + 1) % modes.length]
executeAction(() => playerService.setMode(nextMode), 'change mode')
@@ -172,7 +205,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
const handleDownloadSound = useCallback(async () => {
if (!state.current_sound) return
try {
await filesService.downloadSound(state.current_sound.id)
toast.success('Download started')
@@ -185,7 +218,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
const getModeIcon = () => {
switch (state.mode) {
case 'continuous':
return <ArrowRight className='h-4 w-4' />
return <ArrowRight className="h-4 w-4" />
case 'loop':
return <Repeat className="h-4 w-4" />
case 'loop_one':
@@ -300,11 +333,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<div className="mb-4">
{state.current_sound?.thumbnail ? (
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={(e) => {
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
@@ -312,11 +345,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
if (musicIcon) musicIcon.style.display = 'block'
}}
/>
<Music
<Music
className={cn(
"h-8 w-8 text-muted-foreground",
state.current_sound?.thumbnail ? "hidden" : "block"
)}
'h-8 w-8 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
) : null}
@@ -328,38 +361,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<h3 className="font-medium text-sm truncate">
{state.current_sound?.name || 'No track selected'}
</h3>
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
{state.current_sound &&
(state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -368,7 +401,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-2 cursor-pointer"
onClick={(e) => {
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
@@ -474,10 +507,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{/* Playlist */}
{showPlaylist && state.playlist && (
<div className="mt-4 pt-4 border-t">
<Playlist
<Playlist
playlist={state.playlist}
currentIndex={state.index}
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
onTrackSelect={index =>
executeAction(
() => playerService.playAtIndex(index),
'play track',
)
}
/>
</div>
)}
@@ -506,11 +544,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{/* Large Album Art */}
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
{state.current_sound?.thumbnail ? (
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={(e) => {
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
@@ -519,11 +557,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}}
/>
) : null}
<Music
<Music
className={cn(
"h-32 w-32 text-muted-foreground",
state.current_sound?.thumbnail ? "hidden" : "block"
)}
'h-32 w-32 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
@@ -533,38 +571,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<h1 className="text-2xl font-bold">
{state.current_sound?.name || 'No track selected'}
</h1>
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
{state.current_sound &&
(state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -573,11 +611,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-3 cursor-pointer"
onClick={(e) => {
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
const newPosition = Math.round(percentage * (state.duration || 0))
const newPosition = Math.round(
percentage * (state.duration || 0),
)
handleSeek([newPosition])
}}
/>
@@ -630,24 +670,14 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{/* Secondary Controls */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleModeChange}
>
<Button size="sm" variant="ghost" onClick={handleModeChange}>
{getModeIcon()}
</Button>
<Badge variant="secondary">
{state.mode.replace('_', ' ')}
</Badge>
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleMute}
>
<Button size="sm" variant="ghost" onClick={handleMute}>
{state.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
@@ -680,10 +710,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
</p>
</div>
<div className="p-4">
<Playlist
<Playlist
playlist={state.playlist}
currentIndex={state.index}
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
onTrackSelect={index =>
executeAction(
() => playerService.playAtIndex(index),
'play track',
)
}
variant="maximized"
/>
</div>
@@ -696,7 +731,9 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
// Expose expand function for external use
useEffect(() => {
// Store expand function globally so sidebar can access it
const windowWithExpand = window as unknown as { __expandPlayerFromSidebar?: () => void }
const windowWithExpand = window as unknown as {
__expandPlayerFromSidebar?: () => void
}
windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar
return () => {
delete windowWithExpand.__expandPlayerFromSidebar
@@ -712,4 +749,4 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{displayMode === 'maximized' && renderMaximizedPlayer()}
</div>
)
}
}

View File

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

View File

@@ -1,10 +1,10 @@
import { Card, CardContent } from '@/components/ui/card'
import { Play, Clock, Weight } from 'lucide-react'
import { type Sound } from '@/lib/api/services/sounds'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { formatSize } from '@/utils/format-size'
import NumberFlow from '@number-flow/react'
import { Clock, Play, Weight } from 'lucide-react'
interface SoundCardProps {
sound: Sound
@@ -44,4 +44,4 @@ export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
</CardContent>
</Card>
)
}
}

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)
@@ -76,4 +87,4 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
}

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
@@ -28,9 +43,9 @@ export function SocketProvider({ children }: SocketProviderProps) {
if (!user) return null
// Get socket URL - use relative URL in production with reverse proxy
const socketUrl = import.meta.env.PROD
const socketUrl = import.meta.env.PROD
? '' // Use relative URL in production (same origin as frontend)
: (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000')
: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
const newSocket = io(socketUrl, {
withCredentials: true,
@@ -50,37 +65,37 @@ export function SocketProvider({ children }: SocketProviderProps) {
setIsConnected(false)
})
newSocket.on('connect_error', (error) => {
newSocket.on('connect_error', error => {
setConnectionError(`Connection failed: ${error.message}`)
setIsConnected(false)
setIsReconnecting(false)
})
// Listen for message events
newSocket.on('user_message', (data) => {
newSocket.on('user_message', data => {
toast.info(`Message from ${data.from_user_name}`, {
description: data.message,
})
})
newSocket.on('broadcast_message', (data) => {
newSocket.on('broadcast_message', data => {
toast.warning(`Broadcast from ${data.from_user_name}`, {
description: data.message,
})
})
// Listen for player events and emit them locally
newSocket.on('player_state', (data) => {
newSocket.on('player_state', data => {
playerEvents.emit(PLAYER_EVENTS.PLAYER_STATE, data)
})
// Listen for sound events and emit them locally
newSocket.on('sound_played', (data) => {
newSocket.on('sound_played', data => {
soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
})
// Listen for user events and emit them locally
newSocket.on('user_credits_changed', (data) => {
newSocket.on('user_credits_changed', data => {
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
})
@@ -92,10 +107,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
if (!user || !socket) return
setIsReconnecting(true)
// Disconnect current socket
socket.disconnect()
// Create new socket with fresh token
const newSocket = createSocket()
if (newSocket) {
@@ -106,13 +121,12 @@ export function SocketProvider({ children }: SocketProviderProps) {
// Listen for token refresh events
useEffect(() => {
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
return () => {
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
}
}, [handleTokenRefresh])
// Initial socket connection
useEffect(() => {
if (loading) return
@@ -146,9 +160,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
}
return (
<SocketContext.Provider value={value}>
{children}
</SocketContext.Provider>
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
)
}
@@ -158,4 +170,4 @@ export function useSocket() {
throw new Error('useSocket must be used within a SocketProvider')
}
return context
}
}

View File

@@ -12,5 +12,6 @@ const initialState: ThemeProviderState = {
setTheme: () => null,
}
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export type { Theme, ThemeProviderState }
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)
@@ -8,4 +8,4 @@ export const useTheme = () => {
throw new Error('useTheme must be used within a ThemeProvider')
return context
}
}

View File

@@ -1,5 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@@ -117,4 +117,4 @@
body {
@apply bg-background text-foreground;
}
}
}

View File

@@ -2,4 +2,4 @@
export * from './api/index'
// Export the main API object as default
export { default as api } from './api/index'
export { default as api } from './api/index'

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,9 +11,12 @@ 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) {
// Full base URL provided
url = new URL(endpoint, this.baseURL)
@@ -21,7 +24,7 @@ export class BaseApiClient implements ApiClient {
// Use relative URL (for reverse proxy)
url = new URL(endpoint, window.location.origin)
}
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
@@ -29,7 +32,7 @@ export class BaseApiClient implements ApiClient {
}
})
}
return this.baseURL ? url.toString() : url.pathname + url.search
}
@@ -37,7 +40,7 @@ export class BaseApiClient implements ApiClient {
method: HttpMethod,
endpoint: string,
data?: unknown,
config: ApiRequestConfig = {}
config: ApiRequestConfig = {},
): Promise<T> {
const {
params,
@@ -84,40 +87,43 @@ export class BaseApiClient implements ApiClient {
await this.handleTokenRefresh()
// Retry the original request
const retryResponse = await fetch(url, requestConfig)
if (!retryResponse.ok) {
const errorData = await this.safeParseJSON(retryResponse)
throw createApiError(retryResponse, errorData)
}
return await this.safeParseJSON(retryResponse) as T
return (await this.safeParseJSON(retryResponse)) as T
} catch (refreshError) {
this.handleAuthenticationFailure()
throw refreshError
}
}
const errorData = await this.safeParseJSON(response)
throw createApiError(response, errorData)
}
// Handle empty responses (204 No Content, etc.)
if (response.status === 204 || response.headers.get('content-length') === '0') {
if (
response.status === 204 ||
response.headers.get('content-length') === '0'
) {
return {} as T
}
return await this.safeParseJSON(response) as T
return (await this.safeParseJSON(response)) as T
} catch (error) {
clearTimeout(timeoutId)
if ((error as Error).name === 'AbortError') {
throw new TimeoutError()
}
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new NetworkError()
}
throw error
}
}
@@ -138,7 +144,7 @@ export class BaseApiClient implements ApiClient {
}
this.refreshPromise = this.performTokenRefresh()
try {
await this.refreshPromise
} finally {
@@ -147,11 +153,14 @@ export class BaseApiClient implements ApiClient {
}
private async performTokenRefresh(): Promise<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))
@@ -165,7 +174,7 @@ export class BaseApiClient implements ApiClient {
// Only redirect if we're not already on auth pages to prevent infinite loops
const currentPath = window.location.pathname
const authPaths = ['/login', '/register', '/auth/callback']
if (!authPaths.includes(currentPath)) {
window.location.href = '/login'
}
@@ -176,15 +185,27 @@ export class BaseApiClient implements ApiClient {
return this.request<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)
}
@@ -203,4 +224,4 @@ export class BaseApiClient implements ApiClient {
}
// Default API client instance
export const apiClient = new BaseApiClient()
export const apiClient = new BaseApiClient()

View File

@@ -16,7 +16,7 @@ export const API_CONFIG = {
BASE_URL: getApiBaseUrl(),
TIMEOUT: 30000, // 30 seconds
RETRY_ATTEMPTS: 1,
// API Endpoints
ENDPOINTS: {
AUTH: {
@@ -26,7 +26,8 @@ export const API_CONFIG = {
REFRESH: '/api/v1/auth/refresh',
ME: '/api/v1/auth/me',
PROVIDERS: '/api/v1/auth/providers',
OAUTH_AUTHORIZE: (provider: string) => `/api/v1/auth/${provider}/authorize`,
OAUTH_AUTHORIZE: (provider: string) =>
`/api/v1/auth/${provider}/authorize`,
OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`,
EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token',
API_TOKEN: '/api/v1/auth/api-token',
@@ -35,4 +36,4 @@ export const API_CONFIG = {
},
} as const
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS

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:
@@ -95,4 +114,4 @@ export function createApiError(response: Response, data?: unknown): ApiError {
default:
return new ApiError(message, status, data, errorData?.detail as string)
}
}
}

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,14 +11,10 @@ export * from './errors'
// Services
export * from './services/auth'
// Main API object for convenient access
import { authService } from './services/auth'
import { apiClient } from './client'
export const api = {
auth: authService,
client: apiClient,
} as const
// Default export for convenience
export default api
export default api

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,27 +89,38 @@ 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)
}
}
export const adminService = new AdminService()
export const adminService = new AdminService()

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,
)
}
/**
@@ -224,4 +245,4 @@ export class AuthService {
}
}
export const authService = new AuthService()
export const authService = new AuthService()

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,9 +48,11 @@ 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
}
}
export const extractionsService = new ExtractionsService()
export const extractionsService = new ExtractionsService()

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}`)
@@ -19,7 +22,7 @@ export class FilesService {
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition')
let filename = `sound_${soundId}.mp3`
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/)
if (filenameMatch) {
@@ -30,14 +33,14 @@ export class FilesService {
// Create blob and download
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
// Create temporary download link
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
@@ -59,10 +62,13 @@ export class FilesService {
*/
async hasThumbnail(soundId: number): Promise<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)
@@ -82,4 +88,4 @@ export class FilesService {
}
}
export const filesService = new FilesService()
export const filesService = new FilesService()

View File

@@ -2,4 +2,4 @@ export * from './auth'
export * from './sounds'
export * from './player'
export * from './files'
export * from './extractions'
export * from './extractions'

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
@@ -144,4 +149,4 @@ export class PlayerService {
}
}
export const playerService = new PlayerService()
export const playerService = new PlayerService()

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 {
@@ -47,7 +53,7 @@ export class PlaylistsService {
*/
async getPlaylists(params?: GetPlaylistsParams): Promise<Playlist[]> {
const searchParams = new URLSearchParams()
// Handle parameters
if (params?.search) {
searchParams.append('search', params.search)
@@ -64,8 +70,10 @@ export class PlaylistsService {
if (params?.offset) {
searchParams.append('offset', params.offset.toString())
}
const url = searchParams.toString() ? `/api/v1/playlists/?${searchParams.toString()}` : '/api/v1/playlists/'
const url = searchParams.toString()
? `/api/v1/playlists/?${searchParams.toString()}`
: '/api/v1/playlists/'
return apiClient.get<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,28 +165,38 @@ 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,
})
}
}
export const playlistsService = new PlaylistsService()
export const playlistsService = new PlaylistsService()

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 {
@@ -43,14 +51,14 @@ export class SoundsService {
*/
async getSounds(params?: GetSoundsParams): Promise<Sound[]> {
const searchParams = new URLSearchParams()
// Handle multiple types
if (params?.types) {
params.types.forEach(type => {
searchParams.append('types', type)
})
}
// Handle other parameters
if (params?.search) {
searchParams.append('search', params.search)
@@ -67,8 +75,10 @@ export class SoundsService {
if (params?.offset) {
searchParams.append('offset', params.offset.toString())
}
const url = searchParams.toString() ? `/api/v1/sounds/?${searchParams.toString()}` : '/api/v1/sounds/'
const url = searchParams.toString()
? `/api/v1/sounds/?${searchParams.toString()}`
: '/api/v1/sounds/'
const response = await apiClient.get<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)
}
@@ -102,4 +117,4 @@ export class SoundsService {
}
}
export const soundsService = new SoundsService()
export const soundsService = new SoundsService()

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

@@ -58,4 +58,4 @@ export const SOUND_EVENTS = {
// User event types
export const USER_EVENTS = {
USER_CREDITS_CHANGED: 'user_credits_changed',
} as const
} as const

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
@@ -22,10 +21,10 @@ export class TokenRefreshManager {
this.isEnabled = true
this.scheduleNextRefresh()
// Listen for visibility changes to handle tab switching
document.addEventListener('visibilitychange', this.handleVisibilityChange)
// Listen for successful auth events to reschedule
authEvents.on(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
@@ -41,8 +40,11 @@ export class TokenRefreshManager {
this.isEnabled = false
this.clearRefreshTimer()
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
)
authEvents.off(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
}
@@ -73,10 +75,9 @@ export class TokenRefreshManager {
await api.auth.refreshToken()
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
// Schedule next refresh immediately since we just completed one
this.scheduleNextRefresh()
} catch {
// If refresh fails, try again in 1 minute
this.refreshTimer = setTimeout(() => {
@@ -87,7 +88,6 @@ export class TokenRefreshManager {
}
}
/**
* Handle tab visibility changes
*/
@@ -105,7 +105,7 @@ export class TokenRefreshManager {
try {
// Try to make an API call to see if token is still valid
await api.auth.getMe()
// Token is still valid, reschedule based on remaining time
this.scheduleNextRefresh()
} catch (error: unknown) {
@@ -146,4 +146,4 @@ export class TokenRefreshManager {
}
// Global token refresh manager instance
export const tokenRefreshManager = new TokenRefreshManager()
export const tokenRefreshManager = new TokenRefreshManager()

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,56 +1,72 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { toast } from 'sonner'
import {
User,
Key,
Shield,
Palette,
Eye,
EyeOff,
Copy,
Trash2,
Github,
Mail,
CheckCircle2
} from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/hooks/use-theme'
import { authService, type ApiTokenStatusResponse, type UserProvider } from '@/lib/api/services/auth'
import {
type ApiTokenStatusResponse,
type UserProvider,
authService,
} from '@/lib/api/services/auth'
import {
CheckCircle2,
Copy,
Eye,
EyeOff,
Github,
Key,
Mail,
Palette,
Shield,
Trash2,
User,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
export function AccountPage() {
const { user, setUser } = useAuth()
const { theme, setTheme } = useTheme()
// Profile state
const [profileName, setProfileName] = useState('')
const [profileSaving, setProfileSaving] = useState(false)
// Password state
const [passwordData, setPasswordData] = useState({
current_password: '',
new_password: '',
confirm_password: ''
confirm_password: '',
})
const [passwordSaving, setPasswordSaving] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
// API Token state
const [apiTokenStatus, setApiTokenStatus] = useState<ApiTokenStatusResponse | null>(null)
const [apiTokenStatus, setApiTokenStatus] =
useState<ApiTokenStatusResponse | null>(null)
const [apiTokenLoading, setApiTokenLoading] = useState(true)
const [generatedToken, setGeneratedToken] = useState('')
const [showGeneratedToken, setShowGeneratedToken] = useState(false)
const [tokenExpireDays, setTokenExpireDays] = useState('365')
// Providers state
const [providers, setProviders] = useState<UserProvider[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@@ -91,7 +107,9 @@ export function AccountPage() {
setProfileSaving(true)
try {
const updatedUser = await authService.updateProfile({ name: profileName.trim() })
const updatedUser = await authService.updateProfile({
name: profileName.trim(),
})
setUser?.(updatedUser)
toast.success('Profile updated successfully')
} catch (error) {
@@ -104,14 +122,16 @@ export function AccountPage() {
const handlePasswordChange = async () => {
// Check if user has password authentication from providers
const hasPasswordProvider = providers.some(provider => provider.provider === 'password')
const hasPasswordProvider = providers.some(
provider => provider.provider === 'password',
)
// Validate required fields
if (hasPasswordProvider && !passwordData.current_password) {
toast.error('Current password is required')
return
}
if (!passwordData.new_password) {
toast.error('New password is required')
return
@@ -130,12 +150,22 @@ export function AccountPage() {
setPasswordSaving(true)
try {
await authService.changePassword({
current_password: hasPasswordProvider ? passwordData.current_password : undefined,
new_password: passwordData.new_password
current_password: hasPasswordProvider
? passwordData.current_password
: undefined,
new_password: passwordData.new_password,
})
setPasswordData({ current_password: '', new_password: '', confirm_password: '' })
toast.success(hasPasswordProvider ? 'Password changed successfully' : 'Password set successfully')
setPasswordData({
current_password: '',
new_password: '',
confirm_password: '',
})
toast.success(
hasPasswordProvider
? 'Password changed successfully'
: 'Password set successfully',
)
// Reload providers since password status might have changed
loadProviders()
} catch (error) {
@@ -148,8 +178,8 @@ export function AccountPage() {
const handleGenerateApiToken = async () => {
try {
const response = await authService.generateApiToken({
expires_days: parseInt(tokenExpireDays)
const response = await authService.generateApiToken({
expires_days: parseInt(tokenExpireDays),
})
setGeneratedToken(response.api_token)
setShowGeneratedToken(true)
@@ -192,12 +222,9 @@ export function AccountPage() {
if (!user) {
return (
<AppLayout
<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">
@@ -223,12 +250,9 @@ export function AccountPage() {
}
return (
<AppLayout
<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,15 +296,34 @@ 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>
<Button
onClick={handleProfileSave}
<Button
onClick={handleProfileSave}
disabled={profileSaving || profileName === user.name}
className="w-full"
>
@@ -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,22 +448,31 @@ 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>
<Button
onClick={handlePasswordChange}
<Button
onClick={handlePasswordChange}
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,13 +523,18 @@ 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>
<Button
onClick={handlePasswordChange}
<Button
onClick={handlePasswordChange}
disabled={passwordSaving}
className="w-full"
>
@@ -487,11 +568,15 @@ 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>
<Button
<Button
onClick={handleDeleteApiToken}
variant="destructive"
size="sm"
@@ -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,30 +652,41 @@ 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>
)
})}
{/* API Token Provider */}
{apiTokenStatus?.has_token && (
<div className="flex items-center justify-between p-3 border rounded-lg">
@@ -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>
@@ -649,4 +765,4 @@ export function AccountPage() {
</Dialog>
</AppLayout>
)
}
}

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(() => {
@@ -15,7 +17,7 @@ export function AuthCallbackPage() {
// Get the code from URL parameters
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
if (!code) {
throw new Error('No authorization code received')
}
@@ -25,22 +27,23 @@ export function AuthCallbackPage() {
// Now get the user info
const user = await api.auth.getMe()
// Update auth context
if (setUser) setUser(user)
setStatus('success')
// Redirect to dashboard after a short delay
setTimeout(() => {
navigate('/')
}, 1000)
} catch (error) {
console.error('OAuth callback failed:', error)
setError(error instanceof Error ? error.message : 'Authentication failed')
setError(
error instanceof Error ? error.message : 'Authentication failed',
)
setStatus('error')
// Redirect to login after error
setTimeout(() => {
navigate('/login')
@@ -57,28 +60,40 @@ export function AuthCallbackPage() {
{status === 'processing' && (
<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>
</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,11 +47,13 @@ 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)
// Top sounds state
const [topSounds, setTopSounds] = useState<TopSound[]>([])
const [topSoundsLoading, setTopSoundsLoading] = useState(false)
@@ -48,19 +65,21 @@ export function DashboardPage() {
const fetchStatistics = useCallback(async () => {
try {
const [soundboardResponse, trackResponse] = await Promise.all([
fetch('/api/v1/dashboard/soundboard-statistics', { credentials: 'include' }),
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' })
fetch('/api/v1/dashboard/soundboard-statistics', {
credentials: 'include',
}),
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }),
])
if (!soundboardResponse.ok || !trackResponse.ok) {
throw new Error('Failed to fetch statistics')
}
const [soundboardData, trackData] = await Promise.all([
soundboardResponse.json(),
trackResponse.json()
trackResponse.json(),
])
setSoundboardStatistics(soundboardData)
setTrackStatistics(trackData)
} catch (err) {
@@ -68,61 +87,63 @@ export function DashboardPage() {
}
}, [])
const fetchTopSounds = useCallback(async (showLoading = false) => {
try {
if (showLoading) {
setTopSoundsLoading(true)
}
const response = await fetch(
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
{ credentials: 'include' }
)
if (!response.ok) {
throw new Error('Failed to fetch top sounds')
}
const data = await response.json()
// Graceful update: merge new data while preserving animations
setTopSounds(prevTopSounds => {
// Create a map of existing sounds for efficient lookup
const existingSoundsMap = new Map(prevTopSounds.map(sound => [sound.id, sound]))
// Update existing sounds and add new ones
return data.map((newSound: TopSound) => {
const existingSound = existingSoundsMap.get(newSound.id)
if (existingSound) {
// Preserve object reference if data hasn't changed to avoid re-renders
if (
existingSound.name === newSound.name &&
existingSound.type === newSound.type &&
existingSound.play_count === newSound.play_count &&
existingSound.duration === newSound.duration
) {
return existingSound
const fetchTopSounds = useCallback(
async (showLoading = false) => {
try {
if (showLoading) {
setTopSoundsLoading(true)
}
const response = await fetch(
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
{ credentials: 'include' },
)
if (!response.ok) {
throw new Error('Failed to fetch top sounds')
}
const data = await response.json()
// Graceful update: merge new data while preserving animations
setTopSounds(prevTopSounds => {
// Create a map of existing sounds for efficient lookup
const existingSoundsMap = new Map(
prevTopSounds.map(sound => [sound.id, sound]),
)
// Update existing sounds and add new ones
return data.map((newSound: TopSound) => {
const existingSound = existingSoundsMap.get(newSound.id)
if (existingSound) {
// Preserve object reference if data hasn't changed to avoid re-renders
if (
existingSound.name === newSound.name &&
existingSound.type === newSound.type &&
existingSound.play_count === newSound.play_count &&
existingSound.duration === newSound.duration
) {
return existingSound
}
}
}
return newSound
return newSound
})
})
})
} catch (err) {
console.error('Failed to fetch top sounds:', err)
} finally {
if (showLoading) {
setTopSoundsLoading(false)
} catch (err) {
console.error('Failed to fetch top sounds:', err)
} finally {
if (showLoading) {
setTopSoundsLoading(false)
}
}
}
}, [soundType, period, limit])
},
[soundType, period, limit],
)
const refreshAll = useCallback(async () => {
setRefreshing(true)
try {
await Promise.all([
fetchStatistics(),
fetchTopSounds()
])
await Promise.all([fetchStatistics(), fetchTopSounds()])
} finally {
setRefreshing(false)
}
@@ -149,18 +170,16 @@ export function DashboardPage() {
return () => clearInterval(interval)
}, [refreshAll])
useEffect(() => {
fetchTopSounds(true) // Show loading on initial load and filter changes
}, [fetchTopSounds])
if (loading) {
return (
<AppLayout
<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>
))}
@@ -211,11 +242,9 @@ export function DashboardPage() {
if (error || !soundboardStatistics || !trackStatistics) {
return (
<AppLayout
<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>
@@ -236,11 +267,9 @@ export function DashboardPage() {
}
return (
<AppLayout
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard' }
]
items: [{ label: 'Dashboard' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -251,28 +280,36 @@ export function DashboardPage() {
Overview of your soundboard and track statistics
</p>
</div>
<Button
onClick={refreshAll}
variant="outline"
<Button
onClick={refreshAll}
variant="outline"
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
@@ -385,7 +456,7 @@ export function DashboardPage() {
</Card>
</div>
</div>
{/* Top Sounds Section */}
<div className="mt-8">
<Card>
@@ -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>
))}
@@ -491,4 +577,4 @@ export function DashboardPage() {
</div>
</AppLayout>
)
}
}

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,29 +88,53 @@ 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>
)
}
}
const getServiceBadge = (service: string | undefined) => {
if (!service) return null
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}>
@@ -95,12 +144,9 @@ export function ExtractionsPage() {
}
return (
<AppLayout
<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">
@@ -111,7 +157,7 @@ export function ExtractionsPage() {
Extract audio from YouTube, SoundCloud, and other platforms
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
@@ -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>
@@ -262,4 +332,4 @@ export function ExtractionsPage() {
</div>
</AppLayout>
)
}
}

View File

@@ -1,17 +1,17 @@
import { Link } from 'react-router'
import { LoginForm } from '@/components/auth/LoginForm'
import { Link } from 'react-router'
export function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-full max-w-md space-y-6">
<LoginForm />
<div className="text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
Don't have an account?{' '}
<Link
to="/register"
<Link
to="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Sign up
@@ -21,4 +21,4 @@ export function LoginPage() {
</div>
</div>
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,88 @@
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()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and sorting state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
// Create playlist dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [newPlaylist, setNewPlaylist] = useState({
name: '',
description: '',
genre: ''
genre: '',
})
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
@@ -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 {
@@ -82,17 +123,18 @@ export function PlaylistsPage() {
description: newPlaylist.description.trim() || undefined,
genre: newPlaylist.genre.trim() || undefined,
})
toast.success(`Playlist "${newPlaylist.name}" created successfully`)
// Reset form and close dialog
setNewPlaylist({ name: '', description: '', genre: '' })
setShowCreateDialog(false)
// 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)
@@ -108,11 +150,12 @@ export function PlaylistsPage() {
try {
await playlistsService.setCurrentPlaylist(playlist.id)
toast.success(`"${playlist.name}" is now the current playlist`)
// 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,10 +180,12 @@ 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}
<button
onClick={fetchPlaylists}
className="text-primary hover:underline"
>
Try again
@@ -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>
)}
@@ -275,12 +322,9 @@ export function PlaylistsPage() {
}
return (
<AppLayout
<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>
@@ -359,7 +426,7 @@ export function PlaylistsPage() {
)}
</div>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
@@ -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 && (
@@ -384,9 +451,12 @@ export function PlaylistsPage() {
)}
</div>
</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>
@@ -399,16 +469,20 @@ export function PlaylistsPage() {
<SelectItem value="updated_at">Updated Date</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
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
variant="outline"
size="icon"
@@ -416,13 +490,15 @@ 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>
{renderContent()}
</div>
</AppLayout>
)
}
}

View File

@@ -1,17 +1,17 @@
import { Link } from 'react-router'
import { RegisterForm } from '@/components/auth/RegisterForm'
import { Link } from 'react-router'
export function RegisterPage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-full max-w-md space-y-6">
<RegisterForm />
<div className="text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
Already have an account?{' '}
<Link
to="/login"
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Sign in
@@ -21,4 +21,4 @@ export function RegisterPage() {
</div>
</div>
)
}
}

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
@@ -54,7 +72,7 @@ export function SoundsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentColors, setCurrentColors] = useState<string[]>(lightModeColors)
// Search and sorting state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<SoundSortField>('name')
@@ -65,10 +83,12 @@ 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'}`,
)
}
}
const { theme } = useTheme()
useEffect(() => {
@@ -78,7 +98,7 @@ export function SoundsPage() {
setCurrentColors(lightModeColors)
}
}, [theme])
const getSoundColor = (soundIdx: number) => {
const index = soundIdx % currentColors.length
return currentColors[index]
@@ -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 {
@@ -105,12 +126,12 @@ export function SoundsPage() {
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
@@ -121,12 +142,12 @@ export function SoundsPage() {
// Listen for sound_played events and update play_count
useEffect(() => {
const handleSoundPlayed = (eventData: SoundPlayedEventData) => {
setSounds(prevSounds =>
prevSounds.map(sound =>
setSounds(prevSounds =>
prevSounds.map(sound =>
sound.id === eventData.sound_id
? { ...sound, play_count: eventData.play_count }
: sound
)
: sound,
),
)
}
@@ -156,8 +177,8 @@ export function SoundsPage() {
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to load sounds</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
<button
onClick={() => window.location.reload()}
className="text-primary hover:underline"
>
Try again
@@ -183,19 +204,21 @@ 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>
)
}
return (
<AppLayout
<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">
@@ -212,7 +235,7 @@ export function SoundsPage() {
</div>
)}
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
@@ -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 && (
@@ -237,9 +260,12 @@ export function SoundsPage() {
)}
</div>
</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>
@@ -252,16 +278,20 @@ export function SoundsPage() {
<SelectItem value="updated_at">Updated Date</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
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
variant="outline"
size="icon"
@@ -269,13 +299,15 @@ 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>
{renderContent()}
</div>
</AppLayout>
)
}
}

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,
import {
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)
@@ -48,22 +63,24 @@ export function SettingsPage() {
setNormalizationInProgress(true)
try {
let response: NormalizationResponse
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)
@@ -73,13 +90,13 @@ export function SettingsPage() {
}
return (
<AppLayout
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Admin' },
{ label: 'Settings' }
]
{ label: 'Settings' },
],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -104,11 +121,12 @@ 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
onClick={handleScanSounds}
<Button
onClick={handleScanSounds}
disabled={scanningInProgress}
className="w-full"
>
@@ -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,16 +172,20 @@ 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">
<div className="space-y-2">
<Label>Sound Type</Label>
<Select
value={normalizationOptions.soundType}
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
setNormalizationOptions(prev => ({ ...prev, soundType: value }))
<Select
value={normalizationOptions.soundType}
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
setNormalizationOptions(prev => ({
...prev,
soundType: value,
}))
}
>
<SelectTrigger>
@@ -177,11 +201,14 @@ export function SettingsPage() {
</div>
<div className="flex items-center space-x-2">
<Checkbox
<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">
@@ -190,11 +217,14 @@ export function SettingsPage() {
</div>
<div className="flex items-center space-x-2">
<Checkbox
<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">
@@ -203,8 +233,8 @@ export function SettingsPage() {
</div>
</div>
<Button
onClick={handleNormalizeSounds}
<Button
onClick={handleNormalizeSounds}
disabled={normalizationInProgress}
className="w-full"
>
@@ -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>
)}
@@ -248,4 +294,4 @@ export function SettingsPage() {
</div>
</AppLayout>
)
}
}

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) {
@@ -117,13 +135,13 @@ export function UsersPage() {
if (loading) {
return (
<AppLayout
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Admin' },
{ label: 'Users' }
]
{ label: 'Users' },
],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -152,13 +170,13 @@ export function UsersPage() {
}
return (
<AppLayout
<AppLayout
breadcrumb={{
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,39 +249,62 @@ 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">
<h2 className="text-xl font-semibold">Edit User</h2>
</div>
{editingUser && (
<div className="space-y-8 pb-6">
{/* User Information Section */}
<div className="space-y-4">
<h3 className="font-semibold text-base">User Information</h3>
<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>
@@ -375,4 +458,4 @@ export function UsersPage() {
</Sheet>
</AppLayout>
)
}
}

View File

@@ -45,4 +45,4 @@ export interface AuthContextType {
logout: () => Promise<void>
loading: boolean
setUser?: (user: User | null) => void
}
}

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