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