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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { apiClient } from '../client'
import type { User } from '@/types/auth'
import { apiClient } from '../client'
export interface Plan {
id: number
@@ -56,7 +56,7 @@ export interface NormalizationResponse {
export class AdminService {
async listUsers(limit = 100, offset = 0): Promise<User[]> {
return apiClient.get<User[]>(`/api/v1/admin/users/`, {
params: { limit, offset }
params: { limit, offset },
})
}
@@ -69,11 +69,15 @@ export class AdminService {
}
async disableUser(userId: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/admin/users/${userId}/disable`)
return apiClient.post<MessageResponse>(
`/api/v1/admin/users/${userId}/disable`,
)
}
async enableUser(userId: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/admin/users/${userId}/enable`)
return apiClient.post<MessageResponse>(
`/api/v1/admin/users/${userId}/enable`,
)
}
async listPlans(): Promise<Plan[]> {
@@ -85,24 +89,35 @@ export class AdminService {
return apiClient.post<ScanResponse>(`/api/v1/admin/sounds/scan`)
}
async normalizeAllSounds(force = false, onePass?: boolean): Promise<NormalizationResponse> {
async normalizeAllSounds(
force = false,
onePass?: boolean,
): Promise<NormalizationResponse> {
const params = new URLSearchParams()
if (force) params.append('force', 'true')
if (onePass !== undefined) params.append('one_pass', onePass.toString())
const queryString = params.toString()
const url = queryString ? `/api/v1/admin/sounds/normalize/all?${queryString}` : `/api/v1/admin/sounds/normalize/all`
const url = queryString
? `/api/v1/admin/sounds/normalize/all?${queryString}`
: `/api/v1/admin/sounds/normalize/all`
return apiClient.post<NormalizationResponse>(url)
}
async normalizeSoundsByType(soundType: 'SDB' | 'TTS' | 'EXT', force = false, onePass?: boolean): Promise<NormalizationResponse> {
async normalizeSoundsByType(
soundType: 'SDB' | 'TTS' | 'EXT',
force = false,
onePass?: boolean,
): Promise<NormalizationResponse> {
const params = new URLSearchParams()
if (force) params.append('force', 'true')
if (onePass !== undefined) params.append('one_pass', onePass.toString())
const queryString = params.toString()
const url = queryString ? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}` : `/api/v1/admin/sounds/normalize/type/${soundType}`
const url = queryString
? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}`
: `/api/v1/admin/sounds/normalize/type/${soundType}`
return apiClient.post<NormalizationResponse>(url)
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
import { apiClient } from '../client'
export type PlayerStatus = 'playing' | 'paused' | 'stopped'
export type PlayerMode = 'continuous' | 'loop' | 'loop_one' | 'random' | 'single'
export type PlayerMode =
| 'continuous'
| 'loop'
| 'loop_one'
| 'random'
| 'single'
export interface PlayerSound {
id: number

View File

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

View File

@@ -21,7 +21,15 @@ export interface Sound {
updated_at: string
}
export type SoundSortField = 'name' | 'filename' | 'duration' | 'size' | 'type' | 'play_count' | 'created_at' | 'updated_at'
export type SoundSortField =
| 'name'
| 'filename'
| 'duration'
| 'size'
| 'type'
| 'play_count'
| 'created_at'
| 'updated_at'
export type SortOrder = 'asc' | 'desc'
export interface GetSoundsParams {
@@ -68,7 +76,9 @@ export class SoundsService {
searchParams.append('offset', params.offset.toString())
}
const url = searchParams.toString() ? `/api/v1/sounds/?${searchParams.toString()}` : '/api/v1/sounds/'
const url = searchParams.toString()
? `/api/v1/sounds/?${searchParams.toString()}`
: '/api/v1/sounds/'
const response = await apiClient.get<GetSoundsResponse>(url)
return response.sounds || []
}
@@ -76,14 +86,19 @@ export class SoundsService {
/**
* Get sounds of a specific type
*/
async getSoundsByType(type: string, params?: Omit<GetSoundsParams, 'types'>): Promise<Sound[]> {
async getSoundsByType(
type: string,
params?: Omit<GetSoundsParams, 'types'>,
): Promise<Sound[]> {
return this.getSounds({ ...params, types: [type] })
}
/**
* Get SDB type sounds
*/
async getSDBSounds(params?: Omit<GetSoundsParams, 'types'>): Promise<Sound[]> {
async getSDBSounds(
params?: Omit<GetSoundsParams, 'types'>,
): Promise<Sound[]> {
return this.getSoundsByType('SDB', params)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,41 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
import { toast } from 'sonner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
type ExtractionInfo,
extractionsService,
} from '@/lib/api/services/extractions'
import { formatDistanceToNow } from 'date-fns'
import {
AlertCircle,
Calendar,
CheckCircle,
Clock,
Download,
ExternalLink,
Loader2,
Plus,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
export function ExtractionsPage() {
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
@@ -63,13 +88,33 @@ export function ExtractionsPage() {
const getStatusBadge = (status: ExtractionInfo['status']) => {
switch (status) {
case 'pending':
return <Badge variant="secondary" className="gap-1"><Clock className="h-3 w-3" />Pending</Badge>
return (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
Pending
</Badge>
)
case 'processing':
return <Badge variant="outline" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" />Processing</Badge>
return (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Processing
</Badge>
)
case 'completed':
return <Badge variant="default" className="gap-1"><CheckCircle className="h-3 w-3" />Completed</Badge>
return (
<Badge variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
Completed
</Badge>
)
case 'failed':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Failed</Badge>
return (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Failed
</Badge>
)
}
}
@@ -78,14 +123,18 @@ export function ExtractionsPage() {
const serviceColors: Record<string, string> = {
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
soundcloud: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
soundcloud:
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
instagram:
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
}
const colorClass = serviceColors[service.toLowerCase()] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
const colorClass =
serviceColors[service.toLowerCase()] ||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
return (
<Badge variant="outline" className={colorClass}>
@@ -97,10 +146,7 @@ export function ExtractionsPage() {
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Extractions' }
]
items: [{ label: 'Dashboard', href: '/' }, { label: 'Extractions' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
@@ -130,22 +176,29 @@ export function ExtractionsPage() {
id="url"
placeholder="https://www.youtube.com/watch?v=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !isCreating) {
handleCreateExtraction()
}
}}
/>
<p className="text-sm text-muted-foreground mt-1">
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, Instagram, and more
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
Instagram, and more
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleCreateExtraction} disabled={isCreating}>
<Button
onClick={handleCreateExtraction}
disabled={isCreating}
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -171,9 +224,12 @@ export function ExtractionsPage() {
<CardContent className="py-8">
<div className="text-center">
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No extractions yet</h3>
<h3 className="text-lg font-semibold mb-2">
No extractions yet
</h3>
<p className="text-muted-foreground mb-4">
Start by adding a URL to extract audio from your favorite platforms
Start by adding a URL to extract audio from your favorite
platforms
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
@@ -199,7 +255,7 @@ export function ExtractionsPage() {
</TableRow>
</TableHeader>
<TableBody>
{extractions.map((extraction) => (
{extractions.map(extraction => (
<TableRow key={extraction.id}>
<TableCell>
<div>
@@ -217,7 +273,10 @@ export function ExtractionsPage() {
<TableCell>
{getStatusBadge(extraction.status)}
{extraction.error && (
<div className="text-xs text-destructive mt-1 max-w-48 truncate" title={extraction.error}>
<div
className="text-xs text-destructive mt-1 max-w-48 truncate"
title={extraction.error}
>
{extraction.error}
</div>
)}
@@ -231,7 +290,9 @@ export function ExtractionsPage() {
if (isNaN(date.getTime())) {
return 'Invalid date'
}
return formatDistanceToNow(date, { addSuffix: true })
return formatDistanceToNow(date, {
addSuffix: true,
})
} catch {
return 'Invalid date'
}
@@ -241,12 +302,21 @@ export function ExtractionsPage() {
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" asChild>
<a href={extraction.url} target="_blank" rel="noopener noreferrer">
<a
href={extraction.url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
{extraction.status === 'completed' && extraction.sound_id && (
<Button variant="ghost" size="sm" title="View in Sounds">
{extraction.status === 'completed' &&
extraction.sound_id && (
<Button
variant="ghost"
size="sm"
title="View in Sounds"
>
<Download className="h-4 w-4" />
</Button>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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