Refactor and enhance UI components across multiple pages
- Improved import organization and formatting in PlaylistsPage, RegisterPage, SoundsPage, SettingsPage, and UsersPage for better readability. - Added error handling and user feedback with toast notifications in SoundsPage and SettingsPage. - Enhanced user experience by implementing debounced search functionality in PlaylistsPage and SoundsPage. - Updated the layout and structure of forms in SettingsPage and UsersPage for better usability. - Improved accessibility and semantics by ensuring proper labeling and descriptions in forms. - Fixed minor bugs related to state management and API calls in various components.
This commit is contained in:
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
160
src/App.tsx
160
src/App.tsx
@@ -1,100 +1,138 @@
|
||||
import { Routes, Route, Navigate } from 'react-router'
|
||||
import { Navigate, Route, Routes } from 'react-router'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { SocketProvider } from './contexts/SocketContext'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { AccountPage } from './pages/AccountPage'
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { SoundsPage } from './pages/SoundsPage'
|
||||
import { PlaylistsPage } from './pages/PlaylistsPage'
|
||||
import { PlaylistEditPage } from './pages/PlaylistEditPage'
|
||||
import { ExtractionsPage } from './pages/ExtractionsPage'
|
||||
import { UsersPage } from './pages/admin/UsersPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { PlaylistEditPage } from './pages/PlaylistEditPage'
|
||||
import { PlaylistsPage } from './pages/PlaylistsPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { SoundsPage } from './pages/SoundsPage'
|
||||
import { SettingsPage } from './pages/admin/SettingsPage'
|
||||
import { AccountPage } from './pages/AccountPage'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { UsersPage } from './pages/admin/UsersPage'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={user ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={user ? <Navigate to="/" replace /> : <RegisterPage />}
|
||||
/>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/sounds" element={
|
||||
<ProtectedRoute>
|
||||
<SoundsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/playlists" element={
|
||||
<ProtectedRoute>
|
||||
<PlaylistsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/playlists/:id/edit" element={
|
||||
<ProtectedRoute>
|
||||
<PlaylistEditPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/extractions" element={
|
||||
<ProtectedRoute>
|
||||
<ExtractionsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/account" element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/users" element={
|
||||
<AdminRoute>
|
||||
<UsersPage />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route path="/admin/settings" element={
|
||||
<AdminRoute>
|
||||
<SettingsPage />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sounds"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SoundsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/playlists"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PlaylistsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/playlists/:id/edit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PlaylistEditPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/extractions"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ExtractionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<UsersPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<SettingsPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -10,6 +6,14 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useState } from 'react'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { Player, type PlayerDisplayMode } from './player/Player'
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -23,14 +27,21 @@ interface AppLayoutProps {
|
||||
}
|
||||
|
||||
export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
|
||||
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(
|
||||
() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(
|
||||
'playerDisplayMode',
|
||||
) as PlayerDisplayMode
|
||||
return saved &&
|
||||
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
||||
? saved
|
||||
: 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
},
|
||||
)
|
||||
|
||||
// Note: localStorage is managed by the Player component
|
||||
|
||||
@@ -66,13 +77,9 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||
</SidebarInset>
|
||||
<Player
|
||||
onPlayerModeChange={setPlayerDisplayMode}
|
||||
/>
|
||||
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Home,
|
||||
Music,
|
||||
Users,
|
||||
Settings,
|
||||
Download,
|
||||
PlayCircle
|
||||
} from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -13,13 +6,20 @@ import {
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
Download,
|
||||
Home,
|
||||
Music,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { CreditsNav } from './nav/CreditsNav'
|
||||
import { NavGroup } from './nav/NavGroup'
|
||||
import { NavItem } from './nav/NavItem'
|
||||
import { UserNav } from './nav/UserNav'
|
||||
import { CreditsNav } from './nav/CreditsNav'
|
||||
import { CompactPlayer } from './player/CompactPlayer'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface AppSidebarProps {
|
||||
showCompactPlayer?: boolean
|
||||
@@ -35,7 +35,9 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
<Music className="h-6 w-6" />
|
||||
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">SDB v2</span>
|
||||
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">
|
||||
SDB v2
|
||||
</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -47,7 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
||||
</NavGroup>
|
||||
|
||||
{user.role === "admin" && (
|
||||
{user.role === 'admin' && (
|
||||
<NavGroup label="Admin">
|
||||
<NavItem href="/admin/users" icon={Users} title="Users" />
|
||||
<NavItem href="/admin/settings" icon={Settings} title="Settings" />
|
||||
@@ -73,4 +75,4 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@ export function SocketBadge() {
|
||||
const { isConnected, isReconnecting } = useSocket()
|
||||
|
||||
if (isReconnecting) {
|
||||
return <Badge variant="secondary" className="text-xs">Reconnecting</Badge>
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Reconnecting
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={isConnected ? 'default' : 'destructive'} className="text-xs">
|
||||
<Badge
|
||||
variant={isConnected ? 'default' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useState } from 'react'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
|
||||
export function LoginForm() {
|
||||
const { login } = useAuth()
|
||||
@@ -44,7 +50,9 @@ export function LoginForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">Sign in</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Sign in
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your email and password to sign in to your account
|
||||
</CardDescription>
|
||||
@@ -63,7 +71,7 @@ export function LoginForm() {
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
@@ -83,11 +91,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -96,4 +100,4 @@ export function LoginForm() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { api } from '@/lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function OAuthButtons() {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
@@ -24,10 +24,10 @@ export function OAuthButtons() {
|
||||
setLoading(provider)
|
||||
try {
|
||||
const response = await api.auth.getOAuthUrl(provider)
|
||||
|
||||
|
||||
// Store state in sessionStorage for verification
|
||||
sessionStorage.setItem('oauth_state', response.state)
|
||||
|
||||
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = response.authorization_url
|
||||
} catch (error) {
|
||||
@@ -90,9 +90,9 @@ export function OAuthButtons() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{providers.map((provider) => (
|
||||
{providers.map(provider => (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
@@ -107,14 +107,13 @@ export function OAuthButtons() {
|
||||
getProviderIcon(provider)
|
||||
)}
|
||||
<span className="ml-2">
|
||||
{loading === provider
|
||||
? 'Connecting...'
|
||||
: `Continue with ${getProviderName(provider)}`
|
||||
}
|
||||
{loading === provider
|
||||
? 'Connecting...'
|
||||
: `Continue with ${getProviderName(provider)}`}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useState } from 'react'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { register } = useAuth()
|
||||
@@ -62,7 +68,9 @@ export function RegisterForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">Create account</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Create account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your information to create your account
|
||||
</CardDescription>
|
||||
@@ -94,7 +102,7 @@ export function RegisterForm() {
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
@@ -128,11 +136,7 @@ export function RegisterForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -141,4 +145,4 @@ export function RegisterForm() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CircleDollarSign } from 'lucide-react'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
|
||||
import { userEvents, USER_EVENTS } from '@/lib/events'
|
||||
import { USER_EVENTS, userEvents } from '@/lib/events'
|
||||
import type { User } from '@/types/auth'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { CircleDollarSign } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
|
||||
|
||||
interface CreditsNavProps {
|
||||
user: User
|
||||
@@ -30,7 +30,7 @@ export function CreditsNav({ user }: CreditsNavProps) {
|
||||
}, [user.credits])
|
||||
|
||||
const tooltipText = `Credits: ${credits} / ${user.plan.max_credits}`
|
||||
|
||||
|
||||
// Determine icon color based on credit levels
|
||||
const getIconColor = () => {
|
||||
if (credits === 0) return 'text-red-500'
|
||||
@@ -41,16 +41,21 @@ export function CreditsNav({ user }: CreditsNavProps) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" className="cursor-default group-data-[collapsible=icon]:justify-center" tooltip={tooltipText}>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="cursor-default group-data-[collapsible=icon]:justify-center"
|
||||
tooltip={tooltipText}
|
||||
>
|
||||
<CircleDollarSign className={`h-5 w-5 ${getIconColor()}`} />
|
||||
<div className="flex flex-1 items-center justify-between text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold">Credits:</span>
|
||||
<span className="text-muted-foreground">
|
||||
<NumberFlow value={credits} /> / <NumberFlow value={user.plan.max_credits} />
|
||||
<NumberFlow value={credits} /> /{' '}
|
||||
<NumberFlow value={user.plan.max_credits} />
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,8 @@ export function NavGroup({ label, children }: NavGroupProps) {
|
||||
<SidebarGroup>
|
||||
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{children}
|
||||
</SidebarMenu>
|
||||
<SidebarMenu>{children}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
@@ -31,4 +28,4 @@ export function NavItem({ href, icon: Icon, title, badge }: NavItemProps) {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '../ui/sidebar'
|
||||
import type { User } from '@/types/auth'
|
||||
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '../ui/sidebar'
|
||||
|
||||
interface UserNavProps {
|
||||
user: User
|
||||
@@ -78,4 +83,4 @@ export function UserNav({ user, logout }: UserNavProps) {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Music,
|
||||
Maximize2
|
||||
} from 'lucide-react'
|
||||
import { playerService, type PlayerState, type MessageResponse } from '@/lib/api/services/player'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerState,
|
||||
playerService,
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
Maximize2,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface CompactPlayerProps {
|
||||
className?: string
|
||||
@@ -28,7 +32,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
mode: 'continuous',
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0
|
||||
position: 0,
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -58,18 +62,23 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
const executeAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<void | MessageResponse>,
|
||||
actionName: string,
|
||||
) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state.status === 'playing') {
|
||||
@@ -103,7 +112,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Collapsed state - only play/pause button */}
|
||||
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
|
||||
<Button
|
||||
@@ -128,11 +137,11 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
@@ -141,11 +150,11 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
<Music
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground",
|
||||
state.current_sound?.thumbnail ? "hidden" : "block"
|
||||
)}
|
||||
'h-4 w-4 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -160,7 +169,9 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const expandFn = (window as unknown as { __expandPlayerFromSidebar?: () => void }).__expandPlayerFromSidebar
|
||||
const expandFn = (
|
||||
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
).__expandPlayerFromSidebar
|
||||
if (expandFn) expandFn()
|
||||
}}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
@@ -194,7 +205,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -238,4 +249,4 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,47 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerMode,
|
||||
type PlayerState,
|
||||
playerService,
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowRightToLine,
|
||||
Download,
|
||||
ExternalLink,
|
||||
List,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreVertical,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
List,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Music,
|
||||
ExternalLink,
|
||||
Download,
|
||||
MoreVertical,
|
||||
ArrowRight,
|
||||
ArrowRightToLine
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { playerService, type PlayerState, type PlayerMode, type MessageResponse } from '@/lib/api/services/player'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Playlist } from './Playlist'
|
||||
|
||||
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
|
||||
@@ -47,17 +57,22 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
mode: 'continuous',
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0
|
||||
position: 0,
|
||||
})
|
||||
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode
|
||||
return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal'
|
||||
const saved = localStorage.getItem(
|
||||
'playerDisplayMode',
|
||||
) as PlayerDisplayMode
|
||||
return saved &&
|
||||
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
||||
? saved
|
||||
: 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
|
||||
// Notify parent when display mode changes and save to localStorage
|
||||
useEffect(() => {
|
||||
onPlayerModeChange?.(displayMode)
|
||||
@@ -111,17 +126,23 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}
|
||||
}, [displayMode])
|
||||
|
||||
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
const executeAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<void | MessageResponse>,
|
||||
actionName: string,
|
||||
) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state.status === 'playing') {
|
||||
@@ -143,15 +164,21 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
executeAction(playerService.next, 'go to next track')
|
||||
}, [executeAction])
|
||||
|
||||
const handleSeek = useCallback((position: number[]) => {
|
||||
const newPosition = position[0]
|
||||
executeAction(() => playerService.seek(newPosition), 'seek')
|
||||
}, [executeAction])
|
||||
const handleSeek = useCallback(
|
||||
(position: number[]) => {
|
||||
const newPosition = position[0]
|
||||
executeAction(() => playerService.seek(newPosition), 'seek')
|
||||
},
|
||||
[executeAction],
|
||||
)
|
||||
|
||||
const handleVolumeChange = useCallback((volume: number[]) => {
|
||||
const newVolume = volume[0]
|
||||
executeAction(() => playerService.setVolume(newVolume), 'change volume')
|
||||
}, [executeAction])
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number[]) => {
|
||||
const newVolume = volume[0]
|
||||
executeAction(() => playerService.setVolume(newVolume), 'change volume')
|
||||
},
|
||||
[executeAction],
|
||||
)
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (state.volume === 0) {
|
||||
@@ -164,7 +191,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}, [state.volume, executeAction])
|
||||
|
||||
const handleModeChange = useCallback(() => {
|
||||
const modes: PlayerMode[] = ['continuous', 'loop', 'loop_one', 'random', 'single']
|
||||
const modes: PlayerMode[] = [
|
||||
'continuous',
|
||||
'loop',
|
||||
'loop_one',
|
||||
'random',
|
||||
'single',
|
||||
]
|
||||
const currentIndex = modes.indexOf(state.mode)
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length]
|
||||
executeAction(() => playerService.setMode(nextMode), 'change mode')
|
||||
@@ -172,7 +205,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
|
||||
const handleDownloadSound = useCallback(async () => {
|
||||
if (!state.current_sound) return
|
||||
|
||||
|
||||
try {
|
||||
await filesService.downloadSound(state.current_sound.id)
|
||||
toast.success('Download started')
|
||||
@@ -185,7 +218,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
const getModeIcon = () => {
|
||||
switch (state.mode) {
|
||||
case 'continuous':
|
||||
return <ArrowRight className='h-4 w-4' />
|
||||
return <ArrowRight className="h-4 w-4" />
|
||||
case 'loop':
|
||||
return <Repeat className="h-4 w-4" />
|
||||
case 'loop_one':
|
||||
@@ -300,11 +333,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<div className="mb-4">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
@@ -312,11 +345,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
/>
|
||||
<Music
|
||||
<Music
|
||||
className={cn(
|
||||
"h-8 w-8 text-muted-foreground",
|
||||
state.current_sound?.thumbnail ? "hidden" : "block"
|
||||
)}
|
||||
'h-8 w-8 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -328,38 +361,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<h3 className="font-medium text-sm truncate">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</h3>
|
||||
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
{state.current_sound &&
|
||||
(state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,7 +401,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
@@ -474,10 +507,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Playlist */}
|
||||
{showPlaylist && state.playlist && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<Playlist
|
||||
<Playlist
|
||||
playlist={state.playlist}
|
||||
currentIndex={state.index}
|
||||
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
|
||||
onTrackSelect={index =>
|
||||
executeAction(
|
||||
() => playerService.playAtIndex(index),
|
||||
'play track',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,11 +544,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Large Album Art */}
|
||||
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
@@ -519,11 +557,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
<Music
|
||||
className={cn(
|
||||
"h-32 w-32 text-muted-foreground",
|
||||
state.current_sound?.thumbnail ? "hidden" : "block"
|
||||
)}
|
||||
'h-32 w-32 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -533,38 +571,38 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<h1 className="text-2xl font-bold">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</h1>
|
||||
{state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
{state.current_sound &&
|
||||
(state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleDownloadSound} className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -573,11 +611,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-3 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
const newPosition = Math.round(percentage * (state.duration || 0))
|
||||
const newPosition = Math.round(
|
||||
percentage * (state.duration || 0),
|
||||
)
|
||||
handleSeek([newPosition])
|
||||
}}
|
||||
/>
|
||||
@@ -630,24 +670,14 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleModeChange}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={handleModeChange}>
|
||||
{getModeIcon()}
|
||||
</Button>
|
||||
<Badge variant="secondary">
|
||||
{state.mode.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleMute}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={handleMute}>
|
||||
{state.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
@@ -680,10 +710,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Playlist
|
||||
<Playlist
|
||||
playlist={state.playlist}
|
||||
currentIndex={state.index}
|
||||
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
|
||||
onTrackSelect={index =>
|
||||
executeAction(
|
||||
() => playerService.playAtIndex(index),
|
||||
'play track',
|
||||
)
|
||||
}
|
||||
variant="maximized"
|
||||
/>
|
||||
</div>
|
||||
@@ -696,7 +731,9 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
// Expose expand function for external use
|
||||
useEffect(() => {
|
||||
// Store expand function globally so sidebar can access it
|
||||
const windowWithExpand = window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
const windowWithExpand = window as unknown as {
|
||||
__expandPlayerFromSidebar?: () => void
|
||||
}
|
||||
windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar
|
||||
return () => {
|
||||
delete windowWithExpand.__expandPlayerFromSidebar
|
||||
@@ -712,4 +749,4 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{displayMode === 'maximized' && renderMaximizedPlayer()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Music, Play } from 'lucide-react'
|
||||
import { type PlayerPlaylist } from '@/lib/api/services/player'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { type PlayerPlaylist } from '@/lib/api/services/player'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Music, Play } from 'lucide-react'
|
||||
|
||||
interface PlaylistProps {
|
||||
playlist: PlayerPlaylist
|
||||
@@ -13,33 +13,33 @@ interface PlaylistProps {
|
||||
variant?: 'normal' | 'maximized'
|
||||
}
|
||||
|
||||
export function Playlist({
|
||||
playlist,
|
||||
currentIndex,
|
||||
export function Playlist({
|
||||
playlist,
|
||||
currentIndex,
|
||||
onTrackSelect,
|
||||
variant = 'normal'
|
||||
variant = 'normal',
|
||||
}: PlaylistProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-sm truncate">
|
||||
{playlist.name}
|
||||
</h4>
|
||||
<h4 className="font-medium text-sm truncate">{playlist.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{playlist.sounds.length} tracks
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Track List */}
|
||||
<ScrollArea className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}>
|
||||
<ScrollArea
|
||||
className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}
|
||||
>
|
||||
<div className="w-full">
|
||||
{playlist.sounds.map((sound, index) => (
|
||||
<div
|
||||
key={sound.id}
|
||||
className={cn(
|
||||
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
|
||||
currentIndex === index && 'bg-primary/10 text-primary'
|
||||
currentIndex === index && 'bg-primary/10 text-primary',
|
||||
)}
|
||||
onClick={() => onTrackSelect(index)}
|
||||
>
|
||||
@@ -51,16 +51,18 @@ export function Playlist({
|
||||
<span className="text-muted-foreground">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Thumbnail - 1 column */}
|
||||
<div className="col-span-1">
|
||||
<div className={cn(
|
||||
'bg-muted rounded flex items-center justify-center overflow-hidden',
|
||||
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded flex items-center justify-center overflow-hidden',
|
||||
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5',
|
||||
)}
|
||||
>
|
||||
{sound.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(sound.id)}
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(sound.id)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -69,18 +71,20 @@ export function Playlist({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Track name - 6 columns (takes most space) */}
|
||||
<div className="col-span-6">
|
||||
<span className={cn(
|
||||
'font-medium truncate block',
|
||||
variant === 'maximized' ? 'text-sm' : 'text-xs',
|
||||
currentIndex === index ? 'text-primary' : 'text-foreground'
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium truncate block',
|
||||
variant === 'maximized' ? 'text-sm' : 'text-xs',
|
||||
currentIndex === index ? 'text-primary' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{sound.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Duration - 2 columns */}
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
@@ -101,4 +105,4 @@ export function Playlist({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Play, Clock, Weight } from 'lucide-react'
|
||||
import { type Sound } from '@/lib/api/services/sounds'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { formatSize } from '@/utils/format-size'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Clock, Play, Weight } from 'lucide-react'
|
||||
|
||||
interface SoundCardProps {
|
||||
sound: Sound
|
||||
@@ -44,4 +44,4 @@ export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth'
|
||||
import { authEvents, AUTH_EVENTS } from '@/lib/events'
|
||||
import { AUTH_EVENTS, authEvents } from '@/lib/events'
|
||||
import { tokenRefreshManager } from '@/lib/token-refresh-manager'
|
||||
import type {
|
||||
AuthContextType,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
User,
|
||||
} from '@/types/auth'
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
@@ -76,4 +87,4 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Socket, io } from 'socket.io-client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AUTH_EVENTS,
|
||||
PLAYER_EVENTS,
|
||||
SOUND_EVENTS,
|
||||
USER_EVENTS,
|
||||
authEvents,
|
||||
playerEvents,
|
||||
soundEvents,
|
||||
userEvents,
|
||||
} from '../lib/events'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { authEvents, AUTH_EVENTS, soundEvents, SOUND_EVENTS, userEvents, USER_EVENTS, playerEvents, PLAYER_EVENTS } from '../lib/events'
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null
|
||||
@@ -28,9 +43,9 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
if (!user) return null
|
||||
|
||||
// Get socket URL - use relative URL in production with reverse proxy
|
||||
const socketUrl = import.meta.env.PROD
|
||||
const socketUrl = import.meta.env.PROD
|
||||
? '' // Use relative URL in production (same origin as frontend)
|
||||
: (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000')
|
||||
: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
|
||||
const newSocket = io(socketUrl, {
|
||||
withCredentials: true,
|
||||
@@ -50,37 +65,37 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
setIsConnected(false)
|
||||
})
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
newSocket.on('connect_error', error => {
|
||||
setConnectionError(`Connection failed: ${error.message}`)
|
||||
setIsConnected(false)
|
||||
setIsReconnecting(false)
|
||||
})
|
||||
|
||||
// Listen for message events
|
||||
newSocket.on('user_message', (data) => {
|
||||
newSocket.on('user_message', data => {
|
||||
toast.info(`Message from ${data.from_user_name}`, {
|
||||
description: data.message,
|
||||
})
|
||||
})
|
||||
|
||||
newSocket.on('broadcast_message', (data) => {
|
||||
newSocket.on('broadcast_message', data => {
|
||||
toast.warning(`Broadcast from ${data.from_user_name}`, {
|
||||
description: data.message,
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for player events and emit them locally
|
||||
newSocket.on('player_state', (data) => {
|
||||
newSocket.on('player_state', data => {
|
||||
playerEvents.emit(PLAYER_EVENTS.PLAYER_STATE, data)
|
||||
})
|
||||
|
||||
// Listen for sound events and emit them locally
|
||||
newSocket.on('sound_played', (data) => {
|
||||
newSocket.on('sound_played', data => {
|
||||
soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
|
||||
})
|
||||
|
||||
// Listen for user events and emit them locally
|
||||
newSocket.on('user_credits_changed', (data) => {
|
||||
newSocket.on('user_credits_changed', data => {
|
||||
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
|
||||
})
|
||||
|
||||
@@ -92,10 +107,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
if (!user || !socket) return
|
||||
|
||||
setIsReconnecting(true)
|
||||
|
||||
|
||||
// Disconnect current socket
|
||||
socket.disconnect()
|
||||
|
||||
|
||||
// Create new socket with fresh token
|
||||
const newSocket = createSocket()
|
||||
if (newSocket) {
|
||||
@@ -106,13 +121,12 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
// Listen for token refresh events
|
||||
useEffect(() => {
|
||||
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
||||
|
||||
|
||||
return () => {
|
||||
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
||||
}
|
||||
}, [handleTokenRefresh])
|
||||
|
||||
|
||||
// Initial socket connection
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
@@ -146,9 +160,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,4 +170,4 @@ export function useSocket() {
|
||||
throw new Error('useSocket must be used within a SocketProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ const initialState: ThemeProviderState = {
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
export type { Theme, ThemeProviderState }
|
||||
export const ThemeProviderContext =
|
||||
createContext<ThemeProviderState>(initialState)
|
||||
export type { Theme, ThemeProviderState }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeProviderContext } from '@/contexts/ThemeContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
@@ -8,4 +8,4 @@ export const useTheme = () => {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -117,4 +117,4 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
export * from './api/index'
|
||||
|
||||
// Export the main API object as default
|
||||
export { default as api } from './api/index'
|
||||
export { default as api } from './api/index'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AUTH_EVENTS, authEvents } from '../events'
|
||||
import { API_CONFIG } from './config'
|
||||
import { createApiError, NetworkError, TimeoutError } from './errors'
|
||||
import { NetworkError, TimeoutError, createApiError } from './errors'
|
||||
import type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
|
||||
import { authEvents, AUTH_EVENTS } from '../events'
|
||||
|
||||
export class BaseApiClient implements ApiClient {
|
||||
private refreshPromise: Promise<void> | null = null
|
||||
@@ -11,9 +11,12 @@ export class BaseApiClient implements ApiClient {
|
||||
this.baseURL = baseURL
|
||||
}
|
||||
|
||||
private buildURL(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
private buildURL(
|
||||
endpoint: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
): string {
|
||||
let url: URL
|
||||
|
||||
|
||||
if (this.baseURL) {
|
||||
// Full base URL provided
|
||||
url = new URL(endpoint, this.baseURL)
|
||||
@@ -21,7 +24,7 @@ export class BaseApiClient implements ApiClient {
|
||||
// Use relative URL (for reverse proxy)
|
||||
url = new URL(endpoint, window.location.origin)
|
||||
}
|
||||
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
@@ -29,7 +32,7 @@ export class BaseApiClient implements ApiClient {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return this.baseURL ? url.toString() : url.pathname + url.search
|
||||
}
|
||||
|
||||
@@ -37,7 +40,7 @@ export class BaseApiClient implements ApiClient {
|
||||
method: HttpMethod,
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config: ApiRequestConfig = {}
|
||||
config: ApiRequestConfig = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
params,
|
||||
@@ -84,40 +87,43 @@ export class BaseApiClient implements ApiClient {
|
||||
await this.handleTokenRefresh()
|
||||
// Retry the original request
|
||||
const retryResponse = await fetch(url, requestConfig)
|
||||
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
const errorData = await this.safeParseJSON(retryResponse)
|
||||
throw createApiError(retryResponse, errorData)
|
||||
}
|
||||
|
||||
return await this.safeParseJSON(retryResponse) as T
|
||||
|
||||
return (await this.safeParseJSON(retryResponse)) as T
|
||||
} catch (refreshError) {
|
||||
this.handleAuthenticationFailure()
|
||||
throw refreshError
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const errorData = await this.safeParseJSON(response)
|
||||
throw createApiError(response, errorData)
|
||||
}
|
||||
|
||||
// Handle empty responses (204 No Content, etc.)
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
if (
|
||||
response.status === 204 ||
|
||||
response.headers.get('content-length') === '0'
|
||||
) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return await this.safeParseJSON(response) as T
|
||||
return (await this.safeParseJSON(response)) as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
throw new TimeoutError()
|
||||
}
|
||||
|
||||
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -138,7 +144,7 @@ export class BaseApiClient implements ApiClient {
|
||||
}
|
||||
|
||||
this.refreshPromise = this.performTokenRefresh()
|
||||
|
||||
|
||||
try {
|
||||
await this.refreshPromise
|
||||
} finally {
|
||||
@@ -147,11 +153,14 @@ export class BaseApiClient implements ApiClient {
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<void> {
|
||||
const response = await fetch(`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(
|
||||
`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw createApiError(response, await this.safeParseJSON(response))
|
||||
@@ -165,7 +174,7 @@ export class BaseApiClient implements ApiClient {
|
||||
// Only redirect if we're not already on auth pages to prevent infinite loops
|
||||
const currentPath = window.location.pathname
|
||||
const authPaths = ['/login', '/register', '/auth/callback']
|
||||
|
||||
|
||||
if (!authPaths.includes(currentPath)) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
@@ -176,15 +185,27 @@ export class BaseApiClient implements ApiClient {
|
||||
return this.request<T>('GET', endpoint, undefined, config)
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
||||
async post<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config?: ApiRequestConfig,
|
||||
): Promise<T> {
|
||||
return this.request<T>('POST', endpoint, data, config)
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
||||
async put<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config?: ApiRequestConfig,
|
||||
): Promise<T> {
|
||||
return this.request<T>('PUT', endpoint, data, config)
|
||||
}
|
||||
|
||||
async patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
||||
async patch<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
config?: ApiRequestConfig,
|
||||
): Promise<T> {
|
||||
return this.request<T>('PATCH', endpoint, data, config)
|
||||
}
|
||||
|
||||
@@ -203,4 +224,4 @@ export class BaseApiClient implements ApiClient {
|
||||
}
|
||||
|
||||
// Default API client instance
|
||||
export const apiClient = new BaseApiClient()
|
||||
export const apiClient = new BaseApiClient()
|
||||
|
||||
@@ -16,7 +16,7 @@ export const API_CONFIG = {
|
||||
BASE_URL: getApiBaseUrl(),
|
||||
TIMEOUT: 30000, // 30 seconds
|
||||
RETRY_ATTEMPTS: 1,
|
||||
|
||||
|
||||
// API Endpoints
|
||||
ENDPOINTS: {
|
||||
AUTH: {
|
||||
@@ -26,7 +26,8 @@ export const API_CONFIG = {
|
||||
REFRESH: '/api/v1/auth/refresh',
|
||||
ME: '/api/v1/auth/me',
|
||||
PROVIDERS: '/api/v1/auth/providers',
|
||||
OAUTH_AUTHORIZE: (provider: string) => `/api/v1/auth/${provider}/authorize`,
|
||||
OAUTH_AUTHORIZE: (provider: string) =>
|
||||
`/api/v1/auth/${provider}/authorize`,
|
||||
OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`,
|
||||
EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token',
|
||||
API_TOKEN: '/api/v1/auth/api-token',
|
||||
@@ -35,4 +36,4 @@ export const API_CONFIG = {
|
||||
},
|
||||
} as const
|
||||
|
||||
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
|
||||
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
|
||||
|
||||
@@ -4,7 +4,12 @@ export class ApiError extends Error {
|
||||
public response?: unknown
|
||||
public detail?: string
|
||||
|
||||
constructor(message: string, status: number, response?: unknown, detail?: string) {
|
||||
constructor(
|
||||
message: string,
|
||||
status: number,
|
||||
response?: unknown,
|
||||
detail?: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
@@ -14,8 +19,16 @@ export class ApiError extends Error {
|
||||
|
||||
static fromResponse(response: Response, data?: unknown): ApiError {
|
||||
const errorData = data as Record<string, unknown>
|
||||
const message = errorData?.detail as string || errorData?.message as string || `HTTP ${response.status}: ${response.statusText}`
|
||||
return new ApiError(message, response.status, data, errorData?.detail as string)
|
||||
const message =
|
||||
(errorData?.detail as string) ||
|
||||
(errorData?.message as string) ||
|
||||
`HTTP ${response.status}: ${response.statusText}`
|
||||
return new ApiError(
|
||||
message,
|
||||
response.status,
|
||||
data,
|
||||
errorData?.detail as string,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +88,10 @@ export class ServerError extends ApiError {
|
||||
export function createApiError(response: Response, data?: unknown): ApiError {
|
||||
const status = response.status
|
||||
const errorData = data as Record<string, unknown>
|
||||
const message = errorData?.detail as string || errorData?.message as string || `HTTP ${status}: ${response.statusText}`
|
||||
const message =
|
||||
(errorData?.detail as string) ||
|
||||
(errorData?.message as string) ||
|
||||
`HTTP ${status}: ${response.statusText}`
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
@@ -85,7 +101,10 @@ export function createApiError(response: Response, data?: unknown): ApiError {
|
||||
case 404:
|
||||
return new NotFoundError(message)
|
||||
case 422:
|
||||
return new ValidationError(message, errorData?.fields as Record<string, string[]>)
|
||||
return new ValidationError(
|
||||
message,
|
||||
errorData?.fields as Record<string, string[]>,
|
||||
)
|
||||
case 500:
|
||||
case 501:
|
||||
case 502:
|
||||
@@ -95,4 +114,4 @@ export function createApiError(response: Response, data?: unknown): ApiError {
|
||||
default:
|
||||
return new ApiError(message, status, data, errorData?.detail as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Main API object for convenient access
|
||||
import { apiClient } from './client'
|
||||
import { authService } from './services/auth'
|
||||
|
||||
// Re-export all API services and utilities
|
||||
export * from './client'
|
||||
export * from './config'
|
||||
@@ -7,14 +11,10 @@ export * from './errors'
|
||||
// Services
|
||||
export * from './services/auth'
|
||||
|
||||
// Main API object for convenient access
|
||||
import { authService } from './services/auth'
|
||||
import { apiClient } from './client'
|
||||
|
||||
export const api = {
|
||||
auth: authService,
|
||||
client: apiClient,
|
||||
} as const
|
||||
|
||||
// Default export for convenience
|
||||
export default api
|
||||
export default api
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from '../client'
|
||||
import type { User } from '@/types/auth'
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface Plan {
|
||||
id: number
|
||||
@@ -56,7 +56,7 @@ export interface NormalizationResponse {
|
||||
export class AdminService {
|
||||
async listUsers(limit = 100, offset = 0): Promise<User[]> {
|
||||
return apiClient.get<User[]>(`/api/v1/admin/users/`, {
|
||||
params: { limit, offset }
|
||||
params: { limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,11 +69,15 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async disableUser(userId: number): Promise<MessageResponse> {
|
||||
return apiClient.post<MessageResponse>(`/api/v1/admin/users/${userId}/disable`)
|
||||
return apiClient.post<MessageResponse>(
|
||||
`/api/v1/admin/users/${userId}/disable`,
|
||||
)
|
||||
}
|
||||
|
||||
async enableUser(userId: number): Promise<MessageResponse> {
|
||||
return apiClient.post<MessageResponse>(`/api/v1/admin/users/${userId}/enable`)
|
||||
return apiClient.post<MessageResponse>(
|
||||
`/api/v1/admin/users/${userId}/enable`,
|
||||
)
|
||||
}
|
||||
|
||||
async listPlans(): Promise<Plan[]> {
|
||||
@@ -85,27 +89,38 @@ export class AdminService {
|
||||
return apiClient.post<ScanResponse>(`/api/v1/admin/sounds/scan`)
|
||||
}
|
||||
|
||||
async normalizeAllSounds(force = false, onePass?: boolean): Promise<NormalizationResponse> {
|
||||
async normalizeAllSounds(
|
||||
force = false,
|
||||
onePass?: boolean,
|
||||
): Promise<NormalizationResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (force) params.append('force', 'true')
|
||||
if (onePass !== undefined) params.append('one_pass', onePass.toString())
|
||||
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = queryString ? `/api/v1/admin/sounds/normalize/all?${queryString}` : `/api/v1/admin/sounds/normalize/all`
|
||||
|
||||
const url = queryString
|
||||
? `/api/v1/admin/sounds/normalize/all?${queryString}`
|
||||
: `/api/v1/admin/sounds/normalize/all`
|
||||
|
||||
return apiClient.post<NormalizationResponse>(url)
|
||||
}
|
||||
|
||||
async normalizeSoundsByType(soundType: 'SDB' | 'TTS' | 'EXT', force = false, onePass?: boolean): Promise<NormalizationResponse> {
|
||||
async normalizeSoundsByType(
|
||||
soundType: 'SDB' | 'TTS' | 'EXT',
|
||||
force = false,
|
||||
onePass?: boolean,
|
||||
): Promise<NormalizationResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (force) params.append('force', 'true')
|
||||
if (onePass !== undefined) params.append('one_pass', onePass.toString())
|
||||
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = queryString ? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}` : `/api/v1/admin/sounds/normalize/type/${soundType}`
|
||||
|
||||
const url = queryString
|
||||
? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}`
|
||||
: `/api/v1/admin/sounds/normalize/type/${soundType}`
|
||||
|
||||
return apiClient.post<NormalizationResponse>(url)
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService()
|
||||
export const adminService = new AdminService()
|
||||
|
||||
@@ -72,12 +72,15 @@ export class AuthService {
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<User> {
|
||||
// Using direct fetch for auth endpoints to avoid circular dependency with token refresh
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
@@ -91,12 +94,15 @@ export class AuthService {
|
||||
* Register a new user account
|
||||
*/
|
||||
async register(userData: RegisterRequest): Promise<User> {
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userData),
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userData),
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
@@ -133,7 +139,7 @@ export class AuthService {
|
||||
async getOAuthUrl(provider: string): Promise<OAuthAuthorizationResponse> {
|
||||
return apiClient.get<OAuthAuthorizationResponse>(
|
||||
API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider),
|
||||
{ skipAuth: true }
|
||||
{ skipAuth: true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,21 +149,26 @@ export class AuthService {
|
||||
async getOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
return apiClient.get<OAuthProvidersResponse>(
|
||||
API_CONFIG.ENDPOINTS.AUTH.PROVIDERS,
|
||||
{ skipAuth: true }
|
||||
{ skipAuth: true },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth temporary code for auth cookies
|
||||
*/
|
||||
async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise<ExchangeOAuthTokenResponse> {
|
||||
async exchangeOAuthToken(
|
||||
request: ExchangeOAuthTokenRequest,
|
||||
): Promise<ExchangeOAuthTokenResponse> {
|
||||
// Use direct fetch for OAuth token exchange to avoid auth loops and ensure cookies are set
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
credentials: 'include', // Essential for receiving auth cookies
|
||||
})
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
credentials: 'include', // Essential for receiving auth cookies
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
@@ -171,10 +182,13 @@ export class AuthService {
|
||||
* Refresh authentication token
|
||||
*/
|
||||
async refreshToken(): Promise<void> {
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Token refresh failed')
|
||||
@@ -198,15 +212,22 @@ export class AuthService {
|
||||
/**
|
||||
* Generate a new API token
|
||||
*/
|
||||
async generateApiToken(request: ApiTokenRequest = {}): Promise<ApiTokenResponse> {
|
||||
return apiClient.post<ApiTokenResponse>(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN, request)
|
||||
async generateApiToken(
|
||||
request: ApiTokenRequest = {},
|
||||
): Promise<ApiTokenResponse> {
|
||||
return apiClient.post<ApiTokenResponse>(
|
||||
API_CONFIG.ENDPOINTS.AUTH.API_TOKEN,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API token status
|
||||
*/
|
||||
async getApiTokenStatus(): Promise<ApiTokenStatusResponse> {
|
||||
return apiClient.get<ApiTokenStatusResponse>(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS)
|
||||
return apiClient.get<ApiTokenStatusResponse>(
|
||||
API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,4 +245,4 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService()
|
||||
export const authService = new AuthService()
|
||||
|
||||
@@ -28,7 +28,9 @@ export class ExtractionsService {
|
||||
* Create a new extraction job
|
||||
*/
|
||||
async createExtraction(url: string): Promise<CreateExtractionResponse> {
|
||||
const response = await apiClient.post<CreateExtractionResponse>(`/api/v1/extractions/?url=${encodeURIComponent(url)}`)
|
||||
const response = await apiClient.post<CreateExtractionResponse>(
|
||||
`/api/v1/extractions/?url=${encodeURIComponent(url)}`,
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -36,7 +38,9 @@ export class ExtractionsService {
|
||||
* Get extraction by ID
|
||||
*/
|
||||
async getExtraction(extractionId: number): Promise<ExtractionInfo> {
|
||||
const response = await apiClient.get<ExtractionInfo>(`/api/v1/extractions/${extractionId}`)
|
||||
const response = await apiClient.get<ExtractionInfo>(
|
||||
`/api/v1/extractions/${extractionId}`,
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -44,9 +48,11 @@ export class ExtractionsService {
|
||||
* Get user's extractions
|
||||
*/
|
||||
async getUserExtractions(): Promise<ExtractionInfo[]> {
|
||||
const response = await apiClient.get<GetExtractionsResponse>('/api/v1/extractions/')
|
||||
const response = await apiClient.get<GetExtractionsResponse>(
|
||||
'/api/v1/extractions/',
|
||||
)
|
||||
return response.extractions
|
||||
}
|
||||
}
|
||||
|
||||
export const extractionsService = new ExtractionsService()
|
||||
export const extractionsService = new ExtractionsService()
|
||||
|
||||
@@ -7,10 +7,13 @@ export class FilesService {
|
||||
async downloadSound(soundId: number): Promise<void> {
|
||||
try {
|
||||
// Use fetch directly to handle file download
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.statusText}`)
|
||||
@@ -19,7 +22,7 @@ export class FilesService {
|
||||
// Get filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = `sound_${soundId}.mp3`
|
||||
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="(.+)"/)
|
||||
if (filenameMatch) {
|
||||
@@ -30,14 +33,14 @@ export class FilesService {
|
||||
// Create blob and download
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
|
||||
|
||||
// Create temporary download link
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
@@ -59,10 +62,13 @@ export class FilesService {
|
||||
*/
|
||||
async hasThumbnail(soundId: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`, {
|
||||
method: 'HEAD', // Only check headers, don't download
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`,
|
||||
{
|
||||
method: 'HEAD', // Only check headers, don't download
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
@@ -73,7 +79,7 @@ export class FilesService {
|
||||
* Preload a thumbnail image
|
||||
*/
|
||||
async preloadThumbnail(soundId: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
@@ -82,4 +88,4 @@ export class FilesService {
|
||||
}
|
||||
}
|
||||
|
||||
export const filesService = new FilesService()
|
||||
export const filesService = new FilesService()
|
||||
|
||||
@@ -2,4 +2,4 @@ export * from './auth'
|
||||
export * from './sounds'
|
||||
export * from './player'
|
||||
export * from './files'
|
||||
export * from './extractions'
|
||||
export * from './extractions'
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type PlayerStatus = 'playing' | 'paused' | 'stopped'
|
||||
export type PlayerMode = 'continuous' | 'loop' | 'loop_one' | 'random' | 'single'
|
||||
export type PlayerMode =
|
||||
| 'continuous'
|
||||
| 'loop'
|
||||
| 'loop_one'
|
||||
| 'random'
|
||||
| 'single'
|
||||
|
||||
export interface PlayerSound {
|
||||
id: number
|
||||
@@ -144,4 +149,4 @@ export class PlayerService {
|
||||
}
|
||||
}
|
||||
|
||||
export const playerService = new PlayerService()
|
||||
export const playerService = new PlayerService()
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type PlaylistSortField = 'name' | 'genre' | 'created_at' | 'updated_at' | 'sound_count' | 'total_duration'
|
||||
export type PlaylistSortField =
|
||||
| 'name'
|
||||
| 'genre'
|
||||
| 'created_at'
|
||||
| 'updated_at'
|
||||
| 'sound_count'
|
||||
| 'total_duration'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface Playlist {
|
||||
@@ -47,7 +53,7 @@ export class PlaylistsService {
|
||||
*/
|
||||
async getPlaylists(params?: GetPlaylistsParams): Promise<Playlist[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
|
||||
// Handle parameters
|
||||
if (params?.search) {
|
||||
searchParams.append('search', params.search)
|
||||
@@ -64,8 +70,10 @@ export class PlaylistsService {
|
||||
if (params?.offset) {
|
||||
searchParams.append('offset', params.offset.toString())
|
||||
}
|
||||
|
||||
const url = searchParams.toString() ? `/api/v1/playlists/?${searchParams.toString()}` : '/api/v1/playlists/'
|
||||
|
||||
const url = searchParams.toString()
|
||||
? `/api/v1/playlists/?${searchParams.toString()}`
|
||||
: '/api/v1/playlists/'
|
||||
return apiClient.get<Playlist[]>(url)
|
||||
}
|
||||
|
||||
@@ -104,11 +112,14 @@ export class PlaylistsService {
|
||||
/**
|
||||
* Update a playlist
|
||||
*/
|
||||
async updatePlaylist(id: number, data: {
|
||||
name?: string
|
||||
description?: string
|
||||
genre?: string
|
||||
}): Promise<Playlist> {
|
||||
async updatePlaylist(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
genre?: string
|
||||
},
|
||||
): Promise<Playlist> {
|
||||
return apiClient.put<Playlist>(`/api/v1/playlists/${id}`, data)
|
||||
}
|
||||
|
||||
@@ -154,28 +165,38 @@ export class PlaylistsService {
|
||||
/**
|
||||
* Add sound to playlist
|
||||
*/
|
||||
async addSoundToPlaylist(playlistId: number, soundId: number, position?: number): Promise<void> {
|
||||
async addSoundToPlaylist(
|
||||
playlistId: number,
|
||||
soundId: number,
|
||||
position?: number,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/api/v1/playlists/${playlistId}/sounds`, {
|
||||
sound_id: soundId,
|
||||
position
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sound from playlist
|
||||
*/
|
||||
async removeSoundFromPlaylist(playlistId: number, soundId: number): Promise<void> {
|
||||
async removeSoundFromPlaylist(
|
||||
playlistId: number,
|
||||
soundId: number,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/playlists/${playlistId}/sounds/${soundId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder sounds in playlist
|
||||
*/
|
||||
async reorderPlaylistSounds(playlistId: number, soundPositions: Array<[number, number]>): Promise<void> {
|
||||
async reorderPlaylistSounds(
|
||||
playlistId: number,
|
||||
soundPositions: Array<[number, number]>,
|
||||
): Promise<void> {
|
||||
await apiClient.put(`/api/v1/playlists/${playlistId}/sounds/reorder`, {
|
||||
sound_positions: soundPositions
|
||||
sound_positions: soundPositions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const playlistsService = new PlaylistsService()
|
||||
export const playlistsService = new PlaylistsService()
|
||||
|
||||
@@ -21,7 +21,15 @@ export interface Sound {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type SoundSortField = 'name' | 'filename' | 'duration' | 'size' | 'type' | 'play_count' | 'created_at' | 'updated_at'
|
||||
export type SoundSortField =
|
||||
| 'name'
|
||||
| 'filename'
|
||||
| 'duration'
|
||||
| 'size'
|
||||
| 'type'
|
||||
| 'play_count'
|
||||
| 'created_at'
|
||||
| 'updated_at'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface GetSoundsParams {
|
||||
@@ -43,14 +51,14 @@ export class SoundsService {
|
||||
*/
|
||||
async getSounds(params?: GetSoundsParams): Promise<Sound[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
|
||||
// Handle multiple types
|
||||
if (params?.types) {
|
||||
params.types.forEach(type => {
|
||||
searchParams.append('types', type)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Handle other parameters
|
||||
if (params?.search) {
|
||||
searchParams.append('search', params.search)
|
||||
@@ -67,8 +75,10 @@ export class SoundsService {
|
||||
if (params?.offset) {
|
||||
searchParams.append('offset', params.offset.toString())
|
||||
}
|
||||
|
||||
const url = searchParams.toString() ? `/api/v1/sounds/?${searchParams.toString()}` : '/api/v1/sounds/'
|
||||
|
||||
const url = searchParams.toString()
|
||||
? `/api/v1/sounds/?${searchParams.toString()}`
|
||||
: '/api/v1/sounds/'
|
||||
const response = await apiClient.get<GetSoundsResponse>(url)
|
||||
return response.sounds || []
|
||||
}
|
||||
@@ -76,14 +86,19 @@ export class SoundsService {
|
||||
/**
|
||||
* Get sounds of a specific type
|
||||
*/
|
||||
async getSoundsByType(type: string, params?: Omit<GetSoundsParams, 'types'>): Promise<Sound[]> {
|
||||
async getSoundsByType(
|
||||
type: string,
|
||||
params?: Omit<GetSoundsParams, 'types'>,
|
||||
): Promise<Sound[]> {
|
||||
return this.getSounds({ ...params, types: [type] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SDB type sounds
|
||||
*/
|
||||
async getSDBSounds(params?: Omit<GetSoundsParams, 'types'>): Promise<Sound[]> {
|
||||
async getSDBSounds(
|
||||
params?: Omit<GetSoundsParams, 'types'>,
|
||||
): Promise<Sound[]> {
|
||||
return this.getSoundsByType('SDB', params)
|
||||
}
|
||||
|
||||
@@ -102,4 +117,4 @@ export class SoundsService {
|
||||
}
|
||||
}
|
||||
|
||||
export const soundsService = new SoundsService()
|
||||
export const soundsService = new SoundsService()
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,4 @@ export const SOUND_EVENTS = {
|
||||
// User event types
|
||||
export const USER_EVENTS = {
|
||||
USER_CREDITS_CHANGED: 'user_credits_changed',
|
||||
} as const
|
||||
} as const
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Token refresh manager for proactive token refresh
|
||||
*/
|
||||
|
||||
import { authEvents, AUTH_EVENTS } from './events'
|
||||
import { api } from './api'
|
||||
import { AUTH_EVENTS, authEvents } from './events'
|
||||
|
||||
export class TokenRefreshManager {
|
||||
private refreshTimer: NodeJS.Timeout | null = null
|
||||
@@ -22,10 +21,10 @@ export class TokenRefreshManager {
|
||||
|
||||
this.isEnabled = true
|
||||
this.scheduleNextRefresh()
|
||||
|
||||
|
||||
// Listen for visibility changes to handle tab switching
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
|
||||
|
||||
// Listen for successful auth events to reschedule
|
||||
authEvents.on(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
|
||||
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
|
||||
@@ -41,8 +40,11 @@ export class TokenRefreshManager {
|
||||
|
||||
this.isEnabled = false
|
||||
this.clearRefreshTimer()
|
||||
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.handleVisibilityChange,
|
||||
)
|
||||
authEvents.off(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
|
||||
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
|
||||
}
|
||||
@@ -73,10 +75,9 @@ export class TokenRefreshManager {
|
||||
await api.auth.refreshToken()
|
||||
|
||||
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
|
||||
|
||||
|
||||
// Schedule next refresh immediately since we just completed one
|
||||
this.scheduleNextRefresh()
|
||||
|
||||
} catch {
|
||||
// If refresh fails, try again in 1 minute
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
@@ -87,7 +88,6 @@ export class TokenRefreshManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle tab visibility changes
|
||||
*/
|
||||
@@ -105,7 +105,7 @@ export class TokenRefreshManager {
|
||||
try {
|
||||
// Try to make an API call to see if token is still valid
|
||||
await api.auth.getMe()
|
||||
|
||||
|
||||
// Token is still valid, reschedule based on remaining time
|
||||
this.scheduleNextRefresh()
|
||||
} catch (error: unknown) {
|
||||
@@ -146,4 +146,4 @@ export class TokenRefreshManager {
|
||||
}
|
||||
|
||||
// Global token refresh manager instance
|
||||
export const tokenRefreshManager = new TokenRefreshManager()
|
||||
export const tokenRefreshManager = new TokenRefreshManager()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,56 +1,72 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
User,
|
||||
Key,
|
||||
Shield,
|
||||
Palette,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Trash2,
|
||||
Github,
|
||||
Mail,
|
||||
CheckCircle2
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
import { authService, type ApiTokenStatusResponse, type UserProvider } from '@/lib/api/services/auth'
|
||||
import {
|
||||
type ApiTokenStatusResponse,
|
||||
type UserProvider,
|
||||
authService,
|
||||
} from '@/lib/api/services/auth'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
Key,
|
||||
Mail,
|
||||
Palette,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function AccountPage() {
|
||||
const { user, setUser } = useAuth()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
|
||||
// Profile state
|
||||
const [profileName, setProfileName] = useState('')
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
|
||||
|
||||
// Password state
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
confirm_password: '',
|
||||
})
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
|
||||
|
||||
// API Token state
|
||||
const [apiTokenStatus, setApiTokenStatus] = useState<ApiTokenStatusResponse | null>(null)
|
||||
const [apiTokenStatus, setApiTokenStatus] =
|
||||
useState<ApiTokenStatusResponse | null>(null)
|
||||
const [apiTokenLoading, setApiTokenLoading] = useState(true)
|
||||
const [generatedToken, setGeneratedToken] = useState('')
|
||||
const [showGeneratedToken, setShowGeneratedToken] = useState(false)
|
||||
const [tokenExpireDays, setTokenExpireDays] = useState('365')
|
||||
|
||||
|
||||
// Providers state
|
||||
const [providers, setProviders] = useState<UserProvider[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
@@ -91,7 +107,9 @@ export function AccountPage() {
|
||||
|
||||
setProfileSaving(true)
|
||||
try {
|
||||
const updatedUser = await authService.updateProfile({ name: profileName.trim() })
|
||||
const updatedUser = await authService.updateProfile({
|
||||
name: profileName.trim(),
|
||||
})
|
||||
setUser?.(updatedUser)
|
||||
toast.success('Profile updated successfully')
|
||||
} catch (error) {
|
||||
@@ -104,14 +122,16 @@ export function AccountPage() {
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
// Check if user has password authentication from providers
|
||||
const hasPasswordProvider = providers.some(provider => provider.provider === 'password')
|
||||
|
||||
const hasPasswordProvider = providers.some(
|
||||
provider => provider.provider === 'password',
|
||||
)
|
||||
|
||||
// Validate required fields
|
||||
if (hasPasswordProvider && !passwordData.current_password) {
|
||||
toast.error('Current password is required')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!passwordData.new_password) {
|
||||
toast.error('New password is required')
|
||||
return
|
||||
@@ -130,12 +150,22 @@ export function AccountPage() {
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
await authService.changePassword({
|
||||
current_password: hasPasswordProvider ? passwordData.current_password : undefined,
|
||||
new_password: passwordData.new_password
|
||||
current_password: hasPasswordProvider
|
||||
? passwordData.current_password
|
||||
: undefined,
|
||||
new_password: passwordData.new_password,
|
||||
})
|
||||
setPasswordData({ current_password: '', new_password: '', confirm_password: '' })
|
||||
toast.success(hasPasswordProvider ? 'Password changed successfully' : 'Password set successfully')
|
||||
|
||||
setPasswordData({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
toast.success(
|
||||
hasPasswordProvider
|
||||
? 'Password changed successfully'
|
||||
: 'Password set successfully',
|
||||
)
|
||||
|
||||
// Reload providers since password status might have changed
|
||||
loadProviders()
|
||||
} catch (error) {
|
||||
@@ -148,8 +178,8 @@ export function AccountPage() {
|
||||
|
||||
const handleGenerateApiToken = async () => {
|
||||
try {
|
||||
const response = await authService.generateApiToken({
|
||||
expires_days: parseInt(tokenExpireDays)
|
||||
const response = await authService.generateApiToken({
|
||||
expires_days: parseInt(tokenExpireDays),
|
||||
})
|
||||
setGeneratedToken(response.api_token)
|
||||
setShowGeneratedToken(true)
|
||||
@@ -192,12 +222,9 @@ export function AccountPage() {
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Account' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -223,12 +250,9 @@ export function AccountPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Account' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-6">
|
||||
@@ -264,7 +288,7 @@ export function AccountPage() {
|
||||
<Input
|
||||
id="name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
onChange={e => setProfileName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
/>
|
||||
</div>
|
||||
@@ -272,15 +296,34 @@ export function AccountPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Account Details</Label>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>Role: <Badge variant={user.role === 'admin' ? 'destructive' : 'secondary'}>{user.role}</Badge></div>
|
||||
<div>Credits: <span className="font-medium">{user.credits.toLocaleString()}</span></div>
|
||||
<div>Plan: <span className="font-medium">{user.plan.name}</span></div>
|
||||
<div>Member since: {new Date(user.created_at).toLocaleDateString()}</div>
|
||||
<div>
|
||||
Role:{' '}
|
||||
<Badge
|
||||
variant={
|
||||
user.role === 'admin' ? 'destructive' : 'secondary'
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
Credits:{' '}
|
||||
<span className="font-medium">
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Plan: <span className="font-medium">{user.plan.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
Member since:{' '}
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleProfileSave}
|
||||
<Button
|
||||
onClick={handleProfileSave}
|
||||
disabled={profileSaving || profileName === user.name}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -300,7 +343,12 @@ export function AccountPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Theme Preference</Label>
|
||||
<Select value={theme} onValueChange={(value: 'light' | 'dark' | 'system') => setTheme(value)}>
|
||||
<Select
|
||||
value={theme}
|
||||
onValueChange={(value: 'light' | 'dark' | 'system') =>
|
||||
setTheme(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -317,7 +365,8 @@ export function AccountPage() {
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Current theme: <span className="font-medium capitalize">{theme}</span>
|
||||
Current theme:{' '}
|
||||
<span className="font-medium capitalize">{theme}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -341,7 +390,12 @@ export function AccountPage() {
|
||||
id="current-password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={passwordData.current_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, current_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
current_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
<Button
|
||||
@@ -349,7 +403,9 @@ export function AccountPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
onClick={() =>
|
||||
setShowCurrentPassword(!showCurrentPassword)
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -367,7 +423,12 @@ export function AccountPage() {
|
||||
id="new-password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={passwordData.new_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
new_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<Button
|
||||
@@ -387,22 +448,31 @@ export function AccountPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Label htmlFor="confirm-password">
|
||||
Confirm New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={passwordData.confirm_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
confirm_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordSaving}
|
||||
className="w-full"
|
||||
>
|
||||
{passwordSaving ? 'Changing Password...' : 'Change Password'}
|
||||
{passwordSaving
|
||||
? 'Changing Password...'
|
||||
: 'Change Password'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -411,7 +481,8 @@ export function AccountPage() {
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 <strong>Set up password authentication</strong>
|
||||
<br />
|
||||
You signed up with OAuth and don't have a password yet. Set one now to enable password login.
|
||||
You signed up with OAuth and don't have a password yet.
|
||||
Set one now to enable password login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -422,7 +493,12 @@ export function AccountPage() {
|
||||
id="new-password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={passwordData.new_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
new_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
<Button
|
||||
@@ -447,13 +523,18 @@ export function AccountPage() {
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={passwordData.confirm_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
confirm_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordSaving}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -487,11 +568,15 @@ export function AccountPage() {
|
||||
<span>API Token Active</span>
|
||||
{apiTokenStatus.expires_at && (
|
||||
<span className="text-muted-foreground">
|
||||
(Expires: {new Date(apiTokenStatus.expires_at).toLocaleDateString()})
|
||||
(Expires:{' '}
|
||||
{new Date(
|
||||
apiTokenStatus.expires_at,
|
||||
).toLocaleDateString()}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleDeleteApiToken}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -505,7 +590,10 @@ export function AccountPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expire-days">Token Expiration</Label>
|
||||
<Select value={tokenExpireDays} onValueChange={setTokenExpireDays}>
|
||||
<Select
|
||||
value={tokenExpireDays}
|
||||
onValueChange={setTokenExpireDays}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -517,7 +605,10 @@ export function AccountPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleGenerateApiToken} className="w-full">
|
||||
<Button
|
||||
onClick={handleGenerateApiToken}
|
||||
className="w-full"
|
||||
>
|
||||
Generate API Token
|
||||
</Button>
|
||||
</div>
|
||||
@@ -526,7 +617,8 @@ export function AccountPage() {
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
API tokens allow external applications to access your account programmatically
|
||||
API tokens allow external applications to access your account
|
||||
programmatically
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -540,14 +632,18 @@ export function AccountPage() {
|
||||
Authentication Methods
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Available methods to sign in to your account. Use any of these to access your account.
|
||||
Available methods to sign in to your account. Use any of these to
|
||||
access your account.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{providersLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
@@ -556,30 +652,41 @@ export function AccountPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* All Authentication Providers from API */}
|
||||
{providers.map((provider) => {
|
||||
{providers.map(provider => {
|
||||
const isOAuth = provider.provider !== 'password'
|
||||
|
||||
|
||||
return (
|
||||
<div key={provider.provider} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div
|
||||
key={provider.provider}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getProviderIcon(provider.provider)}
|
||||
<span className="font-medium">{provider.display_name}</span>
|
||||
<span className="font-medium">
|
||||
{provider.display_name}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{isOAuth ? 'OAuth' : 'Password Authentication'}
|
||||
</Badge>
|
||||
{provider.connected_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connected {new Date(provider.connected_at).toLocaleDateString()}
|
||||
Connected{' '}
|
||||
{new Date(
|
||||
provider.connected_at,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20"
|
||||
>
|
||||
Available
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
{/* API Token Provider */}
|
||||
{apiTokenStatus?.has_token && (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
@@ -589,11 +696,17 @@ export function AccountPage() {
|
||||
<Badge variant="secondary">API Access</Badge>
|
||||
{apiTokenStatus.expires_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires {new Date(apiTokenStatus.expires_at).toLocaleDateString()}
|
||||
Expires{' '}
|
||||
{new Date(
|
||||
apiTokenStatus.expires_at,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20"
|
||||
>
|
||||
Available
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -637,11 +750,14 @@ export function AccountPage() {
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ <strong>Important:</strong> This token will only be shown once.
|
||||
Copy it now and store it securely.
|
||||
⚠️ <strong>Important:</strong> This token will only be shown
|
||||
once. Copy it now and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowGeneratedToken(false)} className="w-full">
|
||||
<Button
|
||||
onClick={() => setShowGeneratedToken(false)}
|
||||
className="w-full"
|
||||
>
|
||||
I've Saved My Token
|
||||
</Button>
|
||||
</div>
|
||||
@@ -649,4 +765,4 @@ export function AccountPage() {
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { api } from '@/lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export function AuthCallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setUser } = useAuth()
|
||||
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
|
||||
const [status, setStatus] = useState<'processing' | 'success' | 'error'>(
|
||||
'processing',
|
||||
)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -15,7 +17,7 @@ export function AuthCallbackPage() {
|
||||
// Get the code from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received')
|
||||
}
|
||||
@@ -25,22 +27,23 @@ export function AuthCallbackPage() {
|
||||
|
||||
// Now get the user info
|
||||
const user = await api.auth.getMe()
|
||||
|
||||
|
||||
// Update auth context
|
||||
if (setUser) setUser(user)
|
||||
|
||||
|
||||
setStatus('success')
|
||||
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth callback failed:', error)
|
||||
setError(error instanceof Error ? error.message : 'Authentication failed')
|
||||
setError(
|
||||
error instanceof Error ? error.message : 'Authentication failed',
|
||||
)
|
||||
setStatus('error')
|
||||
|
||||
|
||||
// Redirect to login after error
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
@@ -57,28 +60,40 @@ export function AuthCallbackPage() {
|
||||
{status === 'processing' && (
|
||||
<div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<h2 className="mt-4 text-xl font-semibold">Completing sign in...</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait while we set up your account.</p>
|
||||
<h2 className="mt-4 text-xl font-semibold">
|
||||
Completing sign in...
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please wait while we set up your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<div className="text-green-600 text-4xl mb-4">✓</div>
|
||||
<h2 className="text-xl font-semibold text-green-600">Sign in successful!</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">Redirecting to dashboard...</p>
|
||||
<h2 className="text-xl font-semibold text-green-600">
|
||||
Sign in successful!
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Redirecting to dashboard...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{status === 'error' && (
|
||||
<div>
|
||||
<div className="text-red-600 text-4xl mb-4">✗</div>
|
||||
<h2 className="text-xl font-semibold text-red-600">Sign in failed</h2>
|
||||
<h2 className="text-xl font-semibold text-red-600">
|
||||
Sign in failed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{error}</p>
|
||||
<p className="text-sm text-gray-500">Redirecting to login page...</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Volume2, Play, Clock, HardDrive, Music, Trophy, Loader2, RefreshCw } from 'lucide-react'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
|
||||
import { NumberFlowSize } from '@/components/ui/number-flow-size'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import {
|
||||
Clock,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Music,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
interface SoundboardStatistics {
|
||||
sound_count: number
|
||||
@@ -32,11 +47,13 @@ interface TopSound {
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [soundboardStatistics, setSoundboardStatistics] = useState<SoundboardStatistics | null>(null)
|
||||
const [trackStatistics, setTrackStatistics] = useState<TrackStatistics | null>(null)
|
||||
const [soundboardStatistics, setSoundboardStatistics] =
|
||||
useState<SoundboardStatistics | null>(null)
|
||||
const [trackStatistics, setTrackStatistics] =
|
||||
useState<TrackStatistics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Top sounds state
|
||||
const [topSounds, setTopSounds] = useState<TopSound[]>([])
|
||||
const [topSoundsLoading, setTopSoundsLoading] = useState(false)
|
||||
@@ -48,19 +65,21 @@ export function DashboardPage() {
|
||||
const fetchStatistics = useCallback(async () => {
|
||||
try {
|
||||
const [soundboardResponse, trackResponse] = await Promise.all([
|
||||
fetch('/api/v1/dashboard/soundboard-statistics', { credentials: 'include' }),
|
||||
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' })
|
||||
fetch('/api/v1/dashboard/soundboard-statistics', {
|
||||
credentials: 'include',
|
||||
}),
|
||||
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }),
|
||||
])
|
||||
|
||||
|
||||
if (!soundboardResponse.ok || !trackResponse.ok) {
|
||||
throw new Error('Failed to fetch statistics')
|
||||
}
|
||||
|
||||
|
||||
const [soundboardData, trackData] = await Promise.all([
|
||||
soundboardResponse.json(),
|
||||
trackResponse.json()
|
||||
trackResponse.json(),
|
||||
])
|
||||
|
||||
|
||||
setSoundboardStatistics(soundboardData)
|
||||
setTrackStatistics(trackData)
|
||||
} catch (err) {
|
||||
@@ -68,61 +87,63 @@ export function DashboardPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTopSounds = useCallback(async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(true)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
|
||||
{ credentials: 'include' }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch top sounds')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Graceful update: merge new data while preserving animations
|
||||
setTopSounds(prevTopSounds => {
|
||||
// Create a map of existing sounds for efficient lookup
|
||||
const existingSoundsMap = new Map(prevTopSounds.map(sound => [sound.id, sound]))
|
||||
|
||||
// Update existing sounds and add new ones
|
||||
return data.map((newSound: TopSound) => {
|
||||
const existingSound = existingSoundsMap.get(newSound.id)
|
||||
if (existingSound) {
|
||||
// Preserve object reference if data hasn't changed to avoid re-renders
|
||||
if (
|
||||
existingSound.name === newSound.name &&
|
||||
existingSound.type === newSound.type &&
|
||||
existingSound.play_count === newSound.play_count &&
|
||||
existingSound.duration === newSound.duration
|
||||
) {
|
||||
return existingSound
|
||||
const fetchTopSounds = useCallback(
|
||||
async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(true)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch top sounds')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Graceful update: merge new data while preserving animations
|
||||
setTopSounds(prevTopSounds => {
|
||||
// Create a map of existing sounds for efficient lookup
|
||||
const existingSoundsMap = new Map(
|
||||
prevTopSounds.map(sound => [sound.id, sound]),
|
||||
)
|
||||
|
||||
// Update existing sounds and add new ones
|
||||
return data.map((newSound: TopSound) => {
|
||||
const existingSound = existingSoundsMap.get(newSound.id)
|
||||
if (existingSound) {
|
||||
// Preserve object reference if data hasn't changed to avoid re-renders
|
||||
if (
|
||||
existingSound.name === newSound.name &&
|
||||
existingSound.type === newSound.type &&
|
||||
existingSound.play_count === newSound.play_count &&
|
||||
existingSound.duration === newSound.duration
|
||||
) {
|
||||
return existingSound
|
||||
}
|
||||
}
|
||||
}
|
||||
return newSound
|
||||
return newSound
|
||||
})
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch top sounds:', err)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch top sounds:', err)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [soundType, period, limit])
|
||||
},
|
||||
[soundType, period, limit],
|
||||
)
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStatistics(),
|
||||
fetchTopSounds()
|
||||
])
|
||||
await Promise.all([fetchStatistics(), fetchTopSounds()])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
@@ -149,18 +170,16 @@ export function DashboardPage() {
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshAll])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopSounds(true) // Show loading on initial load and filter changes
|
||||
}, [fetchTopSounds])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard' }
|
||||
]
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -174,30 +193,42 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Soundboard Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Soundboard Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold animate-pulse">---</div>
|
||||
<div className="text-2xl font-bold animate-pulse">
|
||||
---
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Track Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Track Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i + 4}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold animate-pulse">---</div>
|
||||
<div className="text-2xl font-bold animate-pulse">
|
||||
---
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -211,11 +242,9 @@ export function DashboardPage() {
|
||||
|
||||
if (error || !soundboardStatistics || !trackStatistics) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard' }
|
||||
]
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -228,7 +257,9 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4">
|
||||
<p className="text-destructive">Error loading statistics: {error}</p>
|
||||
<p className="text-destructive">
|
||||
Error loading statistics: {error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -236,11 +267,9 @@ export function DashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard' }
|
||||
]
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -251,28 +280,36 @@ export function DashboardPage() {
|
||||
Overview of your soundboard and track statistics
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={refreshAll}
|
||||
variant="outline"
|
||||
<Button
|
||||
onClick={refreshAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Soundboard Statistics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Soundboard Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Soundboard Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Sounds</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Sounds
|
||||
</CardTitle>
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={soundboardStatistics.sound_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={soundboardStatistics.sound_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Soundboard audio files
|
||||
</p>
|
||||
@@ -281,11 +318,15 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Plays</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Plays
|
||||
</CardTitle>
|
||||
<Play className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={soundboardStatistics.total_play_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={soundboardStatistics.total_play_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All-time play count
|
||||
</p>
|
||||
@@ -294,12 +335,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Duration</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Duration
|
||||
</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowDuration duration={soundboardStatistics.total_duration} variant='wordy' />
|
||||
<NumberFlowDuration
|
||||
duration={soundboardStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined audio duration
|
||||
@@ -309,12 +355,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Size</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Size
|
||||
</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowSize size={soundboardStatistics.total_size} binary={true} />
|
||||
<NumberFlowSize
|
||||
size={soundboardStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Original + normalized files
|
||||
@@ -326,15 +377,21 @@ export function DashboardPage() {
|
||||
|
||||
{/* Track Statistics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Track Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Track Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Tracks</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Tracks
|
||||
</CardTitle>
|
||||
<Music className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={trackStatistics.track_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={trackStatistics.track_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Extracted audio tracks
|
||||
</p>
|
||||
@@ -343,11 +400,15 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Plays</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Plays
|
||||
</CardTitle>
|
||||
<Play className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={trackStatistics.total_play_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={trackStatistics.total_play_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All-time play count
|
||||
</p>
|
||||
@@ -356,12 +417,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Duration</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Duration
|
||||
</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowDuration duration={trackStatistics.total_duration} variant='wordy' />
|
||||
<NumberFlowDuration
|
||||
duration={trackStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined track duration
|
||||
@@ -371,12 +437,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Size</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Size
|
||||
</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowSize size={trackStatistics.total_size} binary={true} />
|
||||
<NumberFlowSize
|
||||
size={trackStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Original + normalized files
|
||||
@@ -385,7 +456,7 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Top Sounds Section */}
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
@@ -428,7 +499,10 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Count:</span>
|
||||
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
||||
<Select
|
||||
value={limit.toString()}
|
||||
onValueChange={value => setLimit(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -457,18 +531,26 @@ export function DashboardPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topSounds.map((sound, index) => (
|
||||
<div key={sound.id} className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg">
|
||||
<div
|
||||
key={sound.id}
|
||||
className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
<div className="font-medium truncate">
|
||||
{sound.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
|
||||
{sound.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<NumberFlowDuration duration={sound.duration} variant='wordy' />
|
||||
<NumberFlowDuration
|
||||
duration={sound.duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
|
||||
@@ -477,8 +559,12 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary"><NumberFlow value={sound.play_count} /></div>
|
||||
<div className="text-xs text-muted-foreground">plays</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
<NumberFlow value={sound.play_count} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
plays
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -491,4 +577,4 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
|
||||
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
type ExtractionInfo,
|
||||
extractionsService,
|
||||
} from '@/lib/api/services/extractions'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function ExtractionsPage() {
|
||||
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
|
||||
@@ -63,29 +88,53 @@ export function ExtractionsPage() {
|
||||
const getStatusBadge = (status: ExtractionInfo['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="secondary" className="gap-1"><Clock className="h-3 w-3" />Pending</Badge>
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)
|
||||
case 'processing':
|
||||
return <Badge variant="outline" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" />Processing</Badge>
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
)
|
||||
case 'completed':
|
||||
return <Badge variant="default" className="gap-1"><CheckCircle className="h-3 w-3" />Completed</Badge>
|
||||
return (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
)
|
||||
case 'failed':
|
||||
return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Failed</Badge>
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceBadge = (service: string | undefined) => {
|
||||
if (!service) return null
|
||||
|
||||
|
||||
const serviceColors: Record<string, string> = {
|
||||
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
soundcloud: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
soundcloud:
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
instagram:
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
}
|
||||
|
||||
const colorClass = serviceColors[service.toLowerCase()] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
const colorClass =
|
||||
serviceColors[service.toLowerCase()] ||
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colorClass}>
|
||||
@@ -95,12 +144,9 @@ export function ExtractionsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Extractions' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Extractions' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -111,7 +157,7 @@ export function ExtractionsPage() {
|
||||
Extract audio from YouTube, SoundCloud, and other platforms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
@@ -130,22 +176,29 @@ export function ExtractionsPage() {
|
||||
id="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !isCreating) {
|
||||
handleCreateExtraction()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, Instagram, and more
|
||||
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
|
||||
Instagram, and more
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateExtraction} disabled={isCreating}>
|
||||
<Button
|
||||
onClick={handleCreateExtraction}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
@@ -171,9 +224,12 @@ export function ExtractionsPage() {
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center">
|
||||
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No extractions yet</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
No extractions yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Start by adding a URL to extract audio from your favorite platforms
|
||||
Start by adding a URL to extract audio from your favorite
|
||||
platforms
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -199,7 +255,7 @@ export function ExtractionsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extractions.map((extraction) => (
|
||||
{extractions.map(extraction => (
|
||||
<TableRow key={extraction.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
@@ -217,7 +273,10 @@ export function ExtractionsPage() {
|
||||
<TableCell>
|
||||
{getStatusBadge(extraction.status)}
|
||||
{extraction.error && (
|
||||
<div className="text-xs text-destructive mt-1 max-w-48 truncate" title={extraction.error}>
|
||||
<div
|
||||
className="text-xs text-destructive mt-1 max-w-48 truncate"
|
||||
title={extraction.error}
|
||||
>
|
||||
{extraction.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -231,7 +290,9 @@ export function ExtractionsPage() {
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid date'
|
||||
}
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
return formatDistanceToNow(date, {
|
||||
addSuffix: true,
|
||||
})
|
||||
} catch {
|
||||
return 'Invalid date'
|
||||
}
|
||||
@@ -241,15 +302,24 @@ export function ExtractionsPage() {
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={extraction.url} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={extraction.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{extraction.status === 'completed' && extraction.sound_id && (
|
||||
<Button variant="ghost" size="sm" title="View in Sounds">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{extraction.status === 'completed' &&
|
||||
extraction.sound_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="View in Sounds"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -262,4 +332,4 @@ export function ExtractionsPage() {
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Link } from 'react-router'
|
||||
import { LoginForm } from '@/components/auth/LoginForm'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<LoginForm />
|
||||
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Sign up
|
||||
@@ -21,4 +21,4 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,88 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
type Playlist,
|
||||
type PlaylistSortField,
|
||||
type SortOrder,
|
||||
playlistsService,
|
||||
} from '@/lib/api/services/playlists'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit,
|
||||
Music,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { playlistsService, type Playlist, type PlaylistSortField, type SortOrder } from '@/lib/api/services/playlists'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw, Music, User, Calendar, Clock, Plus, Play, Edit } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
|
||||
export function PlaylistsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Search and sorting state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
|
||||
|
||||
// Create playlist dialog state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [newPlaylist, setNewPlaylist] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
genre: ''
|
||||
genre: '',
|
||||
})
|
||||
|
||||
// Debounce search query
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery)
|
||||
}, 300)
|
||||
|
||||
|
||||
return () => clearTimeout(handler)
|
||||
}, [searchQuery])
|
||||
|
||||
@@ -57,7 +97,8 @@ export function PlaylistsPage() {
|
||||
})
|
||||
setPlaylists(playlistData)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlists'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch playlists'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
@@ -82,17 +123,18 @@ export function PlaylistsPage() {
|
||||
description: newPlaylist.description.trim() || undefined,
|
||||
genre: newPlaylist.genre.trim() || undefined,
|
||||
})
|
||||
|
||||
|
||||
toast.success(`Playlist "${newPlaylist.name}" created successfully`)
|
||||
|
||||
|
||||
// Reset form and close dialog
|
||||
setNewPlaylist({ name: '', description: '', genre: '' })
|
||||
setShowCreateDialog(false)
|
||||
|
||||
|
||||
// Refresh the playlists list
|
||||
fetchPlaylists()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create playlist'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to create playlist'
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
@@ -108,11 +150,12 @@ export function PlaylistsPage() {
|
||||
try {
|
||||
await playlistsService.setCurrentPlaylist(playlist.id)
|
||||
toast.success(`"${playlist.name}" is now the current playlist`)
|
||||
|
||||
|
||||
// Refresh the playlists list to update the current status
|
||||
fetchPlaylists()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to set current playlist'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to set current playlist'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -137,10 +180,12 @@ export function PlaylistsPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load playlists</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load playlists
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchPlaylists}
|
||||
<button
|
||||
onClick={fetchPlaylists}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
@@ -157,7 +202,9 @@ export function PlaylistsPage() {
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No playlists found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'No playlists match your search criteria.' : 'No playlists are available.'}
|
||||
{searchQuery
|
||||
? 'No playlists match your search criteria.'
|
||||
: 'No playlists are available.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -179,13 +226,15 @@ export function PlaylistsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{playlists.map((playlist) => (
|
||||
{playlists.map(playlist => (
|
||||
<TableRow key={playlist.id} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{playlist.name}</div>
|
||||
<div className="font-medium truncate">
|
||||
{playlist.name}
|
||||
</div>
|
||||
{playlist.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{playlist.description}
|
||||
@@ -234,9 +283,7 @@ export function PlaylistsPage() {
|
||||
{playlist.is_current && (
|
||||
<Badge variant="default">Current</Badge>
|
||||
)}
|
||||
{playlist.is_main && (
|
||||
<Badge variant="outline">Main</Badge>
|
||||
)}
|
||||
{playlist.is_main && <Badge variant="outline">Main</Badge>}
|
||||
{!playlist.is_current && !playlist.is_main && (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
@@ -275,12 +322,9 @@ export function PlaylistsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Playlists' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Playlists' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -303,7 +347,8 @@ export function PlaylistsPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Playlist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new playlist to organize your sounds. Give it a name and optionally add a description and genre.
|
||||
Add a new playlist to organize your sounds. Give it a name
|
||||
and optionally add a description and genre.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -313,8 +358,13 @@ export function PlaylistsPage() {
|
||||
id="name"
|
||||
placeholder="My awesome playlist"
|
||||
value={newPlaylist.name}
|
||||
onChange={(e) => setNewPlaylist(prev => ({ ...prev, name: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
onChange={e =>
|
||||
setNewPlaylist(prev => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleCreatePlaylist()
|
||||
@@ -328,7 +378,12 @@ export function PlaylistsPage() {
|
||||
id="description"
|
||||
placeholder="A collection of my favorite sounds..."
|
||||
value={newPlaylist.description}
|
||||
onChange={(e) => setNewPlaylist(prev => ({ ...prev, description: e.target.value }))}
|
||||
onChange={e =>
|
||||
setNewPlaylist(prev => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
@@ -338,15 +393,27 @@ export function PlaylistsPage() {
|
||||
id="genre"
|
||||
placeholder="Electronic, Rock, Comedy, etc."
|
||||
value={newPlaylist.genre}
|
||||
onChange={(e) => setNewPlaylist(prev => ({ ...prev, genre: e.target.value }))}
|
||||
onChange={e =>
|
||||
setNewPlaylist(prev => ({
|
||||
...prev,
|
||||
genre: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelCreate} disabled={createLoading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelCreate}
|
||||
disabled={createLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreatePlaylist} disabled={createLoading || !newPlaylist.name.trim()}>
|
||||
<Button
|
||||
onClick={handleCreatePlaylist}
|
||||
disabled={createLoading || !newPlaylist.name.trim()}
|
||||
>
|
||||
{createLoading ? 'Creating...' : 'Create Playlist'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -359,7 +426,7 @@ export function PlaylistsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
@@ -368,7 +435,7 @@ export function PlaylistsPage() {
|
||||
<Input
|
||||
placeholder="Search playlists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -384,9 +451,12 @@ export function PlaylistsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as PlaylistSortField)}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => setSortBy(value as PlaylistSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
@@ -399,16 +469,20 @@ export function PlaylistsPage() {
|
||||
<SelectItem value="updated_at">Updated Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
|
||||
>
|
||||
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -416,13 +490,15 @@ export function PlaylistsPage() {
|
||||
disabled={loading}
|
||||
title="Refresh playlists"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Link } from 'react-router'
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<RegisterForm />
|
||||
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Sign in
|
||||
@@ -21,4 +21,4 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { SoundCard } from '@/components/sounds/SoundCard'
|
||||
import { soundsService, type Sound, type SoundSortField, type SortOrder } from '@/lib/api/services/sounds'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
import { soundEvents, SOUND_EVENTS } from '@/lib/events'
|
||||
import {
|
||||
type SortOrder,
|
||||
type Sound,
|
||||
type SoundSortField,
|
||||
soundsService,
|
||||
} from '@/lib/api/services/sounds'
|
||||
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
|
||||
import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface SoundPlayedEventData {
|
||||
sound_id: number
|
||||
@@ -54,7 +72,7 @@ export function SoundsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentColors, setCurrentColors] = useState<string[]>(lightModeColors)
|
||||
|
||||
|
||||
// Search and sorting state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
||||
@@ -65,10 +83,12 @@ export function SoundsPage() {
|
||||
await soundsService.playSound(sound.id)
|
||||
toast.success(`Playing: ${sound.name || sound.filename}`)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to play sound: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
toast.error(
|
||||
`Failed to play sound: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,7 +98,7 @@ export function SoundsPage() {
|
||||
setCurrentColors(lightModeColors)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
|
||||
const getSoundColor = (soundIdx: number) => {
|
||||
const index = soundIdx % currentColors.length
|
||||
return currentColors[index]
|
||||
@@ -95,7 +115,8 @@ export function SoundsPage() {
|
||||
})
|
||||
setSounds(sdbSounds)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
@@ -105,12 +126,12 @@ export function SoundsPage() {
|
||||
|
||||
// Debounce search query
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery)
|
||||
}, 300)
|
||||
|
||||
|
||||
return () => clearTimeout(handler)
|
||||
}, [searchQuery])
|
||||
|
||||
@@ -121,12 +142,12 @@ export function SoundsPage() {
|
||||
// Listen for sound_played events and update play_count
|
||||
useEffect(() => {
|
||||
const handleSoundPlayed = (eventData: SoundPlayedEventData) => {
|
||||
setSounds(prevSounds =>
|
||||
prevSounds.map(sound =>
|
||||
setSounds(prevSounds =>
|
||||
prevSounds.map(sound =>
|
||||
sound.id === eventData.sound_id
|
||||
? { ...sound, play_count: eventData.play_count }
|
||||
: sound
|
||||
)
|
||||
: sound,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,8 +177,8 @@ export function SoundsPage() {
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load sounds</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
@@ -183,19 +204,21 @@ export function SoundsPage() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{sounds.map((sound, idx) => (
|
||||
<SoundCard key={sound.id} sound={sound} playSound={handlePlaySound} colorClasses={getSoundColor(idx)} />
|
||||
<SoundCard
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
playSound={handlePlaySound}
|
||||
colorClasses={getSoundColor(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Sounds' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Sounds' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -212,7 +235,7 @@ export function SoundsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
@@ -221,7 +244,7 @@ export function SoundsPage() {
|
||||
<Input
|
||||
placeholder="Search sounds..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -237,9 +260,12 @@ export function SoundsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SoundSortField)}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => setSortBy(value as SoundSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
@@ -252,16 +278,20 @@ export function SoundsPage() {
|
||||
<SelectItem value="updated_at">Updated Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
|
||||
>
|
||||
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -269,13 +299,15 @@ export function SoundsPage() {
|
||||
disabled={loading}
|
||||
title="Refresh sounds"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Scan,
|
||||
Volume2,
|
||||
Settings as SettingsIcon,
|
||||
Loader2,
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
type NormalizationResponse,
|
||||
type ScanResponse,
|
||||
adminService,
|
||||
} from '@/lib/api/services/admin'
|
||||
import {
|
||||
AudioWaveform,
|
||||
FolderSync,
|
||||
AudioWaveform
|
||||
Loader2,
|
||||
Scan,
|
||||
Settings as SettingsIcon,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { adminService, type ScanResponse, type NormalizationResponse } from '@/lib/api/services/admin'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function SettingsPage() {
|
||||
// Sound scanning state
|
||||
const [scanningInProgress, setScanningInProgress] = useState(false)
|
||||
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(null)
|
||||
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
// Sound normalization state
|
||||
const [normalizationInProgress, setNormalizationInProgress] = useState(false)
|
||||
const [normalizationOptions, setNormalizationOptions] = useState({
|
||||
force: false,
|
||||
onePass: false,
|
||||
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT'
|
||||
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT',
|
||||
})
|
||||
const [lastNormalizationResults, setLastNormalizationResults] = useState<NormalizationResponse | null>(null)
|
||||
const [lastNormalizationResults, setLastNormalizationResults] =
|
||||
useState<NormalizationResponse | null>(null)
|
||||
|
||||
const handleScanSounds = async () => {
|
||||
setScanningInProgress(true)
|
||||
try {
|
||||
const response = await adminService.scanSounds()
|
||||
setLastScanResults(response)
|
||||
toast.success(`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`)
|
||||
toast.success(
|
||||
`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error('Failed to scan sounds')
|
||||
console.error('Sound scan error:', error)
|
||||
@@ -48,22 +63,24 @@ export function SettingsPage() {
|
||||
setNormalizationInProgress(true)
|
||||
try {
|
||||
let response: NormalizationResponse
|
||||
|
||||
|
||||
if (normalizationOptions.soundType === 'all') {
|
||||
response = await adminService.normalizeAllSounds(
|
||||
normalizationOptions.force,
|
||||
normalizationOptions.onePass
|
||||
normalizationOptions.onePass,
|
||||
)
|
||||
} else {
|
||||
response = await adminService.normalizeSoundsByType(
|
||||
normalizationOptions.soundType,
|
||||
normalizationOptions.force,
|
||||
normalizationOptions.onePass
|
||||
normalizationOptions.onePass,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
setLastNormalizationResults(response)
|
||||
toast.success(`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`)
|
||||
toast.success(
|
||||
`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error('Failed to normalize sounds')
|
||||
console.error('Sound normalization error:', error)
|
||||
@@ -73,13 +90,13 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Admin' },
|
||||
{ label: 'Settings' }
|
||||
]
|
||||
{ label: 'Settings' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -104,11 +121,12 @@ export function SettingsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the sound directories to synchronize new, updated, and deleted audio files with the database.
|
||||
Scan the sound directories to synchronize new, updated, and
|
||||
deleted audio files with the database.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleScanSounds}
|
||||
<Button
|
||||
onClick={handleScanSounds}
|
||||
disabled={scanningInProgress}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -134,7 +152,9 @@ export function SettingsPage() {
|
||||
<div>🗑️ Deleted: {lastScanResults.results.deleted}</div>
|
||||
<div>⏭️ Skipped: {lastScanResults.results.skipped}</div>
|
||||
{lastScanResults.results.errors.length > 0 && (
|
||||
<div>❌ Errors: {lastScanResults.results.errors.length}</div>
|
||||
<div>
|
||||
❌ Errors: {lastScanResults.results.errors.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,16 +172,20 @@ export function SettingsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Normalize audio levels across all sounds using FFmpeg's loudnorm filter for consistent volume.
|
||||
Normalize audio levels across all sounds using FFmpeg's loudnorm
|
||||
filter for consistent volume.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Sound Type</Label>
|
||||
<Select
|
||||
value={normalizationOptions.soundType}
|
||||
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
|
||||
setNormalizationOptions(prev => ({ ...prev, soundType: value }))
|
||||
<Select
|
||||
value={normalizationOptions.soundType}
|
||||
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
|
||||
setNormalizationOptions(prev => ({
|
||||
...prev,
|
||||
soundType: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -177,11 +201,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
<Checkbox
|
||||
id="force-normalize"
|
||||
checked={normalizationOptions.force}
|
||||
onCheckedChange={(checked) =>
|
||||
setNormalizationOptions(prev => ({ ...prev, force: !!checked }))
|
||||
onCheckedChange={checked =>
|
||||
setNormalizationOptions(prev => ({
|
||||
...prev,
|
||||
force: !!checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="force-normalize" className="text-sm">
|
||||
@@ -190,11 +217,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
<Checkbox
|
||||
id="one-pass"
|
||||
checked={normalizationOptions.onePass}
|
||||
onCheckedChange={(checked) =>
|
||||
setNormalizationOptions(prev => ({ ...prev, onePass: !!checked }))
|
||||
onCheckedChange={checked =>
|
||||
setNormalizationOptions(prev => ({
|
||||
...prev,
|
||||
onePass: !!checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="one-pass" className="text-sm">
|
||||
@@ -203,8 +233,8 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNormalizeSounds}
|
||||
<Button
|
||||
onClick={handleNormalizeSounds}
|
||||
disabled={normalizationInProgress}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -223,19 +253,35 @@ export function SettingsPage() {
|
||||
|
||||
{lastNormalizationResults && (
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium">Last Normalization Results:</div>
|
||||
<div className="text-sm font-medium">
|
||||
Last Normalization Results:
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>🔄 Processed: {lastNormalizationResults.results.processed}</div>
|
||||
<div>✅ Normalized: {lastNormalizationResults.results.normalized}</div>
|
||||
<div>⏭️ Skipped: {lastNormalizationResults.results.skipped}</div>
|
||||
<div>❌ Errors: {lastNormalizationResults.results.errors}</div>
|
||||
{lastNormalizationResults.results.error_details.length > 0 && (
|
||||
<div>
|
||||
🔄 Processed: {lastNormalizationResults.results.processed}
|
||||
</div>
|
||||
<div>
|
||||
✅ Normalized:{' '}
|
||||
{lastNormalizationResults.results.normalized}
|
||||
</div>
|
||||
<div>
|
||||
⏭️ Skipped: {lastNormalizationResults.results.skipped}
|
||||
</div>
|
||||
<div>
|
||||
❌ Errors: {lastNormalizationResults.results.errors}
|
||||
</div>
|
||||
{lastNormalizationResults.results.error_details.length >
|
||||
0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-red-600">View Error Details</summary>
|
||||
<summary className="cursor-pointer text-red-600">
|
||||
View Error Details
|
||||
</summary>
|
||||
<div className="mt-1 text-xs text-red-600 space-y-1">
|
||||
{lastNormalizationResults.results.error_details.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
{lastNormalizationResults.results.error_details.map(
|
||||
(error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
@@ -248,4 +294,4 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { Edit, UserCheck, UserX } from 'lucide-react'
|
||||
import { adminService, type Plan } from '@/lib/api/services/admin'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { type Plan, adminService } from '@/lib/api/services/admin'
|
||||
import type { User } from '@/types/auth'
|
||||
import { Edit, UserCheck, UserX } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface EditUserData {
|
||||
name: string
|
||||
@@ -31,7 +44,7 @@ export function UsersPage() {
|
||||
name: '',
|
||||
plan_id: 0,
|
||||
credits: 0,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -43,7 +56,7 @@ export function UsersPage() {
|
||||
try {
|
||||
const [usersData, plansData] = await Promise.all([
|
||||
adminService.listUsers(),
|
||||
adminService.listPlans()
|
||||
adminService.listPlans(),
|
||||
])
|
||||
setUsers(usersData)
|
||||
setPlans(plansData)
|
||||
@@ -61,7 +74,7 @@ export function UsersPage() {
|
||||
name: user.name,
|
||||
plan_id: user.plan.id,
|
||||
credits: user.credits,
|
||||
is_active: user.is_active
|
||||
is_active: user.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,8 +83,13 @@ export function UsersPage() {
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const updatedUser = await adminService.updateUser(editingUser.id, editData)
|
||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? updatedUser : u))
|
||||
const updatedUser = await adminService.updateUser(
|
||||
editingUser.id,
|
||||
editData,
|
||||
)
|
||||
setUsers(prev =>
|
||||
prev.map(u => (u.id === editingUser.id ? updatedUser : u)),
|
||||
)
|
||||
setEditingUser(null)
|
||||
toast.success('User updated successfully')
|
||||
} catch (error) {
|
||||
@@ -117,13 +135,13 @@ export function UsersPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Admin' },
|
||||
{ label: 'Users' }
|
||||
]
|
||||
{ label: 'Users' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -152,13 +170,13 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Admin' },
|
||||
{ label: 'Users' }
|
||||
]
|
||||
{ label: 'Users' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -192,7 +210,7 @@ export function UsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
{users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
@@ -231,39 +249,62 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
{/* Edit User Sheet */}
|
||||
<Sheet open={!!editingUser} onOpenChange={(open) => !open && setEditingUser(null)}>
|
||||
<Sheet
|
||||
open={!!editingUser}
|
||||
onOpenChange={open => !open && setEditingUser(null)}
|
||||
>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<div className="px-6">
|
||||
<div className="pt-4 pb-6">
|
||||
<h2 className="text-xl font-semibold">Edit User</h2>
|
||||
</div>
|
||||
|
||||
|
||||
{editingUser && (
|
||||
<div className="space-y-8 pb-6">
|
||||
{/* User Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-base">User Information</h3>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">User ID:</span>
|
||||
<span className="col-span-2 font-mono">{editingUser.id}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
User ID:
|
||||
</span>
|
||||
<span className="col-span-2 font-mono">
|
||||
{editingUser.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Email:</span>
|
||||
<span className="col-span-2 break-all">{editingUser.email}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Email:
|
||||
</span>
|
||||
<span className="col-span-2 break-all">
|
||||
{editingUser.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Role:</span>
|
||||
<span className="col-span-2">{getRoleBadge(editingUser.role)}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Role:
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{getRoleBadge(editingUser.role)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Created:</span>
|
||||
<span className="col-span-2">{new Date(editingUser.created_at).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Created:
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{new Date(editingUser.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Last Updated:</span>
|
||||
<span className="col-span-2">{new Date(editingUser.updated_at).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Last Updated:
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{new Date(editingUser.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,11 +315,18 @@ export function UsersPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">Display Name</Label>
|
||||
<Label htmlFor="name" className="text-sm font-medium">
|
||||
Display Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, name: e.target.value }))}
|
||||
onChange={e =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter user's display name"
|
||||
className="h-10"
|
||||
/>
|
||||
@@ -288,21 +336,32 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan" className="text-sm font-medium">Subscription Plan</Label>
|
||||
<Label htmlFor="plan" className="text-sm font-medium">
|
||||
Subscription Plan
|
||||
</Label>
|
||||
<Select
|
||||
value={editData.plan_id.toString()}
|
||||
onValueChange={(value) => setEditData(prev => ({ ...prev, plan_id: parseInt(value) }))}
|
||||
onValueChange={value =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
plan_id: parseInt(value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem key={plan.id} value={plan.id.toString()}>
|
||||
{plans.map(plan => (
|
||||
<SelectItem
|
||||
key={plan.id}
|
||||
value={plan.id.toString()}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plan.max_credits.toLocaleString()} max credits
|
||||
{plan.max_credits.toLocaleString()} max
|
||||
credits
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -310,40 +369,64 @@ export function UsersPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current plan: <span className="font-medium">{editingUser.plan.name}</span>
|
||||
Current plan:{' '}
|
||||
<span className="font-medium">
|
||||
{editingUser.plan.name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits" className="text-sm font-medium">Current Credits</Label>
|
||||
<Label htmlFor="credits" className="text-sm font-medium">
|
||||
Current Credits
|
||||
</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={editData.credits}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, credits: parseInt(e.target.value) || 0 }))}
|
||||
onChange={e =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
credits: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter credit amount"
|
||||
className="h-10"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum allowed: <span className="font-medium">{editingUser.plan.max_credits.toLocaleString()}</span>
|
||||
Maximum allowed:{' '}
|
||||
<span className="font-medium">
|
||||
{editingUser.plan.max_credits.toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Account Status</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
Account Status
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Allow Login Access</span>
|
||||
<span className="text-sm font-medium">
|
||||
Allow Login Access
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{editData.is_active ? 'User can log in and use the platform' : 'User is blocked from logging in and accessing the platform'}
|
||||
{editData.is_active
|
||||
? 'User can log in and use the platform'
|
||||
: 'User is blocked from logging in and accessing the platform'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="active"
|
||||
checked={editData.is_active}
|
||||
onCheckedChange={(checked) => setEditData(prev => ({ ...prev, is_active: checked }))}
|
||||
onCheckedChange={checked =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
is_active: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,4 +458,4 @@ export function UsersPage() {
|
||||
</Sheet>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,4 @@ export interface AuthContextType {
|
||||
logout: () => Promise<void>
|
||||
loading: boolean
|
||||
setUser?: (user: User | null) => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user