Refactor and enhance UI components across multiple pages
- Improved import organization and formatting in PlaylistsPage, RegisterPage, SoundsPage, SettingsPage, and UsersPage for better readability. - Added error handling and user feedback with toast notifications in SoundsPage and SettingsPage. - Enhanced user experience by implementing debounced search functionality in PlaylistsPage and SoundsPage. - Updated the layout and structure of forms in SettingsPage and UsersPage for better usability. - Improved accessibility and semantics by ensuring proper labeling and descriptions in forms. - Fixed minor bugs related to state management and API calls in various components.
This commit is contained in:
@@ -18,4 +18,4 @@
|
|||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/App.tsx
160
src/App.tsx
@@ -1,100 +1,138 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router'
|
import { Navigate, Route, Routes } from 'react-router'
|
||||||
import { ThemeProvider } from './components/ThemeProvider'
|
import { 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) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
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) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role !== 'admin') {
|
if (user.role !== 'admin') {
|
||||||
return <Navigate to="/" replace />
|
return <Navigate to="/" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
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" />
|
||||||
@@ -73,4 +75,4 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
|||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ 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>
|
||||||
@@ -63,7 +71,7 @@ export function LoginForm() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -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>
|
||||||
@@ -96,4 +100,4 @@ export function LoginForm() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>([])
|
||||||
@@ -24,10 +24,10 @@ export function OAuthButtons() {
|
|||||||
setLoading(provider)
|
setLoading(provider)
|
||||||
try {
|
try {
|
||||||
const response = await api.auth.getOAuthUrl(provider)
|
const response = await api.auth.getOAuthUrl(provider)
|
||||||
|
|
||||||
// Store state in sessionStorage for verification
|
// Store state in sessionStorage for verification
|
||||||
sessionStorage.setItem('oauth_state', response.state)
|
sessionStorage.setItem('oauth_state', response.state)
|
||||||
|
|
||||||
// Redirect to OAuth provider
|
// Redirect to OAuth provider
|
||||||
window.location.href = response.authorization_url
|
window.location.href = response.authorization_url
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,9 +90,9 @@ export function OAuthButtons() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
@@ -107,14 +107,13 @@ export function OAuthButtons() {
|
|||||||
getProviderIcon(provider)
|
getProviderIcon(provider)
|
||||||
)}
|
)}
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -94,7 +102,7 @@ export function RegisterForm() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -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>
|
||||||
@@ -141,4 +145,4 @@ export function RegisterForm() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,7 +30,7 @@ export function CreditsNav({ user }: CreditsNavProps) {
|
|||||||
}, [user.credits])
|
}, [user.credits])
|
||||||
|
|
||||||
const tooltipText = `Credits: ${credits} / ${user.plan.max_credits}`
|
const tooltipText = `Credits: ${credits} / ${user.plan.max_credits}`
|
||||||
|
|
||||||
// Determine icon color based on credit levels
|
// Determine icon color based on credit levels
|
||||||
const getIconColor = () => {
|
const getIconColor = () => {
|
||||||
if (credits === 0) return 'text-red-500'
|
if (credits === 0) return 'text-red-500'
|
||||||
@@ -41,16 +41,21 @@ 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>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ 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
|
||||||
@@ -31,4 +28,4 @@ export function NavItem({ href, icon: Icon, title, badge }: NavItemProps) {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -78,4 +83,4 @@ export function UserNav({ user, logout }: UserNavProps) {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
SkipBack,
|
|
||||||
SkipForward,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
Music,
|
|
||||||
Maximize2
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { playerService, type PlayerState, type MessageResponse } from '@/lib/api/services/player'
|
|
||||||
import { filesService } from '@/lib/api/services/files'
|
import { filesService } from '@/lib/api/services/files'
|
||||||
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
|
import {
|
||||||
import { toast } from 'sonner'
|
type MessageResponse,
|
||||||
|
type PlayerState,
|
||||||
|
playerService,
|
||||||
|
} from '@/lib/api/services/player'
|
||||||
|
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
|
import {
|
||||||
|
Maximize2,
|
||||||
|
Music,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface CompactPlayerProps {
|
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
|
||||||
@@ -128,11 +137,11 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
|||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-3 px-1">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
||||||
{state.current_sound?.thumbnail ? (
|
{state.current_sound?.thumbnail ? (
|
||||||
<img
|
<img
|
||||||
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'
|
||||||
@@ -141,11 +150,11 @@ 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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -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"
|
||||||
@@ -194,7 +205,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
|||||||
>
|
>
|
||||||
<SkipBack className="h-3 w-3" />
|
<SkipBack className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -238,4 +249,4 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,47 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Slider } from '@/components/ui/slider'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
Play,
|
import {
|
||||||
Pause,
|
DropdownMenu,
|
||||||
Square,
|
DropdownMenuContent,
|
||||||
SkipBack,
|
DropdownMenuItem,
|
||||||
SkipForward,
|
DropdownMenuTrigger,
|
||||||
Volume2,
|
} from '@/components/ui/dropdown-menu'
|
||||||
VolumeX,
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { filesService } from '@/lib/api/services/files'
|
||||||
|
import {
|
||||||
|
type MessageResponse,
|
||||||
|
type PlayerMode,
|
||||||
|
type PlayerState,
|
||||||
|
playerService,
|
||||||
|
} from '@/lib/api/services/player'
|
||||||
|
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ArrowRightToLine,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
List,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
MoreVertical,
|
||||||
|
Music,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
Repeat,
|
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,17 +57,22 @@ 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'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify parent when display mode changes and save to localStorage
|
// Notify parent when display mode changes and save to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPlayerModeChange?.(displayMode)
|
onPlayerModeChange?.(displayMode)
|
||||||
@@ -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')
|
||||||
@@ -172,7 +205,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
|
|
||||||
const handleDownloadSound = useCallback(async () => {
|
const handleDownloadSound = useCallback(async () => {
|
||||||
if (!state.current_sound) return
|
if (!state.current_sound) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await filesService.downloadSound(state.current_sound.id)
|
await filesService.downloadSound(state.current_sound.id)
|
||||||
toast.success('Download started')
|
toast.success('Download started')
|
||||||
@@ -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':
|
||||||
@@ -300,11 +333,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{state.current_sound?.thumbnail ? (
|
{state.current_sound?.thumbnail ? (
|
||||||
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
<img
|
<img
|
||||||
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'
|
||||||
@@ -312,11 +345,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
if (musicIcon) musicIcon.style.display = 'block'
|
if (musicIcon) musicIcon.style.display = 'block'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -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
|
||||||
@@ -474,10 +507,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
{/* Playlist */}
|
{/* Playlist */}
|
||||||
{showPlaylist && state.playlist && (
|
{showPlaylist && state.playlist && (
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
@@ -506,11 +544,11 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
{/* Large Album Art */}
|
{/* Large Album Art */}
|
||||||
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
|
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
|
||||||
{state.current_sound?.thumbnail ? (
|
{state.current_sound?.thumbnail ? (
|
||||||
<img
|
<img
|
||||||
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'
|
||||||
@@ -519,11 +557,11 @@ 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" />
|
||||||
) : (
|
) : (
|
||||||
@@ -680,10 +710,15 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<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
|
||||||
@@ -712,4 +749,4 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
{displayMode === 'maximized' && renderMaximizedPlayer()}
|
{displayMode === 'maximized' && renderMaximizedPlayer()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,33 +13,33 @@ interface PlaylistProps {
|
|||||||
variant?: 'normal' | 'maximized'
|
variant?: 'normal' | 'maximized'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Playlist({
|
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)}
|
||||||
>
|
>
|
||||||
@@ -51,16 +51,18 @@ export function Playlist({
|
|||||||
<span className="text-muted-foreground">{index + 1}</span>
|
<span className="text-muted-foreground">{index + 1}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -69,18 +71,20 @@ export function Playlist({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|
||||||
{/* Duration - 2 columns */}
|
{/* Duration - 2 columns */}
|
||||||
<div className="col-span-2 text-right">
|
<div className="col-span-2 text-right">
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||||
@@ -101,4 +105,4 @@ export function Playlist({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
||||||
@@ -44,4 +44,4 @@ export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -76,4 +87,4 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -28,9 +43,9 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
|||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
// 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,10 +107,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
|||||||
if (!user || !socket) return
|
if (!user || !socket) return
|
||||||
|
|
||||||
setIsReconnecting(true)
|
setIsReconnecting(true)
|
||||||
|
|
||||||
// Disconnect current socket
|
// Disconnect current socket
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
|
|
||||||
// Create new socket with fresh token
|
// Create new socket with fresh token
|
||||||
const newSocket = createSocket()
|
const newSocket = createSocket()
|
||||||
if (newSocket) {
|
if (newSocket) {
|
||||||
@@ -106,13 +121,12 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
|||||||
// Listen for token refresh events
|
// Listen for token refresh events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
||||||
}
|
}
|
||||||
}, [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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,4 +170,4 @@ export function useSocket() {
|
|||||||
throw new Error('useSocket must be used within a SocketProvider')
|
throw new Error('useSocket must be used within a SocketProvider')
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ const initialState: ThemeProviderState = {
|
|||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
export const ThemeProviderContext =
|
||||||
export type { Theme, ThemeProviderState }
|
createContext<ThemeProviderState>(initialState)
|
||||||
|
export type { Theme, ThemeProviderState }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
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)
|
||||||
@@ -8,4 +8,4 @@ export const useTheme = () => {
|
|||||||
throw new Error('useTheme must be used within a ThemeProvider')
|
throw new Error('useTheme must be used within a ThemeProvider')
|
||||||
|
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 *));
|
||||||
|
|
||||||
@@ -117,4 +117,4 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
export * from './api/index'
|
export * from './api/index'
|
||||||
|
|
||||||
// Export the main API object as default
|
// Export the main API object as default
|
||||||
export { default as api } from './api/index'
|
export { default as api } from './api/index'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { AUTH_EVENTS, authEvents } from '../events'
|
||||||
import { API_CONFIG } from './config'
|
import { 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,9 +11,12 @@ 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) {
|
||||||
// Full base URL provided
|
// Full base URL provided
|
||||||
url = new URL(endpoint, this.baseURL)
|
url = new URL(endpoint, this.baseURL)
|
||||||
@@ -21,7 +24,7 @@ export class BaseApiClient implements ApiClient {
|
|||||||
// Use relative URL (for reverse proxy)
|
// Use relative URL (for reverse proxy)
|
||||||
url = new URL(endpoint, window.location.origin)
|
url = new URL(endpoint, window.location.origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
@@ -29,7 +32,7 @@ export class BaseApiClient implements ApiClient {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.baseURL ? url.toString() : url.pathname + url.search
|
return this.baseURL ? url.toString() : url.pathname + url.search
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
@@ -84,40 +87,43 @@ export class BaseApiClient implements ApiClient {
|
|||||||
await this.handleTokenRefresh()
|
await this.handleTokenRefresh()
|
||||||
// Retry the original request
|
// Retry the original request
|
||||||
const retryResponse = await fetch(url, requestConfig)
|
const retryResponse = await fetch(url, requestConfig)
|
||||||
|
|
||||||
if (!retryResponse.ok) {
|
if (!retryResponse.ok) {
|
||||||
const errorData = await this.safeParseJSON(retryResponse)
|
const errorData = await this.safeParseJSON(retryResponse)
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorData = await this.safeParseJSON(response)
|
const errorData = await this.safeParseJSON(response)
|
||||||
throw createApiError(response, errorData)
|
throw createApiError(response, errorData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
if ((error as Error).name === 'AbortError') {
|
if ((error as Error).name === 'AbortError') {
|
||||||
throw new TimeoutError()
|
throw new TimeoutError()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
throw new NetworkError()
|
throw new NetworkError()
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +144,7 @@ export class BaseApiClient implements ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.refreshPromise = this.performTokenRefresh()
|
this.refreshPromise = this.performTokenRefresh()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.refreshPromise
|
await this.refreshPromise
|
||||||
} finally {
|
} finally {
|
||||||
@@ -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))
|
||||||
@@ -165,7 +174,7 @@ export class BaseApiClient implements ApiClient {
|
|||||||
// Only redirect if we're not already on auth pages to prevent infinite loops
|
// Only redirect if we're not already on auth pages to prevent infinite loops
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const authPaths = ['/login', '/register', '/auth/callback']
|
const authPaths = ['/login', '/register', '/auth/callback']
|
||||||
|
|
||||||
if (!authPaths.includes(currentPath)) {
|
if (!authPaths.includes(currentPath)) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,4 +224,4 @@ export class BaseApiClient implements ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default API client instance
|
// Default API client instance
|
||||||
export const apiClient = new BaseApiClient()
|
export const apiClient = new BaseApiClient()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const API_CONFIG = {
|
|||||||
BASE_URL: getApiBaseUrl(),
|
BASE_URL: getApiBaseUrl(),
|
||||||
TIMEOUT: 30000, // 30 seconds
|
TIMEOUT: 30000, // 30 seconds
|
||||||
RETRY_ATTEMPTS: 1,
|
RETRY_ATTEMPTS: 1,
|
||||||
|
|
||||||
// API Endpoints
|
// API Endpoints
|
||||||
ENDPOINTS: {
|
ENDPOINTS: {
|
||||||
AUTH: {
|
AUTH: {
|
||||||
@@ -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',
|
||||||
@@ -35,4 +36,4 @@ export const API_CONFIG = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
|
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ export class ApiError extends Error {
|
|||||||
public response?: unknown
|
public 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:
|
||||||
@@ -95,4 +114,4 @@ export function createApiError(response: Response, data?: unknown): ApiError {
|
|||||||
default:
|
default:
|
||||||
return new ApiError(message, status, data, errorData?.detail as string)
|
return new ApiError(message, status, data, errorData?.detail as string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Main API object for convenient access
|
||||||
|
import { apiClient } from './client'
|
||||||
|
import { authService } from './services/auth'
|
||||||
|
|
||||||
// Re-export all API services and utilities
|
// Re-export all API services and utilities
|
||||||
export * from './client'
|
export * from './client'
|
||||||
export * from './config'
|
export * from './config'
|
||||||
@@ -7,14 +11,10 @@ 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,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Default export for convenience
|
// Default export for convenience
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from '../client'
|
|
||||||
import type { User } from '@/types/auth'
|
import 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,27 +89,38 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminService = new AdminService()
|
export const adminService = new AdminService()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,4 +245,4 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = new AuthService()
|
export const authService = new AuthService()
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export class ExtractionsService {
|
|||||||
* Create a new extraction job
|
* 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,9 +48,11 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extractionsService = new ExtractionsService()
|
export const extractionsService = new ExtractionsService()
|
||||||
|
|||||||
@@ -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}`)
|
||||||
@@ -19,7 +22,7 @@ export class FilesService {
|
|||||||
// Get filename from Content-Disposition header or use default
|
// Get filename from Content-Disposition header or use default
|
||||||
const contentDisposition = response.headers.get('Content-Disposition')
|
const contentDisposition = response.headers.get('Content-Disposition')
|
||||||
let filename = `sound_${soundId}.mp3`
|
let filename = `sound_${soundId}.mp3`
|
||||||
|
|
||||||
if (contentDisposition) {
|
if (contentDisposition) {
|
||||||
const filenameMatch = contentDisposition.match(/filename="(.+)"/)
|
const filenameMatch = contentDisposition.match(/filename="(.+)"/)
|
||||||
if (filenameMatch) {
|
if (filenameMatch) {
|
||||||
@@ -30,14 +33,14 @@ export class FilesService {
|
|||||||
// Create blob and download
|
// Create blob and download
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
|
||||||
// Create temporary download link
|
// Create temporary download link
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = filename
|
link.download = filename
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
@@ -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)
|
||||||
@@ -82,4 +88,4 @@ export class FilesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesService = new FilesService()
|
export const filesService = new FilesService()
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export * from './auth'
|
|||||||
export * from './sounds'
|
export * from './sounds'
|
||||||
export * from './player'
|
export * from './player'
|
||||||
export * from './files'
|
export * from './files'
|
||||||
export * from './extractions'
|
export * from './extractions'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -144,4 +149,4 @@ export class PlayerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const playerService = new PlayerService()
|
export const playerService = new PlayerService()
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { apiClient } from '../client'
|
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 {
|
||||||
@@ -47,7 +53,7 @@ export class PlaylistsService {
|
|||||||
*/
|
*/
|
||||||
async getPlaylists(params?: GetPlaylistsParams): Promise<Playlist[]> {
|
async getPlaylists(params?: GetPlaylistsParams): Promise<Playlist[]> {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
// Handle parameters
|
// Handle parameters
|
||||||
if (params?.search) {
|
if (params?.search) {
|
||||||
searchParams.append('search', params.search)
|
searchParams.append('search', params.search)
|
||||||
@@ -64,8 +70,10 @@ export class PlaylistsService {
|
|||||||
if (params?.offset) {
|
if (params?.offset) {
|
||||||
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,28 +165,38 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const playlistsService = new PlaylistsService()
|
export const playlistsService = new PlaylistsService()
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -43,14 +51,14 @@ export class SoundsService {
|
|||||||
*/
|
*/
|
||||||
async getSounds(params?: GetSoundsParams): Promise<Sound[]> {
|
async getSounds(params?: GetSoundsParams): Promise<Sound[]> {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
// Handle multiple types
|
// Handle multiple types
|
||||||
if (params?.types) {
|
if (params?.types) {
|
||||||
params.types.forEach(type => {
|
params.types.forEach(type => {
|
||||||
searchParams.append('types', type)
|
searchParams.append('types', type)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle other parameters
|
// Handle other parameters
|
||||||
if (params?.search) {
|
if (params?.search) {
|
||||||
searchParams.append('search', params.search)
|
searchParams.append('search', params.search)
|
||||||
@@ -67,8 +75,10 @@ export class SoundsService {
|
|||||||
if (params?.offset) {
|
if (params?.offset) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,4 +117,4 @@ export class SoundsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const soundsService = new SoundsService()
|
export const soundsService = new SoundsService()
|
||||||
|
|||||||
@@ -20,15 +20,26 @@ export interface ApiRequestConfig extends RequestInit {
|
|||||||
timeout?: number
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,4 @@ export const SOUND_EVENTS = {
|
|||||||
// User event types
|
// User event types
|
||||||
export const USER_EVENTS = {
|
export const USER_EVENTS = {
|
||||||
USER_CREDITS_CHANGED: 'user_credits_changed',
|
USER_CREDITS_CHANGED: 'user_credits_changed',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,10 +21,10 @@ export class TokenRefreshManager {
|
|||||||
|
|
||||||
this.isEnabled = true
|
this.isEnabled = true
|
||||||
this.scheduleNextRefresh()
|
this.scheduleNextRefresh()
|
||||||
|
|
||||||
// Listen for visibility changes to handle tab switching
|
// Listen for visibility changes to handle tab switching
|
||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||||
|
|
||||||
// Listen for successful auth events to reschedule
|
// Listen for successful auth events to reschedule
|
||||||
authEvents.on(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
|
authEvents.on(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
|
||||||
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
|
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
|
||||||
@@ -41,8 +40,11 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -73,10 +75,9 @@ export class TokenRefreshManager {
|
|||||||
await api.auth.refreshToken()
|
await api.auth.refreshToken()
|
||||||
|
|
||||||
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
|
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
|
||||||
|
|
||||||
// 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
|
||||||
*/
|
*/
|
||||||
@@ -105,7 +105,7 @@ export class TokenRefreshManager {
|
|||||||
try {
|
try {
|
||||||
// Try to make an API call to see if token is still valid
|
// Try to make an API call to see if token is still valid
|
||||||
await api.auth.getMe()
|
await api.auth.getMe()
|
||||||
|
|
||||||
// Token is still valid, reschedule based on remaining time
|
// Token is still valid, reschedule based on remaining time
|
||||||
this.scheduleNextRefresh()
|
this.scheduleNextRefresh()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -146,4 +146,4 @@ export class TokenRefreshManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Global token refresh manager instance
|
// Global token refresh manager instance
|
||||||
export const tokenRefreshManager = new TokenRefreshManager()
|
export const tokenRefreshManager = new TokenRefreshManager()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { 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,56 +1,72 @@
|
|||||||
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
|
||||||
User,
|
|
||||||
Key,
|
|
||||||
Shield,
|
|
||||||
Palette,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Copy,
|
|
||||||
Trash2,
|
|
||||||
Github,
|
|
||||||
Mail,
|
|
||||||
CheckCircle2
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { 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()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
// Profile state
|
// Profile state
|
||||||
const [profileName, setProfileName] = useState('')
|
const [profileName, setProfileName] = useState('')
|
||||||
const [profileSaving, setProfileSaving] = useState(false)
|
const [profileSaving, setProfileSaving] = useState(false)
|
||||||
|
|
||||||
// Password state
|
// Password state
|
||||||
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)
|
||||||
const [tokenExpireDays, setTokenExpireDays] = useState('365')
|
const [tokenExpireDays, setTokenExpireDays] = useState('365')
|
||||||
|
|
||||||
// Providers state
|
// Providers state
|
||||||
const [providers, setProviders] = useState<UserProvider[]>([])
|
const [providers, setProviders] = useState<UserProvider[]>([])
|
||||||
const [providersLoading, setProvidersLoading] = useState(true)
|
const [providersLoading, setProvidersLoading] = useState(true)
|
||||||
@@ -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,14 +122,16 @@ 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) {
|
||||||
toast.error('Current password is required')
|
toast.error('Current password is required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordData.new_password) {
|
if (!passwordData.new_password) {
|
||||||
toast.error('New password is required')
|
toast.error('New password is required')
|
||||||
return
|
return
|
||||||
@@ -130,12 +150,22 @@ 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()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,8 +178,8 @@ 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)
|
||||||
@@ -192,12 +222,9 @@ export function AccountPage() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
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">
|
||||||
@@ -223,12 +250,9 @@ 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,15 +296,34 @@ 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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleProfileSave}
|
onClick={handleProfileSave}
|
||||||
disabled={profileSaving || profileName === user.name}
|
disabled={profileSaving || profileName === user.name}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
@@ -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,22 +448,31 @@ 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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePasswordChange}
|
onClick={handlePasswordChange}
|
||||||
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,13 +523,18 @@ 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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePasswordChange}
|
onClick={handlePasswordChange}
|
||||||
disabled={passwordSaving}
|
disabled={passwordSaving}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
@@ -487,11 +568,15 @@ 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>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDeleteApiToken}
|
onClick={handleDeleteApiToken}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -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,30 +652,41 @@ 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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* API Token Provider */}
|
{/* API Token Provider */}
|
||||||
{apiTokenStatus?.has_token && (
|
{apiTokenStatus?.has_token && (
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
@@ -589,11 +696,17 @@ export function AccountPage() {
|
|||||||
<Badge variant="secondary">API Access</Badge>
|
<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>
|
||||||
@@ -649,4 +765,4 @@ export function AccountPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -15,7 +17,7 @@ export function AuthCallbackPage() {
|
|||||||
// Get the code from URL parameters
|
// Get the code from URL parameters
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const code = urlParams.get('code')
|
const code = urlParams.get('code')
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error('No authorization code received')
|
throw new Error('No authorization code received')
|
||||||
}
|
}
|
||||||
@@ -25,22 +27,23 @@ export function AuthCallbackPage() {
|
|||||||
|
|
||||||
// Now get the user info
|
// Now get the user info
|
||||||
const user = await api.auth.getMe()
|
const user = await api.auth.getMe()
|
||||||
|
|
||||||
// Update auth context
|
// Update auth context
|
||||||
if (setUser) setUser(user)
|
if (setUser) setUser(user)
|
||||||
|
|
||||||
setStatus('success')
|
setStatus('success')
|
||||||
|
|
||||||
// Redirect to dashboard after a short delay
|
// Redirect to dashboard after a short delay
|
||||||
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
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
@@ -57,28 +60,40 @@ 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>
|
||||||
</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,11 +47,13 @@ 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)
|
||||||
|
|
||||||
// Top sounds state
|
// Top sounds state
|
||||||
const [topSounds, setTopSounds] = useState<TopSound[]>([])
|
const [topSounds, setTopSounds] = useState<TopSound[]>([])
|
||||||
const [topSoundsLoading, setTopSoundsLoading] = useState(false)
|
const [topSoundsLoading, setTopSoundsLoading] = useState(false)
|
||||||
@@ -48,19 +65,21 @@ 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) {
|
||||||
throw new Error('Failed to fetch statistics')
|
throw new Error('Failed to fetch statistics')
|
||||||
}
|
}
|
||||||
|
|
||||||
const [soundboardData, trackData] = await Promise.all([
|
const [soundboardData, trackData] = await Promise.all([
|
||||||
soundboardResponse.json(),
|
soundboardResponse.json(),
|
||||||
trackResponse.json()
|
trackResponse.json(),
|
||||||
])
|
])
|
||||||
|
|
||||||
setSoundboardStatistics(soundboardData)
|
setSoundboardStatistics(soundboardData)
|
||||||
setTrackStatistics(trackData)
|
setTrackStatistics(trackData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -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(
|
|
||||||
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
|
const response = await fetch(
|
||||||
{ credentials: 'include' }
|
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
|
||||||
)
|
{ credentials: 'include' },
|
||||||
|
)
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch top sounds')
|
if (!response.ok) {
|
||||||
}
|
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
|
|
||||||
setTopSounds(prevTopSounds => {
|
// Graceful update: merge new data while preserving animations
|
||||||
// Create a map of existing sounds for efficient lookup
|
setTopSounds(prevTopSounds => {
|
||||||
const existingSoundsMap = new Map(prevTopSounds.map(sound => [sound.id, sound]))
|
// Create a map of existing sounds for efficient lookup
|
||||||
|
const existingSoundsMap = new Map(
|
||||||
// Update existing sounds and add new ones
|
prevTopSounds.map(sound => [sound.id, sound]),
|
||||||
return data.map((newSound: TopSound) => {
|
)
|
||||||
const existingSound = existingSoundsMap.get(newSound.id)
|
|
||||||
if (existingSound) {
|
// Update existing sounds and add new ones
|
||||||
// Preserve object reference if data hasn't changed to avoid re-renders
|
return data.map((newSound: TopSound) => {
|
||||||
if (
|
const existingSound = existingSoundsMap.get(newSound.id)
|
||||||
existingSound.name === newSound.name &&
|
if (existingSound) {
|
||||||
existingSound.type === newSound.type &&
|
// Preserve object reference if data hasn't changed to avoid re-renders
|
||||||
existingSound.play_count === newSound.play_count &&
|
if (
|
||||||
existingSound.duration === newSound.duration
|
existingSound.name === newSound.name &&
|
||||||
) {
|
existingSound.type === newSound.type &&
|
||||||
return existingSound
|
existingSound.play_count === newSound.play_count &&
|
||||||
|
existingSound.duration === newSound.duration
|
||||||
|
) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -149,18 +170,16 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [refreshAll])
|
}, [refreshAll])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTopSounds(true) // Show loading on initial load and filter changes
|
fetchTopSounds(true) // Show loading on initial load and filter changes
|
||||||
}, [fetchTopSounds])
|
}, [fetchTopSounds])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
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>
|
||||||
))}
|
))}
|
||||||
@@ -211,11 +242,9 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
if (error || !soundboardStatistics || !trackStatistics) {
|
if (error || !soundboardStatistics || !trackStatistics) {
|
||||||
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>
|
||||||
@@ -236,11 +267,9 @@ 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">
|
||||||
@@ -251,28 +280,36 @@ export function DashboardPage() {
|
|||||||
Overview of your soundboard and track statistics
|
Overview of your soundboard and track statistics
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={refreshAll}
|
onClick={refreshAll}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
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
|
||||||
@@ -385,7 +456,7 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Sounds Section */}
|
{/* Top Sounds Section */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
@@ -491,4 +577,4 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +88,53 @@ 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getServiceBadge = (service: string | undefined) => {
|
const getServiceBadge = (service: string | undefined) => {
|
||||||
if (!service) return null
|
if (!service) return null
|
||||||
|
|
||||||
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}>
|
||||||
@@ -95,12 +144,9 @@ 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">
|
||||||
@@ -111,7 +157,7 @@ export function ExtractionsPage() {
|
|||||||
Extract audio from YouTube, SoundCloud, and other platforms
|
Extract audio from YouTube, SoundCloud, and other platforms
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2">
|
<Button className="gap-2">
|
||||||
@@ -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>
|
||||||
@@ -262,4 +332,4 @@ export function ExtractionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
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 (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/register"
|
||||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||||
>
|
>
|
||||||
Sign up
|
Sign up
|
||||||
@@ -21,4 +21,4 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,88 @@
|
|||||||
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
type Playlist,
|
||||||
|
type PlaylistSortField,
|
||||||
|
type SortOrder,
|
||||||
|
playlistsService,
|
||||||
|
} from '@/lib/api/services/playlists'
|
||||||
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Edit,
|
||||||
|
Music,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
SortAsc,
|
||||||
|
SortDesc,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { 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()
|
||||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Search and sorting state
|
// Search and sorting state
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
|
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
|
|
||||||
// Create playlist dialog state
|
// Create playlist dialog state
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
const [newPlaylist, setNewPlaylist] = useState({
|
const [newPlaylist, setNewPlaylist] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
genre: ''
|
genre: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Debounce search query
|
// Debounce search query
|
||||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
setDebouncedSearchQuery(searchQuery)
|
setDebouncedSearchQuery(searchQuery)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
return () => clearTimeout(handler)
|
return () => clearTimeout(handler)
|
||||||
}, [searchQuery])
|
}, [searchQuery])
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -82,17 +123,18 @@ export function PlaylistsPage() {
|
|||||||
description: newPlaylist.description.trim() || undefined,
|
description: newPlaylist.description.trim() || undefined,
|
||||||
genre: newPlaylist.genre.trim() || undefined,
|
genre: newPlaylist.genre.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success(`Playlist "${newPlaylist.name}" created successfully`)
|
toast.success(`Playlist "${newPlaylist.name}" created successfully`)
|
||||||
|
|
||||||
// Reset form and close dialog
|
// Reset form and close dialog
|
||||||
setNewPlaylist({ name: '', description: '', genre: '' })
|
setNewPlaylist({ name: '', description: '', genre: '' })
|
||||||
setShowCreateDialog(false)
|
setShowCreateDialog(false)
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -108,11 +150,12 @@ export function PlaylistsPage() {
|
|||||||
try {
|
try {
|
||||||
await playlistsService.setCurrentPlaylist(playlist.id)
|
await playlistsService.setCurrentPlaylist(playlist.id)
|
||||||
toast.success(`"${playlist.name}" is now the current playlist`)
|
toast.success(`"${playlist.name}" is now the current playlist`)
|
||||||
|
|
||||||
// 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,10 +180,12 @@ 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}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
@@ -275,12 +322,9 @@ 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>
|
||||||
@@ -359,7 +426,7 @@ export function PlaylistsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Sort Controls */}
|
{/* Search and Sort Controls */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -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 && (
|
||||||
@@ -384,9 +451,12 @@ export function PlaylistsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -399,16 +469,20 @@ export function PlaylistsPage() {
|
|||||||
<SelectItem value="updated_at">Updated Date</SelectItem>
|
<SelectItem value="updated_at">Updated Date</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
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
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -416,13 +490,15 @@ 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>
|
||||||
|
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
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 (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
<RegisterForm />
|
<RegisterForm />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
@@ -21,4 +21,4 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -54,7 +72,7 @@ export function SoundsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [currentColors, setCurrentColors] = useState<string[]>(lightModeColors)
|
const [currentColors, setCurrentColors] = useState<string[]>(lightModeColors)
|
||||||
|
|
||||||
// Search and sorting state
|
// Search and sorting state
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
||||||
@@ -65,10 +83,12 @@ 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'}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,7 +98,7 @@ export function SoundsPage() {
|
|||||||
setCurrentColors(lightModeColors)
|
setCurrentColors(lightModeColors)
|
||||||
}
|
}
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
const getSoundColor = (soundIdx: number) => {
|
const getSoundColor = (soundIdx: number) => {
|
||||||
const index = soundIdx % currentColors.length
|
const index = soundIdx % currentColors.length
|
||||||
return currentColors[index]
|
return currentColors[index]
|
||||||
@@ -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 {
|
||||||
@@ -105,12 +126,12 @@ export function SoundsPage() {
|
|||||||
|
|
||||||
// Debounce search query
|
// Debounce search query
|
||||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
setDebouncedSearchQuery(searchQuery)
|
setDebouncedSearchQuery(searchQuery)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
return () => clearTimeout(handler)
|
return () => clearTimeout(handler)
|
||||||
}, [searchQuery])
|
}, [searchQuery])
|
||||||
|
|
||||||
@@ -121,12 +142,12 @@ export function SoundsPage() {
|
|||||||
// Listen for sound_played events and update play_count
|
// Listen for sound_played events and update play_count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSoundPlayed = (eventData: SoundPlayedEventData) => {
|
const handleSoundPlayed = (eventData: SoundPlayedEventData) => {
|
||||||
setSounds(prevSounds =>
|
setSounds(prevSounds =>
|
||||||
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,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +177,8 @@ export function SoundsPage() {
|
|||||||
<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 sounds</h3>
|
<h3 className="text-lg font-semibold mb-2">Failed to load sounds</h3>
|
||||||
<p className="text-muted-foreground mb-4">{error}</p>
|
<p className="text-muted-foreground mb-4">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
@@ -183,19 +204,21 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
@@ -212,7 +235,7 @@ export function SoundsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Sort Controls */}
|
{/* Search and Sort Controls */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -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 && (
|
||||||
@@ -237,9 +260,12 @@ export function SoundsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -252,16 +278,20 @@ export function SoundsPage() {
|
|||||||
<SelectItem value="updated_at">Updated Date</SelectItem>
|
<SelectItem value="updated_at">Updated Date</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
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
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -269,13 +299,15 @@ 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>
|
||||||
|
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
import { toast } from 'sonner'
|
Select,
|
||||||
import {
|
SelectContent,
|
||||||
Scan,
|
SelectItem,
|
||||||
Volume2,
|
SelectTrigger,
|
||||||
Settings as SettingsIcon,
|
SelectValue,
|
||||||
Loader2,
|
} 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)
|
||||||
@@ -48,22 +63,24 @@ export function SettingsPage() {
|
|||||||
setNormalizationInProgress(true)
|
setNormalizationInProgress(true)
|
||||||
try {
|
try {
|
||||||
let response: NormalizationResponse
|
let response: NormalizationResponse
|
||||||
|
|
||||||
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)
|
||||||
@@ -73,13 +90,13 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
breadcrumb={{
|
breadcrumb={{
|
||||||
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,11 +121,12 @@ 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
|
||||||
onClick={handleScanSounds}
|
onClick={handleScanSounds}
|
||||||
disabled={scanningInProgress}
|
disabled={scanningInProgress}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
@@ -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,16 +172,20 @@ 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">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Sound Type</Label>
|
<Label>Sound Type</Label>
|
||||||
<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>
|
||||||
@@ -177,11 +201,14 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<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">
|
||||||
@@ -190,11 +217,14 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<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">
|
||||||
@@ -203,8 +233,8 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNormalizeSounds}
|
onClick={handleNormalizeSounds}
|
||||||
disabled={normalizationInProgress}
|
disabled={normalizationInProgress}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
@@ -248,4 +294,4 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -117,13 +135,13 @@ export function UsersPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
breadcrumb={{
|
breadcrumb={{
|
||||||
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">
|
||||||
@@ -152,13 +170,13 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
breadcrumb={{
|
breadcrumb={{
|
||||||
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,39 +249,62 @@ 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">
|
||||||
<h2 className="text-xl font-semibold">Edit User</h2>
|
<h2 className="text-xl font-semibold">Edit User</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className="space-y-8 pb-6">
|
<div className="space-y-8 pb-6">
|
||||||
{/* User Information Section */}
|
{/* User Information Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-base">User Information</h3>
|
<h3 className="font-semibold text-base">User Information</h3>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -375,4 +458,4 @@ export function UsersPage() {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ export interface AuthContextType {
|
|||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
loading: boolean
|
loading: boolean
|
||||||
setUser?: (user: User | null) => void
|
setUser?: (user: User | null) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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