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

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

View File

@@ -1,25 +1,29 @@
import { Routes, Route, Navigate } from 'react-router' import { Navigate, Route, Routes } from 'react-router'
import { ThemeProvider } from './components/ThemeProvider' import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { SocketProvider } from './contexts/SocketContext' import { SocketProvider } from './contexts/SocketContext'
import { LoginPage } from './pages/LoginPage' import { AccountPage } from './pages/AccountPage'
import { RegisterPage } from './pages/RegisterPage'
import { AuthCallbackPage } from './pages/AuthCallbackPage' import { AuthCallbackPage } from './pages/AuthCallbackPage'
import { DashboardPage } from './pages/DashboardPage' 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 { 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 { SettingsPage } from './pages/admin/SettingsPage'
import { AccountPage } from './pages/AccountPage' import { UsersPage } from './pages/admin/UsersPage'
import { Toaster } from './components/ui/sonner'
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) { 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) { if (!user) {
@@ -33,7 +37,11 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) { 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) { if (!user) {
@@ -52,49 +60,79 @@ function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} /> <Route
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} /> 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="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/" element={ <Route
path="/"
element={
<ProtectedRoute> <ProtectedRoute>
<DashboardPage /> <DashboardPage />
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/sounds" element={ />
<Route
path="/sounds"
element={
<ProtectedRoute> <ProtectedRoute>
<SoundsPage /> <SoundsPage />
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/playlists" element={ />
<Route
path="/playlists"
element={
<ProtectedRoute> <ProtectedRoute>
<PlaylistsPage /> <PlaylistsPage />
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/playlists/:id/edit" element={ />
<Route
path="/playlists/:id/edit"
element={
<ProtectedRoute> <ProtectedRoute>
<PlaylistEditPage /> <PlaylistEditPage />
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/extractions" element={ />
<Route
path="/extractions"
element={
<ProtectedRoute> <ProtectedRoute>
<ExtractionsPage /> <ExtractionsPage />
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/account" element={ />
<Route
path="/account"
element={
<ProtectedRoute> <ProtectedRoute>
<AccountPage /> <AccountPage />
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/admin/users" element={ />
<Route
path="/admin/users"
element={
<AdminRoute> <AdminRoute>
<UsersPage /> <UsersPage />
</AdminRoute> </AdminRoute>
} /> }
<Route path="/admin/settings" element={ />
<Route
path="/admin/settings"
element={
<AdminRoute> <AdminRoute>
<SettingsPage /> <SettingsPage />
</AdminRoute> </AdminRoute>
} /> }
/>
</Routes> </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 { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -10,6 +6,14 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb' } 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' import { Player, type PlayerDisplayMode } from './player/Player'
interface AppLayoutProps { interface AppLayoutProps {
@@ -23,14 +27,21 @@ interface AppLayoutProps {
} }
export function AppLayout({ children, breadcrumb }: AppLayoutProps) { export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(() => { const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(
() => {
// Initialize from localStorage or default to 'normal' // Initialize from localStorage or default to 'normal'
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode const saved = localStorage.getItem(
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal' 'playerDisplayMode',
) as PlayerDisplayMode
return saved &&
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
? saved
: 'normal'
} }
return 'normal' return 'normal'
}) },
)
// Note: localStorage is managed by the Player component // Note: localStorage is managed by the Player component
@@ -66,13 +77,9 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
)} )}
</div> </div>
</header> </header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0"> <div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
{children}
</div>
</SidebarInset> </SidebarInset>
<Player <Player onPlayerModeChange={setPlayerDisplayMode} />
onPlayerModeChange={setPlayerDisplayMode}
/>
</SidebarProvider> </SidebarProvider>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react' import { USER_EVENTS, userEvents } from '@/lib/events'
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 type { User } from '@/types/auth' 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 { interface CreditsNavProps {
user: User user: User
@@ -41,12 +41,17 @@ export function CreditsNav({ user }: CreditsNavProps) {
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <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()}`} /> <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"> <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="font-semibold">Credits:</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
<NumberFlow value={credits} /> / <NumberFlow value={user.plan.max_credits} /> <NumberFlow value={credits} /> /{' '}
<NumberFlow value={user.plan.max_credits} />
</span> </span>
</div> </div>
</SidebarMenuButton> </SidebarMenuButton>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,19 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth' import { AUTH_EVENTS, authEvents } from '@/lib/events'
import { authEvents, AUTH_EVENTS } from '@/lib/events'
import { tokenRefreshManager } from '@/lib/token-refresh-manager' 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) const AuthContext = createContext<AuthContextType | null>(null)

View File

@@ -1,8 +1,23 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' import React, {
import { io, Socket } from 'socket.io-client' createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import { Socket, io } from 'socket.io-client'
import { toast } from 'sonner' 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 { useAuth } from './AuthContext'
import { authEvents, AUTH_EVENTS, soundEvents, SOUND_EVENTS, userEvents, USER_EVENTS, playerEvents, PLAYER_EVENTS } from '../lib/events'
interface SocketContextType { interface SocketContextType {
socket: Socket | null socket: Socket | null
@@ -30,7 +45,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
// Get socket URL - use relative URL in production with reverse proxy // 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) ? '' // 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, { const newSocket = io(socketUrl, {
withCredentials: true, withCredentials: true,
@@ -50,37 +65,37 @@ export function SocketProvider({ children }: SocketProviderProps) {
setIsConnected(false) setIsConnected(false)
}) })
newSocket.on('connect_error', (error) => { newSocket.on('connect_error', error => {
setConnectionError(`Connection failed: ${error.message}`) setConnectionError(`Connection failed: ${error.message}`)
setIsConnected(false) setIsConnected(false)
setIsReconnecting(false) setIsReconnecting(false)
}) })
// Listen for message events // Listen for message events
newSocket.on('user_message', (data) => { newSocket.on('user_message', data => {
toast.info(`Message from ${data.from_user_name}`, { toast.info(`Message from ${data.from_user_name}`, {
description: data.message, description: data.message,
}) })
}) })
newSocket.on('broadcast_message', (data) => { newSocket.on('broadcast_message', data => {
toast.warning(`Broadcast from ${data.from_user_name}`, { toast.warning(`Broadcast from ${data.from_user_name}`, {
description: data.message, description: data.message,
}) })
}) })
// Listen for player events and emit them locally // 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) playerEvents.emit(PLAYER_EVENTS.PLAYER_STATE, data)
}) })
// Listen for sound events and emit them locally // 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) soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
}) })
// Listen for user events and emit them locally // 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) userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
}) })
@@ -112,7 +127,6 @@ export function SocketProvider({ children }: SocketProviderProps) {
} }
}, [handleTokenRefresh]) }, [handleTokenRefresh])
// Initial socket connection // Initial socket connection
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return
@@ -146,9 +160,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
} }
return ( return (
<SocketContext.Provider value={value}> <SocketContext.Provider value={value}>{children}</SocketContext.Provider>
{children}
</SocketContext.Provider>
) )
} }

View File

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

View File

@@ -1,4 +1,4 @@
import * as React from "react" import * as React from 'react'
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768
@@ -10,9 +10,9 @@ export function useIsMobile() {
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
} }
mql.addEventListener("change", onChange) mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener('change', onChange)
}, []) }, [])
return !!isMobile return !!isMobile

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { AUTH_EVENTS, authEvents } from '../events'
import { API_CONFIG } from './config' 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 type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
import { authEvents, AUTH_EVENTS } from '../events'
export class BaseApiClient implements ApiClient { export class BaseApiClient implements ApiClient {
private refreshPromise: Promise<void> | null = null private refreshPromise: Promise<void> | null = null
@@ -11,7 +11,10 @@ export class BaseApiClient implements ApiClient {
this.baseURL = baseURL 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 let url: URL
if (this.baseURL) { if (this.baseURL) {
@@ -37,7 +40,7 @@ export class BaseApiClient implements ApiClient {
method: HttpMethod, method: HttpMethod,
endpoint: string, endpoint: string,
data?: unknown, data?: unknown,
config: ApiRequestConfig = {} config: ApiRequestConfig = {},
): Promise<T> { ): Promise<T> {
const { const {
params, params,
@@ -90,7 +93,7 @@ export class BaseApiClient implements ApiClient {
throw createApiError(retryResponse, errorData) throw createApiError(retryResponse, errorData)
} }
return await this.safeParseJSON(retryResponse) as T return (await this.safeParseJSON(retryResponse)) as T
} catch (refreshError) { } catch (refreshError) {
this.handleAuthenticationFailure() this.handleAuthenticationFailure()
throw refreshError throw refreshError
@@ -102,11 +105,14 @@ export class BaseApiClient implements ApiClient {
} }
// Handle empty responses (204 No Content, etc.) // 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 {} as T
} }
return await this.safeParseJSON(response) as T return (await this.safeParseJSON(response)) as T
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
@@ -147,11 +153,14 @@ export class BaseApiClient implements ApiClient {
} }
private async performTokenRefresh(): Promise<void> { private async performTokenRefresh(): Promise<void> {
const response = await fetch(`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, { const response = await fetch(
`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`,
{
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
}) },
)
if (!response.ok) { if (!response.ok) {
throw createApiError(response, await this.safeParseJSON(response)) throw createApiError(response, await this.safeParseJSON(response))
@@ -176,15 +185,27 @@ export class BaseApiClient implements ApiClient {
return this.request<T>('GET', endpoint, undefined, config) 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) 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) 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) return this.request<T>('PATCH', endpoint, data, config)
} }

View File

@@ -26,7 +26,8 @@ export const API_CONFIG = {
REFRESH: '/api/v1/auth/refresh', REFRESH: '/api/v1/auth/refresh',
ME: '/api/v1/auth/me', ME: '/api/v1/auth/me',
PROVIDERS: '/api/v1/auth/providers', 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`, OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`,
EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token', EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token',
API_TOKEN: '/api/v1/auth/api-token', API_TOKEN: '/api/v1/auth/api-token',

View File

@@ -4,7 +4,12 @@ export class ApiError extends Error {
public response?: unknown public response?: unknown
public detail?: string public detail?: string
constructor(message: string, status: number, response?: unknown, detail?: string) { constructor(
message: string,
status: number,
response?: unknown,
detail?: string,
) {
super(message) super(message)
this.name = 'ApiError' this.name = 'ApiError'
this.status = status this.status = status
@@ -14,8 +19,16 @@ export class ApiError extends Error {
static fromResponse(response: Response, data?: unknown): ApiError { static fromResponse(response: Response, data?: unknown): ApiError {
const errorData = data as Record<string, unknown> const errorData = data as Record<string, unknown>
const message = errorData?.detail as string || errorData?.message as string || `HTTP ${response.status}: ${response.statusText}` const message =
return new ApiError(message, response.status, data, errorData?.detail as string) (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 { export function createApiError(response: Response, data?: unknown): ApiError {
const status = response.status const status = response.status
const errorData = data as Record<string, unknown> 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) { switch (status) {
case 401: case 401:
@@ -85,7 +101,10 @@ export function createApiError(response: Response, data?: unknown): ApiError {
case 404: case 404:
return new NotFoundError(message) return new NotFoundError(message)
case 422: 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 500:
case 501: case 501:
case 502: case 502:

View File

@@ -1,3 +1,7 @@
// Main API object for convenient access
import { apiClient } from './client'
import { authService } from './services/auth'
// Re-export all API services and utilities // Re-export all API services and utilities
export * from './client' export * from './client'
export * from './config' export * from './config'
@@ -7,10 +11,6 @@ export * from './errors'
// Services // Services
export * from './services/auth' export * from './services/auth'
// Main API object for convenient access
import { authService } from './services/auth'
import { apiClient } from './client'
export const api = { export const api = {
auth: authService, auth: authService,
client: apiClient, client: apiClient,

View File

@@ -1,5 +1,5 @@
import { apiClient } from '../client'
import type { User } from '@/types/auth' import type { User } from '@/types/auth'
import { apiClient } from '../client'
export interface Plan { export interface Plan {
id: number id: number
@@ -56,7 +56,7 @@ export interface NormalizationResponse {
export class AdminService { export class AdminService {
async listUsers(limit = 100, offset = 0): Promise<User[]> { async listUsers(limit = 100, offset = 0): Promise<User[]> {
return apiClient.get<User[]>(`/api/v1/admin/users/`, { 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> { 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> { 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[]> { async listPlans(): Promise<Plan[]> {
@@ -85,24 +89,35 @@ export class AdminService {
return apiClient.post<ScanResponse>(`/api/v1/admin/sounds/scan`) 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() const params = new URLSearchParams()
if (force) params.append('force', 'true') if (force) params.append('force', 'true')
if (onePass !== undefined) params.append('one_pass', onePass.toString()) if (onePass !== undefined) params.append('one_pass', onePass.toString())
const queryString = params.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) 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() const params = new URLSearchParams()
if (force) params.append('force', 'true') if (force) params.append('force', 'true')
if (onePass !== undefined) params.append('one_pass', onePass.toString()) if (onePass !== undefined) params.append('one_pass', onePass.toString())
const queryString = params.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) return apiClient.post<NormalizationResponse>(url)
} }

View File

@@ -72,12 +72,15 @@ export class AuthService {
*/ */
async login(credentials: LoginRequest): Promise<User> { async login(credentials: LoginRequest): Promise<User> {
// Using direct fetch for auth endpoints to avoid circular dependency with token refresh // 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}`, { const response = await fetch(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`,
{
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials), body: JSON.stringify(credentials),
credentials: 'include', credentials: 'include',
}) },
)
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null) const errorData = await response.json().catch(() => null)
@@ -91,12 +94,15 @@ export class AuthService {
* Register a new user account * Register a new user account
*/ */
async register(userData: RegisterRequest): Promise<User> { async register(userData: RegisterRequest): Promise<User> {
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`, { const response = await fetch(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`,
{
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData), body: JSON.stringify(userData),
credentials: 'include', credentials: 'include',
}) },
)
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null) const errorData = await response.json().catch(() => null)
@@ -133,7 +139,7 @@ export class AuthService {
async getOAuthUrl(provider: string): Promise<OAuthAuthorizationResponse> { async getOAuthUrl(provider: string): Promise<OAuthAuthorizationResponse> {
return apiClient.get<OAuthAuthorizationResponse>( return apiClient.get<OAuthAuthorizationResponse>(
API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider), API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider),
{ skipAuth: true } { skipAuth: true },
) )
} }
@@ -143,21 +149,26 @@ export class AuthService {
async getOAuthProviders(): Promise<OAuthProvidersResponse> { async getOAuthProviders(): Promise<OAuthProvidersResponse> {
return apiClient.get<OAuthProvidersResponse>( return apiClient.get<OAuthProvidersResponse>(
API_CONFIG.ENDPOINTS.AUTH.PROVIDERS, API_CONFIG.ENDPOINTS.AUTH.PROVIDERS,
{ skipAuth: true } { skipAuth: true },
) )
} }
/** /**
* Exchange OAuth temporary code for auth cookies * 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 // 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}`, { const response = await fetch(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`,
{
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: 'include', // Essential for receiving auth cookies credentials: 'include', // Essential for receiving auth cookies
}) },
)
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null) const errorData = await response.json().catch(() => null)
@@ -171,10 +182,13 @@ export class AuthService {
* Refresh authentication token * Refresh authentication token
*/ */
async refreshToken(): Promise<void> { async refreshToken(): Promise<void> {
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, { const response = await fetch(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`,
{
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}) },
)
if (!response.ok) { if (!response.ok) {
throw new Error('Token refresh failed') throw new Error('Token refresh failed')
@@ -198,15 +212,22 @@ export class AuthService {
/** /**
* Generate a new API token * Generate a new API token
*/ */
async generateApiToken(request: ApiTokenRequest = {}): Promise<ApiTokenResponse> { async generateApiToken(
return apiClient.post<ApiTokenResponse>(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN, request) request: ApiTokenRequest = {},
): Promise<ApiTokenResponse> {
return apiClient.post<ApiTokenResponse>(
API_CONFIG.ENDPOINTS.AUTH.API_TOKEN,
request,
)
} }
/** /**
* Get API token status * Get API token status
*/ */
async getApiTokenStatus(): Promise<ApiTokenStatusResponse> { async getApiTokenStatus(): Promise<ApiTokenStatusResponse> {
return apiClient.get<ApiTokenStatusResponse>(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS) return apiClient.get<ApiTokenStatusResponse>(
API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS,
)
} }
/** /**

View File

@@ -28,7 +28,9 @@ export class ExtractionsService {
* Create a new extraction job * Create a new extraction job
*/ */
async createExtraction(url: string): Promise<CreateExtractionResponse> { 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 return response
} }
@@ -36,7 +38,9 @@ export class ExtractionsService {
* Get extraction by ID * Get extraction by ID
*/ */
async getExtraction(extractionId: number): Promise<ExtractionInfo> { 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 return response
} }
@@ -44,7 +48,9 @@ export class ExtractionsService {
* Get user's extractions * Get user's extractions
*/ */
async getUserExtractions(): Promise<ExtractionInfo[]> { 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 return response.extractions
} }
} }

View File

@@ -7,10 +7,13 @@ export class FilesService {
async downloadSound(soundId: number): Promise<void> { async downloadSound(soundId: number): Promise<void> {
try { try {
// Use fetch directly to handle file download // Use fetch directly to handle file download
const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`, { const response = await fetch(
`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`,
{
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) },
)
if (!response.ok) { if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`) throw new Error(`Download failed: ${response.statusText}`)
@@ -59,10 +62,13 @@ export class FilesService {
*/ */
async hasThumbnail(soundId: number): Promise<boolean> { async hasThumbnail(soundId: number): Promise<boolean> {
try { try {
const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`, { const response = await fetch(
`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`,
{
method: 'HEAD', // Only check headers, don't download method: 'HEAD', // Only check headers, don't download
credentials: 'include', credentials: 'include',
}) },
)
return response.ok return response.ok
} catch { } catch {
return false return false
@@ -73,7 +79,7 @@ export class FilesService {
* Preload a thumbnail image * Preload a thumbnail image
*/ */
async preloadThumbnail(soundId: number): Promise<boolean> { async preloadThumbnail(soundId: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise(resolve => {
const img = new Image() const img = new Image()
img.onload = () => resolve(true) img.onload = () => resolve(true)
img.onerror = () => resolve(false) img.onerror = () => resolve(false)

View File

@@ -1,7 +1,12 @@
import { apiClient } from '../client' import { apiClient } from '../client'
export type PlayerStatus = 'playing' | 'paused' | 'stopped' 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 { export interface PlayerSound {
id: number id: number

View File

@@ -1,6 +1,12 @@
import { apiClient } from '../client' 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 type SortOrder = 'asc' | 'desc'
export interface Playlist { export interface Playlist {
@@ -65,7 +71,9 @@ export class PlaylistsService {
searchParams.append('offset', params.offset.toString()) 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) return apiClient.get<Playlist[]>(url)
} }
@@ -104,11 +112,14 @@ export class PlaylistsService {
/** /**
* Update a playlist * Update a playlist
*/ */
async updatePlaylist(id: number, data: { async updatePlaylist(
id: number,
data: {
name?: string name?: string
description?: string description?: string
genre?: string genre?: string
}): Promise<Playlist> { },
): Promise<Playlist> {
return apiClient.put<Playlist>(`/api/v1/playlists/${id}`, data) return apiClient.put<Playlist>(`/api/v1/playlists/${id}`, data)
} }
@@ -154,26 +165,36 @@ export class PlaylistsService {
/** /**
* Add sound to playlist * 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`, { await apiClient.post(`/api/v1/playlists/${playlistId}/sounds`, {
sound_id: soundId, sound_id: soundId,
position position,
}) })
} }
/** /**
* Remove sound from playlist * 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}`) await apiClient.delete(`/api/v1/playlists/${playlistId}/sounds/${soundId}`)
} }
/** /**
* Reorder sounds in playlist * 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`, { await apiClient.put(`/api/v1/playlists/${playlistId}/sounds/reorder`, {
sound_positions: soundPositions sound_positions: soundPositions,
}) })
} }
} }

View File

@@ -21,7 +21,15 @@ export interface Sound {
updated_at: string 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 type SortOrder = 'asc' | 'desc'
export interface GetSoundsParams { export interface GetSoundsParams {
@@ -68,7 +76,9 @@ export class SoundsService {
searchParams.append('offset', params.offset.toString()) 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) const response = await apiClient.get<GetSoundsResponse>(url)
return response.sounds || [] return response.sounds || []
} }
@@ -76,14 +86,19 @@ export class SoundsService {
/** /**
* Get sounds of a specific type * 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] }) return this.getSounds({ ...params, types: [type] })
} }
/** /**
* Get SDB type sounds * 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) return this.getSoundsByType('SDB', params)
} }

View File

@@ -20,15 +20,26 @@ export interface ApiRequestConfig extends RequestInit {
timeout?: number timeout?: number
} }
// HTTP Methods // HTTP Methods
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Generic API client interface // Generic API client interface
export interface ApiClient { export interface ApiClient {
get<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> get<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> post<T>(
put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> endpoint: string,
patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> 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> delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
} }

View File

@@ -1,9 +1,8 @@
/** /**
* Token refresh manager for proactive token refresh * Token refresh manager for proactive token refresh
*/ */
import { authEvents, AUTH_EVENTS } from './events'
import { api } from './api' import { api } from './api'
import { AUTH_EVENTS, authEvents } from './events'
export class TokenRefreshManager { export class TokenRefreshManager {
private refreshTimer: NodeJS.Timeout | null = null private refreshTimer: NodeJS.Timeout | null = null
@@ -42,7 +41,10 @@ export class TokenRefreshManager {
this.isEnabled = false this.isEnabled = false
this.clearRefreshTimer() this.clearRefreshTimer()
document.removeEventListener('visibilitychange', this.handleVisibilityChange) document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
)
authEvents.off(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess) authEvents.off(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed) authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
} }
@@ -76,7 +78,6 @@ export class TokenRefreshManager {
// Schedule next refresh immediately since we just completed one // Schedule next refresh immediately since we just completed one
this.scheduleNextRefresh() this.scheduleNextRefresh()
} catch { } catch {
// If refresh fails, try again in 1 minute // If refresh fails, try again in 1 minute
this.refreshTimer = setTimeout(() => { this.refreshTimer = setTimeout(() => {
@@ -87,7 +88,6 @@ export class TokenRefreshManager {
} }
} }
/** /**
* Handle tab visibility changes * Handle tab visibility changes
*/ */

View File

@@ -1,5 +1,5 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from 'clsx'
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))

View File

@@ -1,30 +1,45 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { toast } from 'sonner'
import { import {
User, Dialog,
Key, DialogContent,
Shield, DialogHeader,
Palette, DialogTitle,
Eye, } from '@/components/ui/dialog'
EyeOff, import { Input } from '@/components/ui/input'
Copy, import { Label } from '@/components/ui/label'
Trash2, import {
Github, Select,
Mail, SelectContent,
CheckCircle2 SelectItem,
} from 'lucide-react' SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/hooks/use-theme' 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() { export function AccountPage() {
const { user, setUser } = useAuth() const { user, setUser } = useAuth()
@@ -38,14 +53,15 @@ export function AccountPage() {
const [passwordData, setPasswordData] = useState({ const [passwordData, setPasswordData] = useState({
current_password: '', current_password: '',
new_password: '', new_password: '',
confirm_password: '' confirm_password: '',
}) })
const [passwordSaving, setPasswordSaving] = useState(false) const [passwordSaving, setPasswordSaving] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false)
// API Token state // API Token state
const [apiTokenStatus, setApiTokenStatus] = useState<ApiTokenStatusResponse | null>(null) const [apiTokenStatus, setApiTokenStatus] =
useState<ApiTokenStatusResponse | null>(null)
const [apiTokenLoading, setApiTokenLoading] = useState(true) const [apiTokenLoading, setApiTokenLoading] = useState(true)
const [generatedToken, setGeneratedToken] = useState('') const [generatedToken, setGeneratedToken] = useState('')
const [showGeneratedToken, setShowGeneratedToken] = useState(false) const [showGeneratedToken, setShowGeneratedToken] = useState(false)
@@ -91,7 +107,9 @@ export function AccountPage() {
setProfileSaving(true) setProfileSaving(true)
try { try {
const updatedUser = await authService.updateProfile({ name: profileName.trim() }) const updatedUser = await authService.updateProfile({
name: profileName.trim(),
})
setUser?.(updatedUser) setUser?.(updatedUser)
toast.success('Profile updated successfully') toast.success('Profile updated successfully')
} catch (error) { } catch (error) {
@@ -104,7 +122,9 @@ export function AccountPage() {
const handlePasswordChange = async () => { const handlePasswordChange = async () => {
// Check if user has password authentication from providers // 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 // Validate required fields
if (hasPasswordProvider && !passwordData.current_password) { if (hasPasswordProvider && !passwordData.current_password) {
@@ -130,11 +150,21 @@ export function AccountPage() {
setPasswordSaving(true) setPasswordSaving(true)
try { try {
await authService.changePassword({ await authService.changePassword({
current_password: hasPasswordProvider ? passwordData.current_password : undefined, current_password: hasPasswordProvider
new_password: passwordData.new_password ? passwordData.current_password
: undefined,
new_password: passwordData.new_password,
}) })
setPasswordData({ current_password: '', new_password: '', confirm_password: '' }) setPasswordData({
toast.success(hasPasswordProvider ? 'Password changed successfully' : 'Password set successfully') current_password: '',
new_password: '',
confirm_password: '',
})
toast.success(
hasPasswordProvider
? 'Password changed successfully'
: 'Password set successfully',
)
// Reload providers since password status might have changed // Reload providers since password status might have changed
loadProviders() loadProviders()
@@ -149,7 +179,7 @@ export function AccountPage() {
const handleGenerateApiToken = async () => { const handleGenerateApiToken = async () => {
try { try {
const response = await authService.generateApiToken({ const response = await authService.generateApiToken({
expires_days: parseInt(tokenExpireDays) expires_days: parseInt(tokenExpireDays),
}) })
setGeneratedToken(response.api_token) setGeneratedToken(response.api_token)
setShowGeneratedToken(true) setShowGeneratedToken(true)
@@ -194,10 +224,7 @@ export function AccountPage() {
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
items: [ items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
{ label: 'Dashboard', href: '/' },
{ label: 'Account' }
]
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -225,10 +252,7 @@ export function AccountPage() {
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
items: [ items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
{ label: 'Dashboard', href: '/' },
{ label: 'Account' }
]
}} }}
> >
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
@@ -264,7 +288,7 @@ export function AccountPage() {
<Input <Input
id="name" id="name"
value={profileName} value={profileName}
onChange={(e) => setProfileName(e.target.value)} onChange={e => setProfileName(e.target.value)}
placeholder="Enter your display name" placeholder="Enter your display name"
/> />
</div> </div>
@@ -272,10 +296,29 @@ export function AccountPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Account Details</Label> <Label>Account Details</Label>
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
<div>Role: <Badge variant={user.role === 'admin' ? 'destructive' : 'secondary'}>{user.role}</Badge></div> <div>
<div>Credits: <span className="font-medium">{user.credits.toLocaleString()}</span></div> Role:{' '}
<div>Plan: <span className="font-medium">{user.plan.name}</span></div> <Badge
<div>Member since: {new Date(user.created_at).toLocaleDateString()}</div> 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>
</div> </div>
@@ -300,7 +343,12 @@ export function AccountPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Theme Preference</Label> <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> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -317,7 +365,8 @@ export function AccountPage() {
<div className="pt-4"> <div className="pt-4">
<div className="text-sm text-muted-foreground"> <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>
</div> </div>
</CardContent> </CardContent>
@@ -341,7 +390,12 @@ export function AccountPage() {
id="current-password" id="current-password"
type={showCurrentPassword ? 'text' : 'password'} type={showCurrentPassword ? 'text' : 'password'}
value={passwordData.current_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" placeholder="Enter current password"
/> />
<Button <Button
@@ -349,7 +403,9 @@ export function AccountPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowCurrentPassword(!showCurrentPassword)} onClick={() =>
setShowCurrentPassword(!showCurrentPassword)
}
> >
{showCurrentPassword ? ( {showCurrentPassword ? (
<EyeOff className="h-4 w-4" /> <EyeOff className="h-4 w-4" />
@@ -367,7 +423,12 @@ export function AccountPage() {
id="new-password" id="new-password"
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
value={passwordData.new_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" placeholder="Enter new password"
/> />
<Button <Button
@@ -387,12 +448,19 @@ export function AccountPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label> <Label htmlFor="confirm-password">
Confirm New Password
</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
value={passwordData.confirm_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" placeholder="Confirm new password"
/> />
</div> </div>
@@ -402,7 +470,9 @@ export function AccountPage() {
disabled={passwordSaving} disabled={passwordSaving}
className="w-full" className="w-full"
> >
{passwordSaving ? 'Changing Password...' : 'Change Password'} {passwordSaving
? 'Changing Password...'
: 'Change Password'}
</Button> </Button>
</> </>
) : ( ) : (
@@ -411,7 +481,8 @@ export function AccountPage() {
<p className="text-sm text-blue-800 dark:text-blue-200"> <p className="text-sm text-blue-800 dark:text-blue-200">
💡 <strong>Set up password authentication</strong> 💡 <strong>Set up password authentication</strong>
<br /> <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> </p>
</div> </div>
@@ -422,7 +493,12 @@ export function AccountPage() {
id="new-password" id="new-password"
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
value={passwordData.new_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" placeholder="Enter your new password"
/> />
<Button <Button
@@ -447,7 +523,12 @@ export function AccountPage() {
id="confirm-password" id="confirm-password"
type="password" type="password"
value={passwordData.confirm_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" placeholder="Confirm your password"
/> />
</div> </div>
@@ -487,7 +568,11 @@ export function AccountPage() {
<span>API Token Active</span> <span>API Token Active</span>
{apiTokenStatus.expires_at && ( {apiTokenStatus.expires_at && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
(Expires: {new Date(apiTokenStatus.expires_at).toLocaleDateString()}) (Expires:{' '}
{new Date(
apiTokenStatus.expires_at,
).toLocaleDateString()}
)
</span> </span>
)} )}
</div> </div>
@@ -505,7 +590,10 @@ export function AccountPage() {
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="expire-days">Token Expiration</Label> <Label htmlFor="expire-days">Token Expiration</Label>
<Select value={tokenExpireDays} onValueChange={setTokenExpireDays}> <Select
value={tokenExpireDays}
onValueChange={setTokenExpireDays}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -517,7 +605,10 @@ export function AccountPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button onClick={handleGenerateApiToken} className="w-full"> <Button
onClick={handleGenerateApiToken}
className="w-full"
>
Generate API Token Generate API Token
</Button> </Button>
</div> </div>
@@ -526,7 +617,8 @@ export function AccountPage() {
)} )}
<div className="text-xs text-muted-foreground"> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -540,14 +632,18 @@ export function AccountPage() {
Authentication Methods Authentication Methods
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground"> <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> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{providersLoading ? ( {providersLoading ? (
<div className="space-y-3"> <div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => ( {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-4 w-24" />
<Skeleton className="h-8 w-20" /> <Skeleton className="h-8 w-20" />
</div> </div>
@@ -556,24 +652,35 @@ export function AccountPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{/* All Authentication Providers from API */} {/* All Authentication Providers from API */}
{providers.map((provider) => { {providers.map(provider => {
const isOAuth = provider.provider !== 'password' const isOAuth = provider.provider !== 'password'
return ( 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"> <div className="flex items-center gap-2">
{getProviderIcon(provider.provider)} {getProviderIcon(provider.provider)}
<span className="font-medium">{provider.display_name}</span> <span className="font-medium">
{provider.display_name}
</span>
<Badge variant="secondary"> <Badge variant="secondary">
{isOAuth ? 'OAuth' : 'Password Authentication'} {isOAuth ? 'OAuth' : 'Password Authentication'}
</Badge> </Badge>
{provider.connected_at && ( {provider.connected_at && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Connected {new Date(provider.connected_at).toLocaleDateString()} Connected{' '}
{new Date(
provider.connected_at,
).toLocaleDateString()}
</span> </span>
)} )}
</div> </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 Available
</Badge> </Badge>
</div> </div>
@@ -589,11 +696,17 @@ export function AccountPage() {
<Badge variant="secondary">API Access</Badge> <Badge variant="secondary">API Access</Badge>
{apiTokenStatus.expires_at && ( {apiTokenStatus.expires_at && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Expires {new Date(apiTokenStatus.expires_at).toLocaleDateString()} Expires{' '}
{new Date(
apiTokenStatus.expires_at,
).toLocaleDateString()}
</span> </span>
)} )}
</div> </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 Available
</Badge> </Badge>
</div> </div>
@@ -637,11 +750,14 @@ export function AccountPage() {
</div> </div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg"> <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"> <p className="text-sm text-yellow-800 dark:text-yellow-200">
⚠️ <strong>Important:</strong> This token will only be shown once. ⚠️ <strong>Important:</strong> This token will only be shown
Copy it now and store it securely. once. Copy it now and store it securely.
</p> </p>
</div> </div>
<Button onClick={() => setShowGeneratedToken(false)} className="w-full"> <Button
onClick={() => setShowGeneratedToken(false)}
className="w-full"
>
I've Saved My Token I've Saved My Token
</Button> </Button>
</div> </div>

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
export function AuthCallbackPage() { export function AuthCallbackPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { setUser } = useAuth() const { setUser } = useAuth()
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing') const [status, setStatus] = useState<'processing' | 'success' | 'error'>(
'processing',
)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
useEffect(() => { useEffect(() => {
@@ -35,10 +37,11 @@ export function AuthCallbackPage() {
setTimeout(() => { setTimeout(() => {
navigate('/') navigate('/')
}, 1000) }, 1000)
} catch (error) { } catch (error) {
console.error('OAuth callback failed:', 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') setStatus('error')
// Redirect to login after error // Redirect to login after error
@@ -57,25 +60,37 @@ export function AuthCallbackPage() {
{status === 'processing' && ( {status === 'processing' && (
<div> <div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></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> <h2 className="mt-4 text-xl font-semibold">
<p className="text-gray-600 dark:text-gray-400">Please wait while we set up your account.</p> Completing sign in...
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we set up your account.
</p>
</div> </div>
)} )}
{status === 'success' && ( {status === 'success' && (
<div> <div>
<div className="text-green-600 text-4xl mb-4"></div> <div className="text-green-600 text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-green-600">Sign in successful!</h2> <h2 className="text-xl font-semibold text-green-600">
<p className="text-gray-600 dark:text-gray-400">Redirecting to dashboard...</p> Sign in successful!
</h2>
<p className="text-gray-600 dark:text-gray-400">
Redirecting to dashboard...
</p>
</div> </div>
)} )}
{status === 'error' && ( {status === 'error' && (
<div> <div>
<div className="text-red-600 text-4xl mb-4"></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-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> </div>

View File

@@ -1,12 +1,27 @@
import { useCallback, useEffect, useState } from 'react'
import { AppLayout } from '@/components/AppLayout' 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 { Button } from '@/components/ui/button'
import { Volume2, Play, Clock, HardDrive, Music, Trophy, Loader2, RefreshCw } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import NumberFlow from '@number-flow/react'
import { NumberFlowDuration } from '@/components/ui/number-flow-duration' import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
import { NumberFlowSize } from '@/components/ui/number-flow-size' 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 { interface SoundboardStatistics {
sound_count: number sound_count: number
@@ -32,8 +47,10 @@ interface TopSound {
} }
export function DashboardPage() { export function DashboardPage() {
const [soundboardStatistics, setSoundboardStatistics] = useState<SoundboardStatistics | null>(null) const [soundboardStatistics, setSoundboardStatistics] =
const [trackStatistics, setTrackStatistics] = useState<TrackStatistics | null>(null) useState<SoundboardStatistics | null>(null)
const [trackStatistics, setTrackStatistics] =
useState<TrackStatistics | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -48,8 +65,10 @@ export function DashboardPage() {
const fetchStatistics = useCallback(async () => { const fetchStatistics = useCallback(async () => {
try { try {
const [soundboardResponse, trackResponse] = await Promise.all([ const [soundboardResponse, trackResponse] = await Promise.all([
fetch('/api/v1/dashboard/soundboard-statistics', { credentials: 'include' }), fetch('/api/v1/dashboard/soundboard-statistics', {
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }) credentials: 'include',
}),
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }),
]) ])
if (!soundboardResponse.ok || !trackResponse.ok) { if (!soundboardResponse.ok || !trackResponse.ok) {
@@ -58,7 +77,7 @@ export function DashboardPage() {
const [soundboardData, trackData] = await Promise.all([ const [soundboardData, trackData] = await Promise.all([
soundboardResponse.json(), soundboardResponse.json(),
trackResponse.json() trackResponse.json(),
]) ])
setSoundboardStatistics(soundboardData) setSoundboardStatistics(soundboardData)
@@ -68,7 +87,8 @@ export function DashboardPage() {
} }
}, []) }, [])
const fetchTopSounds = useCallback(async (showLoading = false) => { const fetchTopSounds = useCallback(
async (showLoading = false) => {
try { try {
if (showLoading) { if (showLoading) {
setTopSoundsLoading(true) setTopSoundsLoading(true)
@@ -76,7 +96,7 @@ export function DashboardPage() {
const response = await fetch( const response = await fetch(
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`, `/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
{ credentials: 'include' } { credentials: 'include' },
) )
if (!response.ok) { if (!response.ok) {
@@ -88,7 +108,9 @@ export function DashboardPage() {
// Graceful update: merge new data while preserving animations // Graceful update: merge new data while preserving animations
setTopSounds(prevTopSounds => { setTopSounds(prevTopSounds => {
// Create a map of existing sounds for efficient lookup // Create a map of existing sounds for efficient lookup
const existingSoundsMap = new Map(prevTopSounds.map(sound => [sound.id, sound])) const existingSoundsMap = new Map(
prevTopSounds.map(sound => [sound.id, sound]),
)
// Update existing sounds and add new ones // Update existing sounds and add new ones
return data.map((newSound: TopSound) => { return data.map((newSound: TopSound) => {
@@ -114,15 +136,14 @@ export function DashboardPage() {
setTopSoundsLoading(false) setTopSoundsLoading(false)
} }
} }
}, [soundType, period, limit]) },
[soundType, period, limit],
)
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
setRefreshing(true) setRefreshing(true)
try { try {
await Promise.all([ await Promise.all([fetchStatistics(), fetchTopSounds()])
fetchStatistics(),
fetchTopSounds()
])
} finally { } finally {
setRefreshing(false) setRefreshing(false)
} }
@@ -158,9 +179,7 @@ export function DashboardPage() {
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
items: [ items: [{ label: 'Dashboard' }],
{ label: 'Dashboard' }
]
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -174,30 +193,42 @@ export function DashboardPage() {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div> <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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i}> <Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold animate-pulse">---</div> <div className="text-2xl font-bold animate-pulse">
---
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
</div> </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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i + 4}> <Card key={i + 4}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold animate-pulse">---</div> <div className="text-2xl font-bold animate-pulse">
---
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -213,9 +244,7 @@ export function DashboardPage() {
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
items: [ items: [{ label: 'Dashboard' }],
{ label: 'Dashboard' }
]
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -228,7 +257,9 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4"> <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>
</div> </div>
</AppLayout> </AppLayout>
@@ -238,9 +269,7 @@ export function DashboardPage() {
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
items: [ items: [{ label: 'Dashboard' }],
{ label: 'Dashboard' }
]
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -257,22 +286,30 @@ export function DashboardPage() {
size="sm" size="sm"
disabled={refreshing} disabled={refreshing}
> >
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
/>
</Button> </Button>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{/* Soundboard Statistics */} {/* Soundboard Statistics */}
<div> <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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <Volume2 className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <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"> <p className="text-xs text-muted-foreground">
Soundboard audio files Soundboard audio files
</p> </p>
@@ -281,11 +318,15 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <Play className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <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"> <p className="text-xs text-muted-foreground">
All-time play count All-time play count
</p> </p>
@@ -294,12 +335,17 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
<NumberFlowDuration duration={soundboardStatistics.total_duration} variant='wordy' /> <NumberFlowDuration
duration={soundboardStatistics.total_duration}
variant="wordy"
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Combined audio duration Combined audio duration
@@ -309,12 +355,17 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
<NumberFlowSize size={soundboardStatistics.total_size} binary={true} /> <NumberFlowSize
size={soundboardStatistics.total_size}
binary={true}
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Original + normalized files Original + normalized files
@@ -326,15 +377,21 @@ export function DashboardPage() {
{/* Track Statistics */} {/* Track Statistics */}
<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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <Music className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <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"> <p className="text-xs text-muted-foreground">
Extracted audio tracks Extracted audio tracks
</p> </p>
@@ -343,11 +400,15 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <Play className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <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"> <p className="text-xs text-muted-foreground">
All-time play count All-time play count
</p> </p>
@@ -356,12 +417,17 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
<NumberFlowDuration duration={trackStatistics.total_duration} variant='wordy' /> <NumberFlowDuration
duration={trackStatistics.total_duration}
variant="wordy"
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Combined track duration Combined track duration
@@ -371,12 +437,17 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
<NumberFlowSize size={trackStatistics.total_size} binary={true} /> <NumberFlowSize
size={trackStatistics.total_size}
binary={true}
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Original + normalized files Original + normalized files
@@ -428,7 +499,10 @@ export function DashboardPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">Count:</span> <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"> <SelectTrigger className="w-20">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -457,18 +531,26 @@ export function DashboardPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{topSounds.map((sound, index) => ( {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"> <div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
{index + 1} {index + 1}
</div> </div>
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-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"> <div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
{sound.duration && ( {sound.duration && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<NumberFlowDuration duration={sound.duration} variant='wordy' /> <NumberFlowDuration
duration={sound.duration}
variant="wordy"
/>
</span> </span>
)} )}
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs"> <span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
@@ -477,8 +559,12 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-primary"><NumberFlow value={sound.play_count} /></div> <div className="text-2xl font-bold text-primary">
<div className="text-xs text-muted-foreground">plays</div> <NumberFlow value={sound.play_count} />
</div>
<div className="text-xs text-muted-foreground">
plays
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,16 +1,41 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import {
import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react' Table,
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions' TableBody,
import { toast } from 'sonner' TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
type ExtractionInfo,
extractionsService,
} from '@/lib/api/services/extractions'
import { formatDistanceToNow } from 'date-fns' 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() { export function ExtractionsPage() {
const [extractions, setExtractions] = useState<ExtractionInfo[]>([]) const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
@@ -63,13 +88,33 @@ export function ExtractionsPage() {
const getStatusBadge = (status: ExtractionInfo['status']) => { const getStatusBadge = (status: ExtractionInfo['status']) => {
switch (status) { switch (status) {
case 'pending': 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': 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': 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': case 'failed':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Failed</Badge> return (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Failed
</Badge>
)
} }
} }
@@ -78,14 +123,18 @@ export function ExtractionsPage() {
const serviceColors: Record<string, string> = { const serviceColors: Record<string, string> = {
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', 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', 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', 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', 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 ( return (
<Badge variant="outline" className={colorClass}> <Badge variant="outline" className={colorClass}>
@@ -97,10 +146,7 @@ export function ExtractionsPage() {
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
items: [ items: [{ label: 'Dashboard', href: '/' }, { label: 'Extractions' }],
{ label: 'Dashboard', href: '/' },
{ label: 'Extractions' }
]
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -130,22 +176,29 @@ export function ExtractionsPage() {
id="url" id="url"
placeholder="https://www.youtube.com/watch?v=..." placeholder="https://www.youtube.com/watch?v=..."
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={e => setUrl(e.target.value)}
onKeyDown={(e) => { onKeyDown={e => {
if (e.key === 'Enter' && !isCreating) { if (e.key === 'Enter' && !isCreating) {
handleCreateExtraction() handleCreateExtraction()
} }
}} }}
/> />
<p className="text-sm text-muted-foreground mt-1"> <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> </p>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}> <Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreateExtraction} disabled={isCreating}> <Button
onClick={handleCreateExtraction}
disabled={isCreating}
>
{isCreating ? ( {isCreating ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -171,9 +224,12 @@ export function ExtractionsPage() {
<CardContent className="py-8"> <CardContent className="py-8">
<div className="text-center"> <div className="text-center">
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" /> <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"> <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> </p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2"> <Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -199,7 +255,7 @@ export function ExtractionsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{extractions.map((extraction) => ( {extractions.map(extraction => (
<TableRow key={extraction.id}> <TableRow key={extraction.id}>
<TableCell> <TableCell>
<div> <div>
@@ -217,7 +273,10 @@ export function ExtractionsPage() {
<TableCell> <TableCell>
{getStatusBadge(extraction.status)} {getStatusBadge(extraction.status)}
{extraction.error && ( {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} {extraction.error}
</div> </div>
)} )}
@@ -231,7 +290,9 @@ export function ExtractionsPage() {
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return 'Invalid date' return 'Invalid date'
} }
return formatDistanceToNow(date, { addSuffix: true }) return formatDistanceToNow(date, {
addSuffix: true,
})
} catch { } catch {
return 'Invalid date' return 'Invalid date'
} }
@@ -241,12 +302,21 @@ export function ExtractionsPage() {
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" asChild> <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" /> <ExternalLink className="h-4 w-4" />
</a> </a>
</Button> </Button>
{extraction.status === 'completed' && extraction.sound_id && ( {extraction.status === 'completed' &&
<Button variant="ghost" size="sm" title="View in Sounds"> extraction.sound_id && (
<Button
variant="ghost"
size="sm"
title="View in Sounds"
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
)} )}

View File

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

View File

@@ -1,38 +1,61 @@
import { useEffect, useState, useCallback } from 'react' import { AppLayout } from '@/components/AppLayout'
import { useParams, useNavigate } from 'react-router' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import {
type Playlist,
type PlaylistSound,
playlistsService,
} from '@/lib/api/services/playlists'
import { type Sound, soundsService } from '@/lib/api/services/sounds'
import { formatDuration } from '@/utils/format-duration'
import { import {
DndContext, DndContext,
type DragEndEvent, type DragEndEvent,
type DragStartEvent,
type DragOverEvent, type DragOverEvent,
closestCenter, DragOverlay,
type DragStartEvent,
PointerSensor, PointerSensor,
closestCenter,
useSensor, useSensor,
useSensors, useSensors,
DragOverlay,
} from '@dnd-kit/core' } from '@dnd-kit/core'
import { useDroppable } from '@dnd-kit/core'
import { import {
SortableContext, SortableContext,
verticalListSortingStrategy,
useSortable, useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { useDroppable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { AppLayout } from '@/components/AppLayout' import {
import { playlistsService, type Playlist, type PlaylistSound } from '@/lib/api/services/playlists' AlertCircle,
import { soundsService, type Sound } from '@/lib/api/services/sounds' ChevronDown,
import { Skeleton } from '@/components/ui/skeleton' ChevronUp,
import { Input } from '@/components/ui/input' Clock,
import { Button } from '@/components/ui/button' Edit,
import { Textarea } from '@/components/ui/textarea' Minus,
import { Label } from '@/components/ui/label' Music,
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' Plus,
import { Badge } from '@/components/ui/badge' RefreshCw,
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' Save,
import { AlertCircle, Save, Music, Clock, ChevronUp, ChevronDown, Trash2, RefreshCw, Edit, X, Plus, Minus } from 'lucide-react' Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDuration } from '@/utils/format-duration'
// Sortable table row component for normal table view // Sortable table row component for normal table view
interface SortableTableRowProps { interface SortableTableRowProps {
@@ -50,7 +73,7 @@ function SortableTableRow({
onMoveSoundUp, onMoveSoundUp,
onMoveSoundDown, onMoveSoundDown,
onRemoveSound, onRemoveSound,
totalSounds totalSounds,
}: SortableTableRowProps) { }: SortableTableRowProps) {
const { const {
attributes, attributes,
@@ -92,9 +115,7 @@ function SortableTableRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium truncate"> <div className="font-medium truncate">{sound.name}</div>
{sound.name}
</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
@@ -149,7 +170,11 @@ interface SimpleSortableRowProps {
onRemoveSound: (soundId: number) => void onRemoveSound: (soundId: number) => void
} }
function SimpleSortableRow({ sound, index, onRemoveSound }: SimpleSortableRowProps) { function SimpleSortableRow({
sound,
index,
onRemoveSound,
}: SimpleSortableRowProps) {
const { const {
attributes, attributes,
listeners, listeners,
@@ -180,15 +205,13 @@ function SimpleSortableRow({ sound, index, onRemoveSound }: SimpleSortableRowPro
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium truncate"> <div className="font-medium truncate">{sound.name}</div>
{sound.name}
</div>
</div> </div>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={(e) => { onClick={e => {
e.stopPropagation() e.stopPropagation()
onRemoveSound(sound.id) onRemoveSound(sound.id)
}} }}
@@ -234,15 +257,13 @@ function AvailableSound({ sound, onAddToPlaylist }: AvailableSoundProps) {
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium truncate"> <div className="font-medium truncate">{sound.name}</div>
{sound.name}
</div>
</div> </div>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={(e) => { onClick={e => {
e.stopPropagation() e.stopPropagation()
onAddToPlaylist(sound.id) onAddToPlaylist(sound.id)
}} }}
@@ -255,12 +276,11 @@ function AvailableSound({ sound, onAddToPlaylist }: AvailableSoundProps) {
) )
} }
// Simple drop area for the end of the playlist // Simple drop area for the end of the playlist
function EndDropArea() { function EndDropArea() {
const { setNodeRef } = useDroppable({ const { setNodeRef } = useDroppable({
id: 'playlist-end', id: 'playlist-end',
data: { type: 'playlist-end' } data: { type: 'playlist-end' },
}) })
return ( return (
@@ -287,9 +307,7 @@ function InlinePreview({ sound, position }: InlinePreviewProps) {
<Music className="h-4 w-4 text-primary flex-shrink-0" /> <Music className="h-4 w-4 text-primary flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium truncate text-primary"> <div className="font-medium truncate text-primary">{sound.name}</div>
{sound.name}
</div>
</div> </div>
</div> </div>
) )
@@ -313,9 +331,7 @@ function DragOverlayContent({ sound, position }: DragOverlayContentProps) {
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium truncate"> <div className="font-medium truncate">{sound.name}</div>
{sound.name}
</div>
</div> </div>
</div> </div>
) )
@@ -327,15 +343,12 @@ function DragOverlayContent({ sound, position }: DragOverlayContentProps) {
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium truncate"> <div className="font-medium truncate">{sound.name}</div>
{sound.name}
</div>
</div> </div>
</div> </div>
) )
} }
export function PlaylistEditPage() { export function PlaylistEditPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -354,24 +367,25 @@ export function PlaylistEditPage() {
const [availableSounds, setAvailableSounds] = useState<Sound[]>([]) const [availableSounds, setAvailableSounds] = useState<Sound[]>([])
const [availableSoundsLoading, setAvailableSoundsLoading] = useState(false) const [availableSoundsLoading, setAvailableSoundsLoading] = useState(false)
const [draggedItem, setDraggedItem] = useState<string | null>(null) const [draggedItem, setDraggedItem] = useState<string | null>(null)
const [draggedSound, setDraggedSound] = useState<Sound | PlaylistSound | null>(null) const [draggedSound, setDraggedSound] = useState<
Sound | PlaylistSound | null
>(null)
const [dropPosition, setDropPosition] = useState<number | null>(null) const [dropPosition, setDropPosition] = useState<number | null>(null)
// dnd-kit sensors // dnd-kit sensors
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8, distance: 8,
}, },
}) }),
) )
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
description: '', description: '',
genre: '' genre: '',
}) })
// Track if form has changes // Track if form has changes
@@ -386,10 +400,11 @@ export function PlaylistEditPage() {
setFormData({ setFormData({
name: playlistData.name, name: playlistData.name,
description: playlistData.description || '', description: playlistData.description || '',
genre: playlistData.genre || '' genre: playlistData.genre || '',
}) })
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlist' const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch playlist'
setError(errorMessage) setError(errorMessage)
toast.error(errorMessage) toast.error(errorMessage)
} finally { } finally {
@@ -403,7 +418,8 @@ export function PlaylistEditPage() {
const soundsData = await playlistsService.getPlaylistSounds(playlistId) const soundsData = await playlistsService.getPlaylistSounds(playlistId)
setSounds(soundsData) setSounds(soundsData)
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlist sounds' const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch playlist sounds'
toast.error(errorMessage) toast.error(errorMessage)
} finally { } finally {
setSoundsLoading(false) setSoundsLoading(false)
@@ -416,10 +432,13 @@ export function PlaylistEditPage() {
const soundsData = await soundsService.getSoundsByType('EXT') const soundsData = await soundsService.getSoundsByType('EXT')
// Filter out sounds that are already in the playlist // Filter out sounds that are already in the playlist
const playlistSoundIds = new Set(sounds.map(s => s.id)) const playlistSoundIds = new Set(sounds.map(s => s.id))
const filteredSounds = soundsData.filter(sound => !playlistSoundIds.has(sound.id)) const filteredSounds = soundsData.filter(
sound => !playlistSoundIds.has(sound.id),
)
setAvailableSounds(filteredSounds) setAvailableSounds(filteredSounds)
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch available sounds' const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch available sounds'
toast.error(errorMessage) toast.error(errorMessage)
} finally { } finally {
setAvailableSoundsLoading(false) setAvailableSoundsLoading(false)
@@ -457,7 +476,7 @@ export function PlaylistEditPage() {
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[field]: value [field]: value,
})) }))
} }
@@ -469,7 +488,7 @@ export function PlaylistEditPage() {
await playlistsService.updatePlaylist(playlist.id, { await playlistsService.updatePlaylist(playlist.id, {
name: formData.name.trim() || undefined, name: formData.name.trim() || undefined,
description: formData.description.trim() || undefined, description: formData.description.trim() || undefined,
genre: formData.genre.trim() || undefined genre: formData.genre.trim() || undefined,
}) })
toast.success('Playlist updated successfully') toast.success('Playlist updated successfully')
@@ -478,7 +497,8 @@ export function PlaylistEditPage() {
await fetchPlaylist() await fetchPlaylist()
setIsEditMode(false) setIsEditMode(false)
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update playlist' const errorMessage =
err instanceof Error ? err.message : 'Failed to update playlist'
toast.error(errorMessage) toast.error(errorMessage)
} finally { } finally {
setSaving(false) setSaving(false)
@@ -491,7 +511,7 @@ export function PlaylistEditPage() {
setFormData({ setFormData({
name: playlist.name, name: playlist.name,
description: playlist.description || '', description: playlist.description || '',
genre: playlist.genre || '' genre: playlist.genre || '',
}) })
setIsEditMode(false) setIsEditMode(false)
} }
@@ -507,7 +527,8 @@ export function PlaylistEditPage() {
// Refresh playlist data // Refresh playlist data
await fetchPlaylist() await fetchPlaylist()
} catch (err) { } 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) toast.error(errorMessage)
} }
} }
@@ -520,14 +541,17 @@ export function PlaylistEditPage() {
newSounds.splice(index - 1, 0, movedSound) newSounds.splice(index - 1, 0, movedSound)
// Create sound positions array for the API // Create sound positions array for the API
const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) const soundPositions: Array<[number, number]> = newSounds.map(
(sound, idx) => [sound.id, idx],
)
try { try {
await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) await playlistsService.reorderPlaylistSounds(playlistId, soundPositions)
setSounds(newSounds) setSounds(newSounds)
toast.success('Sound moved up') toast.success('Sound moved up')
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to reorder sounds' const errorMessage =
err instanceof Error ? err.message : 'Failed to reorder sounds'
toast.error(errorMessage) toast.error(errorMessage)
} }
} }
@@ -540,14 +564,17 @@ export function PlaylistEditPage() {
newSounds.splice(index + 1, 0, movedSound) newSounds.splice(index + 1, 0, movedSound)
// Create sound positions array for the API // Create sound positions array for the API
const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) const soundPositions: Array<[number, number]> = newSounds.map(
(sound, idx) => [sound.id, idx],
)
try { try {
await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) await playlistsService.reorderPlaylistSounds(playlistId, soundPositions)
setSounds(newSounds) setSounds(newSounds)
toast.success('Sound moved down') toast.success('Sound moved down')
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to reorder sounds' const errorMessage =
err instanceof Error ? err.message : 'Failed to reorder sounds'
toast.error(errorMessage) toast.error(errorMessage)
} }
} }
@@ -575,11 +602,16 @@ export function PlaylistEditPage() {
// Only update the playlist counter optimistically (avoid full refetch) // Only update the playlist counter optimistically (avoid full refetch)
if (playlist) { if (playlist) {
setPlaylist(prev => prev ? { setPlaylist(prev =>
prev
? {
...prev, ...prev,
sound_count: (prev.sound_count || 0) + 1, sound_count: (prev.sound_count || 0) + 1,
total_duration: (prev.total_duration || 0) + (soundToAdd.duration || 0) total_duration:
} : null) (prev.total_duration || 0) + (soundToAdd.duration || 0),
}
: null,
)
} }
} else { } else {
// Fallback to refresh if we can't find the sound data // Fallback to refresh if we can't find the sound data
@@ -587,7 +619,8 @@ export function PlaylistEditPage() {
await fetchPlaylist() await fetchPlaylist()
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist' const errorMessage =
err instanceof Error ? err.message : 'Failed to add sound to playlist'
toast.error(errorMessage) toast.error(errorMessage)
} }
} }
@@ -611,7 +644,11 @@ export function PlaylistEditPage() {
const allExtSounds = await soundsService.getSoundsByType('EXT') const allExtSounds = await soundsService.getSoundsByType('EXT')
const soundToAddBack = allExtSounds.find(s => s.id === soundId) const soundToAddBack = allExtSounds.find(s => s.id === soundId)
if (soundToAddBack) { if (soundToAddBack) {
setAvailableSounds(prev => [...prev, soundToAddBack].sort((a, b) => a.name.localeCompare(b.name))) setAvailableSounds(prev =>
[...prev, soundToAddBack].sort((a, b) =>
a.name.localeCompare(b.name),
),
)
} }
} catch { } catch {
// If we can't fetch the sound data, just refresh the available sounds // If we can't fetch the sound data, just refresh the available sounds
@@ -621,14 +658,22 @@ export function PlaylistEditPage() {
// Optimistically update playlist stats // Optimistically update playlist stats
if (playlist) { if (playlist) {
setPlaylist(prev => prev ? { setPlaylist(prev =>
prev
? {
...prev, ...prev,
sound_count: Math.max(0, (prev.sound_count || 0) - 1), sound_count: Math.max(0, (prev.sound_count || 0) - 1),
total_duration: Math.max(0, (prev.total_duration || 0) - (removedSound.duration || 0)) total_duration: Math.max(
} : null) 0,
(prev.total_duration || 0) - (removedSound.duration || 0),
),
}
: null,
)
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to remove sound' const errorMessage =
err instanceof Error ? err.message : 'Failed to remove sound'
toast.error(errorMessage) toast.error(errorMessage)
// On error, refresh to get correct state // On error, refresh to get correct state
await fetchSounds() await fetchSounds()
@@ -636,7 +681,6 @@ export function PlaylistEditPage() {
} }
} }
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
const draggedId = event.active.id.toString() const draggedId = event.active.id.toString()
setDraggedItem(draggedId) setDraggedItem(draggedId)
@@ -647,9 +691,13 @@ export function PlaylistEditPage() {
const soundId = parseInt(draggedId.replace('available-sound-', ''), 10) const soundId = parseInt(draggedId.replace('available-sound-', ''), 10)
const sound = availableSounds.find(s => s.id === soundId) const sound = availableSounds.find(s => s.id === soundId)
setDraggedSound(sound || null) setDraggedSound(sound || null)
} else if (draggedId.startsWith('playlist-sound-') || draggedId.startsWith('table-sound-')) { } else if (
draggedId.startsWith('playlist-sound-') ||
draggedId.startsWith('table-sound-')
) {
const soundId = parseInt( const soundId = parseInt(
draggedId.replace('playlist-sound-', '').replace('table-sound-', ''), 10 draggedId.replace('playlist-sound-', '').replace('table-sound-', ''),
10,
) )
const sound = sounds.find(s => s.id === soundId) const sound = sounds.find(s => s.id === soundId)
setDraggedSound(sound || null) setDraggedSound(sound || null)
@@ -668,13 +716,19 @@ export function PlaylistEditPage() {
const overId = over.id as string const overId = over.id as string
// Handle adding sound from available list to playlist // Handle adding sound from available list to playlist
if (activeId.startsWith('available-sound-') && (overId.startsWith('playlist-sound-') || overId === 'playlist-end')) { if (
activeId.startsWith('available-sound-') &&
(overId.startsWith('playlist-sound-') || overId === 'playlist-end')
) {
const soundId = parseInt(activeId.replace('available-sound-', ''), 10) const soundId = parseInt(activeId.replace('available-sound-', ''), 10)
let position = sounds.length // Default to end let position = sounds.length // Default to end
if (overId.startsWith('playlist-sound-')) { if (overId.startsWith('playlist-sound-')) {
const targetSoundId = parseInt(overId.replace('playlist-sound-', ''), 10) const targetSoundId = parseInt(
overId.replace('playlist-sound-', ''),
10,
)
const targetIndex = sounds.findIndex(s => s.id === targetSoundId) const targetIndex = sounds.findIndex(s => s.id === targetSoundId)
position = targetIndex position = targetIndex
} else if (overId === 'playlist-end') { } else if (overId === 'playlist-end') {
@@ -693,41 +747,57 @@ export function PlaylistEditPage() {
setAvailableSounds(prev => prev.filter(s => s.id !== soundId)) setAvailableSounds(prev => prev.filter(s => s.id !== soundId))
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add sound to playlist' const errorMessage =
err instanceof Error ? err.message : 'Failed to add sound to playlist'
toast.error(errorMessage) toast.error(errorMessage)
} }
return return
} }
// Handle reordering sounds within playlist (both table and simple view) // Handle reordering sounds within playlist (both table and simple view)
if ((activeId.startsWith('table-sound-') && overId.startsWith('table-sound-')) || if (
(activeId.startsWith('playlist-sound-') && overId.startsWith('playlist-sound-'))) { (activeId.startsWith('table-sound-') &&
overId.startsWith('table-sound-')) ||
(activeId.startsWith('playlist-sound-') &&
overId.startsWith('playlist-sound-'))
) {
const draggedSoundId = parseInt( const draggedSoundId = parseInt(
activeId.replace('table-sound-', '').replace('playlist-sound-', ''), 10 activeId.replace('table-sound-', '').replace('playlist-sound-', ''),
10,
) )
const targetSoundId = parseInt( const targetSoundId = parseInt(
overId.replace('table-sound-', '').replace('playlist-sound-', ''), 10 overId.replace('table-sound-', '').replace('playlist-sound-', ''),
10,
) )
const draggedIndex = sounds.findIndex(s => s.id === draggedSoundId) const draggedIndex = sounds.findIndex(s => s.id === draggedSoundId)
const targetIndex = sounds.findIndex(s => s.id === targetSoundId) const targetIndex = sounds.findIndex(s => s.id === targetSoundId)
if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) { if (
draggedIndex !== -1 &&
targetIndex !== -1 &&
draggedIndex !== targetIndex
) {
// Reorder sounds in playlist // Reorder sounds in playlist
const newSounds = [...sounds] const newSounds = [...sounds]
const [draggedSound] = newSounds.splice(draggedIndex, 1) const [draggedSound] = newSounds.splice(draggedIndex, 1)
newSounds.splice(targetIndex, 0, draggedSound) newSounds.splice(targetIndex, 0, draggedSound)
// Create sound positions for API // Create sound positions for API
const soundPositions: Array<[number, number]> = newSounds.map((sound, idx) => [sound.id, idx]) const soundPositions: Array<[number, number]> = newSounds.map(
(sound, idx) => [sound.id, idx],
)
try { try {
await playlistsService.reorderPlaylistSounds(playlistId, soundPositions) await playlistsService.reorderPlaylistSounds(
playlistId,
soundPositions,
)
setSounds(newSounds) setSounds(newSounds)
toast.success('Playlist reordered') toast.success('Playlist reordered')
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to reorder playlist' const errorMessage =
err instanceof Error ? err.message : 'Failed to reorder playlist'
toast.error(errorMessage) toast.error(errorMessage)
} }
} }
@@ -763,8 +833,8 @@ export function PlaylistEditPage() {
items: [ items: [
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
{ label: 'Playlists', href: '/playlists' }, { label: 'Playlists', href: '/playlists' },
{ label: 'Edit' } { label: 'Edit' },
] ],
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4 space-y-6"> <div className="flex-1 rounded-xl bg-muted/50 p-4 space-y-6">
@@ -785,14 +855,16 @@ export function PlaylistEditPage() {
items: [ items: [
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
{ label: 'Playlists', href: '/playlists' }, { label: 'Playlists', href: '/playlists' },
{ label: 'Edit' } { label: 'Edit' },
] ],
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" /> <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to load playlist</h3> <h3 className="text-lg font-semibold mb-2">
Failed to load playlist
</h3>
<p className="text-muted-foreground mb-4">{error}</p> <p className="text-muted-foreground mb-4">{error}</p>
<button <button
onClick={() => navigate('/playlists')} onClick={() => navigate('/playlists')}
@@ -819,8 +891,8 @@ export function PlaylistEditPage() {
items: [ items: [
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
{ label: 'Playlists', href: '/playlists' }, { label: 'Playlists', href: '/playlists' },
{ label: playlist.name } { label: playlist.name },
] ],
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -836,10 +908,7 @@ export function PlaylistEditPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!playlist.is_current && !isEditMode && ( {!playlist.is_current && !isEditMode && (
<Button <Button variant="outline" onClick={handleSetCurrent}>
variant="outline"
onClick={handleSetCurrent}
>
Set as Current Set as Current
</Button> </Button>
)} )}
@@ -866,7 +935,9 @@ export function PlaylistEditPage() {
<Input <Input
id="name" id="name"
value={formData.name} value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)} onChange={e =>
handleInputChange('name', e.target.value)
}
placeholder="Playlist name" placeholder="Playlist name"
/> />
</div> </div>
@@ -876,7 +947,9 @@ export function PlaylistEditPage() {
<Textarea <Textarea
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)} onChange={e =>
handleInputChange('description', e.target.value)
}
placeholder="Playlist description" placeholder="Playlist description"
className="min-h-[100px]" className="min-h-[100px]"
/> />
@@ -887,7 +960,9 @@ export function PlaylistEditPage() {
<Input <Input
id="genre" id="genre"
value={formData.genre} value={formData.genre}
onChange={(e) => handleInputChange('genre', e.target.value)} onChange={e =>
handleInputChange('genre', e.target.value)
}
placeholder="Electronic, Rock, Comedy, etc." placeholder="Electronic, Rock, Comedy, etc."
/> />
</div> </div>
@@ -896,26 +971,36 @@ export function PlaylistEditPage() {
<> <>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label className="text-sm font-medium text-muted-foreground">Name</Label> <Label className="text-sm font-medium text-muted-foreground">
Name
</Label>
<p className="text-lg font-semibold">{playlist.name}</p> <p className="text-lg font-semibold">{playlist.name}</p>
</div> </div>
{playlist.description && ( {playlist.description && (
<div> <div>
<Label className="text-sm font-medium text-muted-foreground">Description</Label> <Label className="text-sm font-medium text-muted-foreground">
Description
</Label>
<p className="text-sm">{playlist.description}</p> <p className="text-sm">{playlist.description}</p>
</div> </div>
)} )}
{playlist.genre && ( {playlist.genre && (
<div> <div>
<Label className="text-sm font-medium text-muted-foreground">Genre</Label> <Label className="text-sm font-medium text-muted-foreground">
<Badge variant="secondary" className="mt-1">{playlist.genre}</Badge> Genre
</Label>
<Badge variant="secondary" className="mt-1">
{playlist.genre}
</Badge>
</div> </div>
)} )}
{!playlist.description && !playlist.genre && ( {!playlist.description && !playlist.genre && (
<p className="text-sm text-muted-foreground italic">No additional details provided</p> <p className="text-sm text-muted-foreground italic">
No additional details provided
</p>
)} )}
</div> </div>
</> </>
@@ -925,10 +1010,7 @@ export function PlaylistEditPage() {
<div className="pt-4 border-t"> <div className="pt-4 border-t">
{isEditMode ? ( {isEditMode ? (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button variant="outline" onClick={handleCancelEdit}>
variant="outline"
onClick={handleCancelEdit}
>
<X className="h-4 w-4 mr-2" /> <X className="h-4 w-4 mr-2" />
Cancel Cancel
</Button> </Button>
@@ -969,28 +1051,47 @@ export function PlaylistEditPage() {
</div> </div>
<div className="text-center p-3 bg-muted rounded-lg"> <div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatDuration(sounds.reduce((total, sound) => total + (sound.duration || 0), 0))} {formatDuration(
sounds.reduce(
(total, sound) => total + (sound.duration || 0),
0,
),
)}
</div>
<div className="text-sm text-muted-foreground">
Duration
</div> </div>
<div className="text-sm text-muted-foreground">Duration</div>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Created:</span> <span>Created:</span>
<span>{new Date(playlist.created_at).toLocaleDateString()}</span> <span>
{new Date(playlist.created_at).toLocaleDateString()}
</span>
</div> </div>
{playlist.updated_at && ( {playlist.updated_at && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Updated:</span> <span>Updated:</span>
<span>{new Date(playlist.updated_at).toLocaleDateString()}</span> <span>
{new Date(playlist.updated_at).toLocaleDateString()}
</span>
</div> </div>
)} )}
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Status:</span> <span>Status:</span>
<div className="flex gap-1"> <div className="flex gap-1">
{playlist.is_main && <Badge variant="outline" className="text-xs">Main</Badge>} {playlist.is_main && (
{playlist.is_current && <Badge variant="default" className="text-xs">Current</Badge>} <Badge variant="outline" className="text-xs">
Main
</Badge>
)}
{playlist.is_current && (
<Badge variant="default" className="text-xs">
Current
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1008,12 +1109,20 @@ export function PlaylistEditPage() {
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant={isAddMode ? "default" : "outline"} variant={isAddMode ? 'default' : 'outline'}
size="sm" size="sm"
onClick={toggleAddMode} onClick={toggleAddMode}
title={isAddMode ? "Exit add mode" : "Enter add mode to add EXT sounds"} title={
isAddMode
? 'Exit add mode'
: 'Enter add mode to add EXT sounds'
}
> >
{isAddMode ? <Minus className="h-4 w-4" /> : <Plus className="h-4 w-4" />} {isAddMode ? (
<Minus className="h-4 w-4" />
) : (
<Plus className="h-4 w-4" />
)}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -1021,7 +1130,9 @@ export function PlaylistEditPage() {
onClick={fetchSounds} onClick={fetchSounds}
disabled={soundsLoading} disabled={soundsLoading}
> >
<RefreshCw className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`} /> <RefreshCw
className={`h-4 w-4 ${soundsLoading ? 'animate-spin' : ''}`}
/>
</Button> </Button>
</div> </div>
</div> </div>
@@ -1038,7 +1149,9 @@ export function PlaylistEditPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Current Playlist Sounds - Simplified */} {/* Current Playlist Sounds - Simplified */}
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-sm text-muted-foreground mb-3">Current Playlist</h4> <h4 className="font-medium text-sm text-muted-foreground mb-3">
Current Playlist
</h4>
<SortableContext <SortableContext
items={sounds.map(sound => `playlist-sound-${sound.id}`)} items={sounds.map(sound => `playlist-sound-${sound.id}`)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
@@ -1048,11 +1161,18 @@ export function PlaylistEditPage() {
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No sounds in playlist</p> <p className="text-sm">No sounds in playlist</p>
<p className="text-xs mt-1">Drag sounds here or use + button</p> <p className="text-xs mt-1">
Drag sounds here or use + button
</p>
{/* Show inline preview at position 0 if dragging */} {/* Show inline preview at position 0 if dragging */}
{dropPosition === 0 && draggedSound && draggedItem?.startsWith('available-sound-') && ( {dropPosition === 0 &&
draggedSound &&
draggedItem?.startsWith('available-sound-') && (
<div className="mt-4"> <div className="mt-4">
<InlinePreview sound={draggedSound} position={0} /> <InlinePreview
sound={draggedSound}
position={0}
/>
</div> </div>
)} )}
{/* Invisible drop area */} {/* Invisible drop area */}
@@ -1061,12 +1181,22 @@ export function PlaylistEditPage() {
) : ( ) : (
<> <>
{sounds.map((sound, index) => { {sounds.map((sound, index) => {
const adjustedIndex = dropPosition !== null && dropPosition <= index ? index + 1 : index const adjustedIndex =
dropPosition !== null && dropPosition <= index
? index + 1
: index
return ( return (
<div key={sound.id}> <div key={sound.id}>
{/* Show inline preview if this is the drop position */} {/* Show inline preview if this is the drop position */}
{dropPosition === index && draggedSound && draggedItem?.startsWith('available-sound-') && ( {dropPosition === index &&
<InlinePreview sound={draggedSound} position={dropPosition} /> draggedSound &&
draggedItem?.startsWith(
'available-sound-',
) && (
<InlinePreview
sound={draggedSound}
position={dropPosition}
/>
)} )}
<SimpleSortableRow <SimpleSortableRow
sound={sound} sound={sound}
@@ -1077,8 +1207,13 @@ export function PlaylistEditPage() {
) )
})} })}
{/* Show inline preview at the end if that's the drop position */} {/* Show inline preview at the end if that's the drop position */}
{dropPosition === sounds.length && draggedSound && draggedItem?.startsWith('available-sound-') && ( {dropPosition === sounds.length &&
<InlinePreview sound={draggedSound} position={dropPosition} /> draggedSound &&
draggedItem?.startsWith('available-sound-') && (
<InlinePreview
sound={draggedSound}
position={dropPosition}
/>
)} )}
{/* Invisible drop area at the end */} {/* Invisible drop area at the end */}
<EndDropArea /> <EndDropArea />
@@ -1103,19 +1238,25 @@ export function PlaylistEditPage() {
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No EXT sounds available</p> <p className="text-sm">No EXT sounds available</p>
<p className="text-xs mt-1">All EXT sounds are already in this playlist</p> <p className="text-xs mt-1">
All EXT sounds are already in this playlist
</p>
</div> </div>
) : ( ) : (
<SortableContext <SortableContext
items={availableSounds.map(sound => `available-sound-${sound.id}`)} items={availableSounds.map(
sound => `available-sound-${sound.id}`,
)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-2 max-h-96 overflow-y-auto"> <div className="space-y-2 max-h-96 overflow-y-auto">
{availableSounds.map((sound) => ( {availableSounds.map(sound => (
<AvailableSound <AvailableSound
key={sound.id} key={sound.id}
sound={sound} sound={sound}
onAddToPlaylist={(soundId) => handleAddSoundToPlaylist(soundId)} onAddToPlaylist={soundId =>
handleAddSoundToPlaylist(soundId)
}
/> />
))} ))}
</div> </div>
@@ -1127,7 +1268,9 @@ export function PlaylistEditPage() {
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No sounds in this playlist</p> <p>No sounds in this playlist</p>
<p className="text-xs mt-1">Click the + button to enter add mode</p> <p className="text-xs mt-1">
Click the + button to enter add mode
</p>
</div> </div>
) : ( ) : (
// Normal Mode: Full table view (only show table if there are sounds) // Normal Mode: Full table view (only show table if there are sounds)
@@ -1184,7 +1327,11 @@ export function PlaylistEditPage() {
{draggedSound && ( {draggedSound && (
<DragOverlayContent <DragOverlayContent
sound={draggedSound} sound={draggedSound}
position={draggedItem?.startsWith('available-sound-') ? (dropPosition ?? sounds.length) : undefined} position={
draggedItem?.startsWith('available-sound-')
? (dropPosition ?? sounds.length)
: undefined
}
/> />
)} )}
</DragOverlay> </DragOverlay>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ function parseSize(bytes: number, binary: boolean = false): FileSize {
return { return {
value: Math.round(value * 100) / 100, // Round to 2 decimal places 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 * @param binary Whether to use binary (1024) or decimal (1000) units
* @returns Object with numeric value and unit string * @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) return parseSize(bytes, binary)
} }