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:
@@ -1,56 +1,72 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
User,
|
||||
Key,
|
||||
Shield,
|
||||
Palette,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Trash2,
|
||||
Github,
|
||||
Mail,
|
||||
CheckCircle2
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
import { authService, type ApiTokenStatusResponse, type UserProvider } from '@/lib/api/services/auth'
|
||||
import {
|
||||
type ApiTokenStatusResponse,
|
||||
type UserProvider,
|
||||
authService,
|
||||
} from '@/lib/api/services/auth'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
Key,
|
||||
Mail,
|
||||
Palette,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function AccountPage() {
|
||||
const { user, setUser } = useAuth()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
|
||||
// Profile state
|
||||
const [profileName, setProfileName] = useState('')
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
|
||||
|
||||
// Password state
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
confirm_password: '',
|
||||
})
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
|
||||
|
||||
// API Token state
|
||||
const [apiTokenStatus, setApiTokenStatus] = useState<ApiTokenStatusResponse | null>(null)
|
||||
const [apiTokenStatus, setApiTokenStatus] =
|
||||
useState<ApiTokenStatusResponse | null>(null)
|
||||
const [apiTokenLoading, setApiTokenLoading] = useState(true)
|
||||
const [generatedToken, setGeneratedToken] = useState('')
|
||||
const [showGeneratedToken, setShowGeneratedToken] = useState(false)
|
||||
const [tokenExpireDays, setTokenExpireDays] = useState('365')
|
||||
|
||||
|
||||
// Providers state
|
||||
const [providers, setProviders] = useState<UserProvider[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
@@ -91,7 +107,9 @@ export function AccountPage() {
|
||||
|
||||
setProfileSaving(true)
|
||||
try {
|
||||
const updatedUser = await authService.updateProfile({ name: profileName.trim() })
|
||||
const updatedUser = await authService.updateProfile({
|
||||
name: profileName.trim(),
|
||||
})
|
||||
setUser?.(updatedUser)
|
||||
toast.success('Profile updated successfully')
|
||||
} catch (error) {
|
||||
@@ -104,14 +122,16 @@ export function AccountPage() {
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
// Check if user has password authentication from providers
|
||||
const hasPasswordProvider = providers.some(provider => provider.provider === 'password')
|
||||
|
||||
const hasPasswordProvider = providers.some(
|
||||
provider => provider.provider === 'password',
|
||||
)
|
||||
|
||||
// Validate required fields
|
||||
if (hasPasswordProvider && !passwordData.current_password) {
|
||||
toast.error('Current password is required')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!passwordData.new_password) {
|
||||
toast.error('New password is required')
|
||||
return
|
||||
@@ -130,12 +150,22 @@ export function AccountPage() {
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
await authService.changePassword({
|
||||
current_password: hasPasswordProvider ? passwordData.current_password : undefined,
|
||||
new_password: passwordData.new_password
|
||||
current_password: hasPasswordProvider
|
||||
? passwordData.current_password
|
||||
: undefined,
|
||||
new_password: passwordData.new_password,
|
||||
})
|
||||
setPasswordData({ current_password: '', new_password: '', confirm_password: '' })
|
||||
toast.success(hasPasswordProvider ? 'Password changed successfully' : 'Password set successfully')
|
||||
|
||||
setPasswordData({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
toast.success(
|
||||
hasPasswordProvider
|
||||
? 'Password changed successfully'
|
||||
: 'Password set successfully',
|
||||
)
|
||||
|
||||
// Reload providers since password status might have changed
|
||||
loadProviders()
|
||||
} catch (error) {
|
||||
@@ -148,8 +178,8 @@ export function AccountPage() {
|
||||
|
||||
const handleGenerateApiToken = async () => {
|
||||
try {
|
||||
const response = await authService.generateApiToken({
|
||||
expires_days: parseInt(tokenExpireDays)
|
||||
const response = await authService.generateApiToken({
|
||||
expires_days: parseInt(tokenExpireDays),
|
||||
})
|
||||
setGeneratedToken(response.api_token)
|
||||
setShowGeneratedToken(true)
|
||||
@@ -192,12 +222,9 @@ export function AccountPage() {
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Account' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -223,12 +250,9 @@ export function AccountPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Account' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-6">
|
||||
@@ -264,7 +288,7 @@ export function AccountPage() {
|
||||
<Input
|
||||
id="name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
onChange={e => setProfileName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
/>
|
||||
</div>
|
||||
@@ -272,15 +296,34 @@ export function AccountPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Account Details</Label>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>Role: <Badge variant={user.role === 'admin' ? 'destructive' : 'secondary'}>{user.role}</Badge></div>
|
||||
<div>Credits: <span className="font-medium">{user.credits.toLocaleString()}</span></div>
|
||||
<div>Plan: <span className="font-medium">{user.plan.name}</span></div>
|
||||
<div>Member since: {new Date(user.created_at).toLocaleDateString()}</div>
|
||||
<div>
|
||||
Role:{' '}
|
||||
<Badge
|
||||
variant={
|
||||
user.role === 'admin' ? 'destructive' : 'secondary'
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
Credits:{' '}
|
||||
<span className="font-medium">
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Plan: <span className="font-medium">{user.plan.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
Member since:{' '}
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleProfileSave}
|
||||
<Button
|
||||
onClick={handleProfileSave}
|
||||
disabled={profileSaving || profileName === user.name}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -300,7 +343,12 @@ export function AccountPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Theme Preference</Label>
|
||||
<Select value={theme} onValueChange={(value: 'light' | 'dark' | 'system') => setTheme(value)}>
|
||||
<Select
|
||||
value={theme}
|
||||
onValueChange={(value: 'light' | 'dark' | 'system') =>
|
||||
setTheme(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -317,7 +365,8 @@ export function AccountPage() {
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Current theme: <span className="font-medium capitalize">{theme}</span>
|
||||
Current theme:{' '}
|
||||
<span className="font-medium capitalize">{theme}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -341,7 +390,12 @@ export function AccountPage() {
|
||||
id="current-password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={passwordData.current_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, current_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
current_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
<Button
|
||||
@@ -349,7 +403,9 @@ export function AccountPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
onClick={() =>
|
||||
setShowCurrentPassword(!showCurrentPassword)
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -367,7 +423,12 @@ export function AccountPage() {
|
||||
id="new-password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={passwordData.new_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
new_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<Button
|
||||
@@ -387,22 +448,31 @@ export function AccountPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Label htmlFor="confirm-password">
|
||||
Confirm New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={passwordData.confirm_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
confirm_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordSaving}
|
||||
className="w-full"
|
||||
>
|
||||
{passwordSaving ? 'Changing Password...' : 'Change Password'}
|
||||
{passwordSaving
|
||||
? 'Changing Password...'
|
||||
: 'Change Password'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -411,7 +481,8 @@ export function AccountPage() {
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 <strong>Set up password authentication</strong>
|
||||
<br />
|
||||
You signed up with OAuth and don't have a password yet. Set one now to enable password login.
|
||||
You signed up with OAuth and don't have a password yet.
|
||||
Set one now to enable password login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -422,7 +493,12 @@ export function AccountPage() {
|
||||
id="new-password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={passwordData.new_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
new_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
<Button
|
||||
@@ -447,13 +523,18 @@ export function AccountPage() {
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={passwordData.confirm_password}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))}
|
||||
onChange={e =>
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
confirm_password: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordSaving}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -487,11 +568,15 @@ export function AccountPage() {
|
||||
<span>API Token Active</span>
|
||||
{apiTokenStatus.expires_at && (
|
||||
<span className="text-muted-foreground">
|
||||
(Expires: {new Date(apiTokenStatus.expires_at).toLocaleDateString()})
|
||||
(Expires:{' '}
|
||||
{new Date(
|
||||
apiTokenStatus.expires_at,
|
||||
).toLocaleDateString()}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleDeleteApiToken}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -505,7 +590,10 @@ export function AccountPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expire-days">Token Expiration</Label>
|
||||
<Select value={tokenExpireDays} onValueChange={setTokenExpireDays}>
|
||||
<Select
|
||||
value={tokenExpireDays}
|
||||
onValueChange={setTokenExpireDays}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -517,7 +605,10 @@ export function AccountPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleGenerateApiToken} className="w-full">
|
||||
<Button
|
||||
onClick={handleGenerateApiToken}
|
||||
className="w-full"
|
||||
>
|
||||
Generate API Token
|
||||
</Button>
|
||||
</div>
|
||||
@@ -526,7 +617,8 @@ export function AccountPage() {
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
API tokens allow external applications to access your account programmatically
|
||||
API tokens allow external applications to access your account
|
||||
programmatically
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -540,14 +632,18 @@ export function AccountPage() {
|
||||
Authentication Methods
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Available methods to sign in to your account. Use any of these to access your account.
|
||||
Available methods to sign in to your account. Use any of these to
|
||||
access your account.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{providersLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
@@ -556,30 +652,41 @@ export function AccountPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* All Authentication Providers from API */}
|
||||
{providers.map((provider) => {
|
||||
{providers.map(provider => {
|
||||
const isOAuth = provider.provider !== 'password'
|
||||
|
||||
|
||||
return (
|
||||
<div key={provider.provider} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div
|
||||
key={provider.provider}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getProviderIcon(provider.provider)}
|
||||
<span className="font-medium">{provider.display_name}</span>
|
||||
<span className="font-medium">
|
||||
{provider.display_name}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{isOAuth ? 'OAuth' : 'Password Authentication'}
|
||||
</Badge>
|
||||
{provider.connected_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connected {new Date(provider.connected_at).toLocaleDateString()}
|
||||
Connected{' '}
|
||||
{new Date(
|
||||
provider.connected_at,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20"
|
||||
>
|
||||
Available
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
{/* API Token Provider */}
|
||||
{apiTokenStatus?.has_token && (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
@@ -589,11 +696,17 @@ export function AccountPage() {
|
||||
<Badge variant="secondary">API Access</Badge>
|
||||
{apiTokenStatus.expires_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires {new Date(apiTokenStatus.expires_at).toLocaleDateString()}
|
||||
Expires{' '}
|
||||
{new Date(
|
||||
apiTokenStatus.expires_at,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20"
|
||||
>
|
||||
Available
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -637,11 +750,14 @@ export function AccountPage() {
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ <strong>Important:</strong> This token will only be shown once.
|
||||
Copy it now and store it securely.
|
||||
⚠️ <strong>Important:</strong> This token will only be shown
|
||||
once. Copy it now and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowGeneratedToken(false)} className="w-full">
|
||||
<Button
|
||||
onClick={() => setShowGeneratedToken(false)}
|
||||
className="w-full"
|
||||
>
|
||||
I've Saved My Token
|
||||
</Button>
|
||||
</div>
|
||||
@@ -649,4 +765,4 @@ export function AccountPage() {
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { api } from '@/lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export function AuthCallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setUser } = useAuth()
|
||||
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
|
||||
const [status, setStatus] = useState<'processing' | 'success' | 'error'>(
|
||||
'processing',
|
||||
)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -15,7 +17,7 @@ export function AuthCallbackPage() {
|
||||
// Get the code from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received')
|
||||
}
|
||||
@@ -25,22 +27,23 @@ export function AuthCallbackPage() {
|
||||
|
||||
// Now get the user info
|
||||
const user = await api.auth.getMe()
|
||||
|
||||
|
||||
// Update auth context
|
||||
if (setUser) setUser(user)
|
||||
|
||||
|
||||
setStatus('success')
|
||||
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth callback failed:', error)
|
||||
setError(error instanceof Error ? error.message : 'Authentication failed')
|
||||
setError(
|
||||
error instanceof Error ? error.message : 'Authentication failed',
|
||||
)
|
||||
setStatus('error')
|
||||
|
||||
|
||||
// Redirect to login after error
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
@@ -57,28 +60,40 @@ export function AuthCallbackPage() {
|
||||
{status === 'processing' && (
|
||||
<div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<h2 className="mt-4 text-xl font-semibold">Completing sign in...</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait while we set up your account.</p>
|
||||
<h2 className="mt-4 text-xl font-semibold">
|
||||
Completing sign in...
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please wait while we set up your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<div className="text-green-600 text-4xl mb-4">✓</div>
|
||||
<h2 className="text-xl font-semibold text-green-600">Sign in successful!</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">Redirecting to dashboard...</p>
|
||||
<h2 className="text-xl font-semibold text-green-600">
|
||||
Sign in successful!
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Redirecting to dashboard...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{status === 'error' && (
|
||||
<div>
|
||||
<div className="text-red-600 text-4xl mb-4">✗</div>
|
||||
<h2 className="text-xl font-semibold text-red-600">Sign in failed</h2>
|
||||
<h2 className="text-xl font-semibold text-red-600">
|
||||
Sign in failed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{error}</p>
|
||||
<p className="text-sm text-gray-500">Redirecting to login page...</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Volume2, Play, Clock, HardDrive, Music, Trophy, Loader2, RefreshCw } from 'lucide-react'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
|
||||
import { NumberFlowSize } from '@/components/ui/number-flow-size'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import {
|
||||
Clock,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Music,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
interface SoundboardStatistics {
|
||||
sound_count: number
|
||||
@@ -32,11 +47,13 @@ interface TopSound {
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [soundboardStatistics, setSoundboardStatistics] = useState<SoundboardStatistics | null>(null)
|
||||
const [trackStatistics, setTrackStatistics] = useState<TrackStatistics | null>(null)
|
||||
const [soundboardStatistics, setSoundboardStatistics] =
|
||||
useState<SoundboardStatistics | null>(null)
|
||||
const [trackStatistics, setTrackStatistics] =
|
||||
useState<TrackStatistics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Top sounds state
|
||||
const [topSounds, setTopSounds] = useState<TopSound[]>([])
|
||||
const [topSoundsLoading, setTopSoundsLoading] = useState(false)
|
||||
@@ -48,19 +65,21 @@ export function DashboardPage() {
|
||||
const fetchStatistics = useCallback(async () => {
|
||||
try {
|
||||
const [soundboardResponse, trackResponse] = await Promise.all([
|
||||
fetch('/api/v1/dashboard/soundboard-statistics', { credentials: 'include' }),
|
||||
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' })
|
||||
fetch('/api/v1/dashboard/soundboard-statistics', {
|
||||
credentials: 'include',
|
||||
}),
|
||||
fetch('/api/v1/dashboard/track-statistics', { credentials: 'include' }),
|
||||
])
|
||||
|
||||
|
||||
if (!soundboardResponse.ok || !trackResponse.ok) {
|
||||
throw new Error('Failed to fetch statistics')
|
||||
}
|
||||
|
||||
|
||||
const [soundboardData, trackData] = await Promise.all([
|
||||
soundboardResponse.json(),
|
||||
trackResponse.json()
|
||||
trackResponse.json(),
|
||||
])
|
||||
|
||||
|
||||
setSoundboardStatistics(soundboardData)
|
||||
setTrackStatistics(trackData)
|
||||
} catch (err) {
|
||||
@@ -68,61 +87,63 @@ export function DashboardPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTopSounds = useCallback(async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(true)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
|
||||
{ credentials: 'include' }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch top sounds')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Graceful update: merge new data while preserving animations
|
||||
setTopSounds(prevTopSounds => {
|
||||
// Create a map of existing sounds for efficient lookup
|
||||
const existingSoundsMap = new Map(prevTopSounds.map(sound => [sound.id, sound]))
|
||||
|
||||
// Update existing sounds and add new ones
|
||||
return data.map((newSound: TopSound) => {
|
||||
const existingSound = existingSoundsMap.get(newSound.id)
|
||||
if (existingSound) {
|
||||
// Preserve object reference if data hasn't changed to avoid re-renders
|
||||
if (
|
||||
existingSound.name === newSound.name &&
|
||||
existingSound.type === newSound.type &&
|
||||
existingSound.play_count === newSound.play_count &&
|
||||
existingSound.duration === newSound.duration
|
||||
) {
|
||||
return existingSound
|
||||
const fetchTopSounds = useCallback(
|
||||
async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(true)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/dashboard/top-sounds?sound_type=${soundType}&period=${period}&limit=${limit}`,
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch top sounds')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Graceful update: merge new data while preserving animations
|
||||
setTopSounds(prevTopSounds => {
|
||||
// Create a map of existing sounds for efficient lookup
|
||||
const existingSoundsMap = new Map(
|
||||
prevTopSounds.map(sound => [sound.id, sound]),
|
||||
)
|
||||
|
||||
// Update existing sounds and add new ones
|
||||
return data.map((newSound: TopSound) => {
|
||||
const existingSound = existingSoundsMap.get(newSound.id)
|
||||
if (existingSound) {
|
||||
// Preserve object reference if data hasn't changed to avoid re-renders
|
||||
if (
|
||||
existingSound.name === newSound.name &&
|
||||
existingSound.type === newSound.type &&
|
||||
existingSound.play_count === newSound.play_count &&
|
||||
existingSound.duration === newSound.duration
|
||||
) {
|
||||
return existingSound
|
||||
}
|
||||
}
|
||||
}
|
||||
return newSound
|
||||
return newSound
|
||||
})
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch top sounds:', err)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch top sounds:', err)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setTopSoundsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [soundType, period, limit])
|
||||
},
|
||||
[soundType, period, limit],
|
||||
)
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStatistics(),
|
||||
fetchTopSounds()
|
||||
])
|
||||
await Promise.all([fetchStatistics(), fetchTopSounds()])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
@@ -149,18 +170,16 @@ export function DashboardPage() {
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshAll])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopSounds(true) // Show loading on initial load and filter changes
|
||||
}, [fetchTopSounds])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard' }
|
||||
]
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -174,30 +193,42 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Soundboard Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Soundboard Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold animate-pulse">---</div>
|
||||
<div className="text-2xl font-bold animate-pulse">
|
||||
---
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Track Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Track Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i + 4}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold animate-pulse">---</div>
|
||||
<div className="text-2xl font-bold animate-pulse">
|
||||
---
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -211,11 +242,9 @@ export function DashboardPage() {
|
||||
|
||||
if (error || !soundboardStatistics || !trackStatistics) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard' }
|
||||
]
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -228,7 +257,9 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4">
|
||||
<p className="text-destructive">Error loading statistics: {error}</p>
|
||||
<p className="text-destructive">
|
||||
Error loading statistics: {error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -236,11 +267,9 @@ export function DashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard' }
|
||||
]
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -251,28 +280,36 @@ export function DashboardPage() {
|
||||
Overview of your soundboard and track statistics
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={refreshAll}
|
||||
variant="outline"
|
||||
<Button
|
||||
onClick={refreshAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Soundboard Statistics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Soundboard Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Soundboard Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Sounds</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Sounds
|
||||
</CardTitle>
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={soundboardStatistics.sound_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={soundboardStatistics.sound_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Soundboard audio files
|
||||
</p>
|
||||
@@ -281,11 +318,15 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Plays</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Plays
|
||||
</CardTitle>
|
||||
<Play className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={soundboardStatistics.total_play_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={soundboardStatistics.total_play_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All-time play count
|
||||
</p>
|
||||
@@ -294,12 +335,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Duration</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Duration
|
||||
</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowDuration duration={soundboardStatistics.total_duration} variant='wordy' />
|
||||
<NumberFlowDuration
|
||||
duration={soundboardStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined audio duration
|
||||
@@ -309,12 +355,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Size</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Size
|
||||
</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowSize size={soundboardStatistics.total_size} binary={true} />
|
||||
<NumberFlowSize
|
||||
size={soundboardStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Original + normalized files
|
||||
@@ -326,15 +377,21 @@ export function DashboardPage() {
|
||||
|
||||
{/* Track Statistics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">Track Statistics</h2>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Track Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Tracks</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Tracks
|
||||
</CardTitle>
|
||||
<Music className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={trackStatistics.track_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={trackStatistics.track_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Extracted audio tracks
|
||||
</p>
|
||||
@@ -343,11 +400,15 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Plays</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Plays
|
||||
</CardTitle>
|
||||
<Play className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold"><NumberFlow value={trackStatistics.total_play_count} /></div>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlow value={trackStatistics.total_play_count} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All-time play count
|
||||
</p>
|
||||
@@ -356,12 +417,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Duration</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Duration
|
||||
</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowDuration duration={trackStatistics.total_duration} variant='wordy' />
|
||||
<NumberFlowDuration
|
||||
duration={trackStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined track duration
|
||||
@@ -371,12 +437,17 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Size</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Size
|
||||
</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowSize size={trackStatistics.total_size} binary={true} />
|
||||
<NumberFlowSize
|
||||
size={trackStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Original + normalized files
|
||||
@@ -385,7 +456,7 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Top Sounds Section */}
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
@@ -428,7 +499,10 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Count:</span>
|
||||
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
||||
<Select
|
||||
value={limit.toString()}
|
||||
onValueChange={value => setLimit(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -457,18 +531,26 @@ export function DashboardPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topSounds.map((sound, index) => (
|
||||
<div key={sound.id} className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg">
|
||||
<div
|
||||
key={sound.id}
|
||||
className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
<div className="font-medium truncate">
|
||||
{sound.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
|
||||
{sound.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<NumberFlowDuration duration={sound.duration} variant='wordy' />
|
||||
<NumberFlowDuration
|
||||
duration={sound.duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
|
||||
@@ -477,8 +559,12 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary"><NumberFlow value={sound.play_count} /></div>
|
||||
<div className="text-xs text-muted-foreground">plays</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
<NumberFlow value={sound.play_count} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
plays
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -491,4 +577,4 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
|
||||
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
type ExtractionInfo,
|
||||
extractionsService,
|
||||
} from '@/lib/api/services/extractions'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function ExtractionsPage() {
|
||||
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
|
||||
@@ -63,29 +88,53 @@ export function ExtractionsPage() {
|
||||
const getStatusBadge = (status: ExtractionInfo['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="secondary" className="gap-1"><Clock className="h-3 w-3" />Pending</Badge>
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)
|
||||
case 'processing':
|
||||
return <Badge variant="outline" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" />Processing</Badge>
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
)
|
||||
case 'completed':
|
||||
return <Badge variant="default" className="gap-1"><CheckCircle className="h-3 w-3" />Completed</Badge>
|
||||
return (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
)
|
||||
case 'failed':
|
||||
return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Failed</Badge>
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceBadge = (service: string | undefined) => {
|
||||
if (!service) return null
|
||||
|
||||
|
||||
const serviceColors: Record<string, string> = {
|
||||
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
soundcloud: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
soundcloud:
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
instagram:
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
}
|
||||
|
||||
const colorClass = serviceColors[service.toLowerCase()] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
const colorClass =
|
||||
serviceColors[service.toLowerCase()] ||
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colorClass}>
|
||||
@@ -95,12 +144,9 @@ export function ExtractionsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Extractions' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Extractions' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -111,7 +157,7 @@ export function ExtractionsPage() {
|
||||
Extract audio from YouTube, SoundCloud, and other platforms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
@@ -130,22 +176,29 @@ export function ExtractionsPage() {
|
||||
id="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !isCreating) {
|
||||
handleCreateExtraction()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, Instagram, and more
|
||||
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
|
||||
Instagram, and more
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateExtraction} disabled={isCreating}>
|
||||
<Button
|
||||
onClick={handleCreateExtraction}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
@@ -171,9 +224,12 @@ export function ExtractionsPage() {
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center">
|
||||
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No extractions yet</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
No extractions yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Start by adding a URL to extract audio from your favorite platforms
|
||||
Start by adding a URL to extract audio from your favorite
|
||||
platforms
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -199,7 +255,7 @@ export function ExtractionsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extractions.map((extraction) => (
|
||||
{extractions.map(extraction => (
|
||||
<TableRow key={extraction.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
@@ -217,7 +273,10 @@ export function ExtractionsPage() {
|
||||
<TableCell>
|
||||
{getStatusBadge(extraction.status)}
|
||||
{extraction.error && (
|
||||
<div className="text-xs text-destructive mt-1 max-w-48 truncate" title={extraction.error}>
|
||||
<div
|
||||
className="text-xs text-destructive mt-1 max-w-48 truncate"
|
||||
title={extraction.error}
|
||||
>
|
||||
{extraction.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -231,7 +290,9 @@ export function ExtractionsPage() {
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid date'
|
||||
}
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
return formatDistanceToNow(date, {
|
||||
addSuffix: true,
|
||||
})
|
||||
} catch {
|
||||
return 'Invalid date'
|
||||
}
|
||||
@@ -241,15 +302,24 @@ export function ExtractionsPage() {
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={extraction.url} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={extraction.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{extraction.status === 'completed' && extraction.sound_id && (
|
||||
<Button variant="ghost" size="sm" title="View in Sounds">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{extraction.status === 'completed' &&
|
||||
extraction.sound_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="View in Sounds"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -262,4 +332,4 @@ export function ExtractionsPage() {
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Link } from 'react-router'
|
||||
import { LoginForm } from '@/components/auth/LoginForm'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<LoginForm />
|
||||
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Sign up
|
||||
@@ -21,4 +21,4 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,88 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
type Playlist,
|
||||
type PlaylistSortField,
|
||||
type SortOrder,
|
||||
playlistsService,
|
||||
} from '@/lib/api/services/playlists'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit,
|
||||
Music,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { playlistsService, type Playlist, type PlaylistSortField, type SortOrder } from '@/lib/api/services/playlists'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw, Music, User, Calendar, Clock, Plus, Play, Edit } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
|
||||
export function PlaylistsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Search and sorting state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
|
||||
|
||||
// Create playlist dialog state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [newPlaylist, setNewPlaylist] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
genre: ''
|
||||
genre: '',
|
||||
})
|
||||
|
||||
// Debounce search query
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery)
|
||||
}, 300)
|
||||
|
||||
|
||||
return () => clearTimeout(handler)
|
||||
}, [searchQuery])
|
||||
|
||||
@@ -57,7 +97,8 @@ export function PlaylistsPage() {
|
||||
})
|
||||
setPlaylists(playlistData)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch playlists'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch playlists'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
@@ -82,17 +123,18 @@ export function PlaylistsPage() {
|
||||
description: newPlaylist.description.trim() || undefined,
|
||||
genre: newPlaylist.genre.trim() || undefined,
|
||||
})
|
||||
|
||||
|
||||
toast.success(`Playlist "${newPlaylist.name}" created successfully`)
|
||||
|
||||
|
||||
// Reset form and close dialog
|
||||
setNewPlaylist({ name: '', description: '', genre: '' })
|
||||
setShowCreateDialog(false)
|
||||
|
||||
|
||||
// Refresh the playlists list
|
||||
fetchPlaylists()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create playlist'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to create playlist'
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
@@ -108,11 +150,12 @@ export function PlaylistsPage() {
|
||||
try {
|
||||
await playlistsService.setCurrentPlaylist(playlist.id)
|
||||
toast.success(`"${playlist.name}" is now the current playlist`)
|
||||
|
||||
|
||||
// Refresh the playlists list to update the current status
|
||||
fetchPlaylists()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to set current playlist'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to set current playlist'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -137,10 +180,12 @@ export function PlaylistsPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load playlists</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load playlists
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchPlaylists}
|
||||
<button
|
||||
onClick={fetchPlaylists}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
@@ -157,7 +202,9 @@ export function PlaylistsPage() {
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No playlists found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'No playlists match your search criteria.' : 'No playlists are available.'}
|
||||
{searchQuery
|
||||
? 'No playlists match your search criteria.'
|
||||
: 'No playlists are available.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -179,13 +226,15 @@ export function PlaylistsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{playlists.map((playlist) => (
|
||||
{playlists.map(playlist => (
|
||||
<TableRow key={playlist.id} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{playlist.name}</div>
|
||||
<div className="font-medium truncate">
|
||||
{playlist.name}
|
||||
</div>
|
||||
{playlist.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{playlist.description}
|
||||
@@ -234,9 +283,7 @@ export function PlaylistsPage() {
|
||||
{playlist.is_current && (
|
||||
<Badge variant="default">Current</Badge>
|
||||
)}
|
||||
{playlist.is_main && (
|
||||
<Badge variant="outline">Main</Badge>
|
||||
)}
|
||||
{playlist.is_main && <Badge variant="outline">Main</Badge>}
|
||||
{!playlist.is_current && !playlist.is_main && (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
@@ -275,12 +322,9 @@ export function PlaylistsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Playlists' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Playlists' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -303,7 +347,8 @@ export function PlaylistsPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Playlist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new playlist to organize your sounds. Give it a name and optionally add a description and genre.
|
||||
Add a new playlist to organize your sounds. Give it a name
|
||||
and optionally add a description and genre.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -313,8 +358,13 @@ export function PlaylistsPage() {
|
||||
id="name"
|
||||
placeholder="My awesome playlist"
|
||||
value={newPlaylist.name}
|
||||
onChange={(e) => setNewPlaylist(prev => ({ ...prev, name: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
onChange={e =>
|
||||
setNewPlaylist(prev => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleCreatePlaylist()
|
||||
@@ -328,7 +378,12 @@ export function PlaylistsPage() {
|
||||
id="description"
|
||||
placeholder="A collection of my favorite sounds..."
|
||||
value={newPlaylist.description}
|
||||
onChange={(e) => setNewPlaylist(prev => ({ ...prev, description: e.target.value }))}
|
||||
onChange={e =>
|
||||
setNewPlaylist(prev => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
@@ -338,15 +393,27 @@ export function PlaylistsPage() {
|
||||
id="genre"
|
||||
placeholder="Electronic, Rock, Comedy, etc."
|
||||
value={newPlaylist.genre}
|
||||
onChange={(e) => setNewPlaylist(prev => ({ ...prev, genre: e.target.value }))}
|
||||
onChange={e =>
|
||||
setNewPlaylist(prev => ({
|
||||
...prev,
|
||||
genre: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelCreate} disabled={createLoading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelCreate}
|
||||
disabled={createLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreatePlaylist} disabled={createLoading || !newPlaylist.name.trim()}>
|
||||
<Button
|
||||
onClick={handleCreatePlaylist}
|
||||
disabled={createLoading || !newPlaylist.name.trim()}
|
||||
>
|
||||
{createLoading ? 'Creating...' : 'Create Playlist'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -359,7 +426,7 @@ export function PlaylistsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
@@ -368,7 +435,7 @@ export function PlaylistsPage() {
|
||||
<Input
|
||||
placeholder="Search playlists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -384,9 +451,12 @@ export function PlaylistsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as PlaylistSortField)}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => setSortBy(value as PlaylistSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
@@ -399,16 +469,20 @@ export function PlaylistsPage() {
|
||||
<SelectItem value="updated_at">Updated Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
|
||||
>
|
||||
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -416,13 +490,15 @@ export function PlaylistsPage() {
|
||||
disabled={loading}
|
||||
title="Refresh playlists"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Link } from 'react-router'
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<RegisterForm />
|
||||
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Sign in
|
||||
@@ -21,4 +21,4 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { SoundCard } from '@/components/sounds/SoundCard'
|
||||
import { soundsService, type Sound, type SoundSortField, type SortOrder } from '@/lib/api/services/sounds'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
import { soundEvents, SOUND_EVENTS } from '@/lib/events'
|
||||
import {
|
||||
type SortOrder,
|
||||
type Sound,
|
||||
type SoundSortField,
|
||||
soundsService,
|
||||
} from '@/lib/api/services/sounds'
|
||||
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
|
||||
import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface SoundPlayedEventData {
|
||||
sound_id: number
|
||||
@@ -54,7 +72,7 @@ export function SoundsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentColors, setCurrentColors] = useState<string[]>(lightModeColors)
|
||||
|
||||
|
||||
// Search and sorting state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
||||
@@ -65,10 +83,12 @@ export function SoundsPage() {
|
||||
await soundsService.playSound(sound.id)
|
||||
toast.success(`Playing: ${sound.name || sound.filename}`)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to play sound: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
toast.error(
|
||||
`Failed to play sound: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,7 +98,7 @@ export function SoundsPage() {
|
||||
setCurrentColors(lightModeColors)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
|
||||
const getSoundColor = (soundIdx: number) => {
|
||||
const index = soundIdx % currentColors.length
|
||||
return currentColors[index]
|
||||
@@ -95,7 +115,8 @@ export function SoundsPage() {
|
||||
})
|
||||
setSounds(sdbSounds)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
@@ -105,12 +126,12 @@ export function SoundsPage() {
|
||||
|
||||
// Debounce search query
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery)
|
||||
}, 300)
|
||||
|
||||
|
||||
return () => clearTimeout(handler)
|
||||
}, [searchQuery])
|
||||
|
||||
@@ -121,12 +142,12 @@ export function SoundsPage() {
|
||||
// Listen for sound_played events and update play_count
|
||||
useEffect(() => {
|
||||
const handleSoundPlayed = (eventData: SoundPlayedEventData) => {
|
||||
setSounds(prevSounds =>
|
||||
prevSounds.map(sound =>
|
||||
setSounds(prevSounds =>
|
||||
prevSounds.map(sound =>
|
||||
sound.id === eventData.sound_id
|
||||
? { ...sound, play_count: eventData.play_count }
|
||||
: sound
|
||||
)
|
||||
: sound,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,8 +177,8 @@ export function SoundsPage() {
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load sounds</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
@@ -183,19 +204,21 @@ export function SoundsPage() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{sounds.map((sound, idx) => (
|
||||
<SoundCard key={sound.id} sound={sound} playSound={handlePlaySound} colorClasses={getSoundColor(idx)} />
|
||||
<SoundCard
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
playSound={handlePlaySound}
|
||||
colorClasses={getSoundColor(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Sounds' }
|
||||
]
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Sounds' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -212,7 +235,7 @@ export function SoundsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
@@ -221,7 +244,7 @@ export function SoundsPage() {
|
||||
<Input
|
||||
placeholder="Search sounds..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -237,9 +260,12 @@ export function SoundsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SoundSortField)}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => setSortBy(value as SoundSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
@@ -252,16 +278,20 @@ export function SoundsPage() {
|
||||
<SelectItem value="updated_at">Updated Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
|
||||
>
|
||||
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -269,13 +299,15 @@ export function SoundsPage() {
|
||||
disabled={loading}
|
||||
title="Refresh sounds"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Scan,
|
||||
Volume2,
|
||||
Settings as SettingsIcon,
|
||||
Loader2,
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
type NormalizationResponse,
|
||||
type ScanResponse,
|
||||
adminService,
|
||||
} from '@/lib/api/services/admin'
|
||||
import {
|
||||
AudioWaveform,
|
||||
FolderSync,
|
||||
AudioWaveform
|
||||
Loader2,
|
||||
Scan,
|
||||
Settings as SettingsIcon,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { adminService, type ScanResponse, type NormalizationResponse } from '@/lib/api/services/admin'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function SettingsPage() {
|
||||
// Sound scanning state
|
||||
const [scanningInProgress, setScanningInProgress] = useState(false)
|
||||
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(null)
|
||||
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
// Sound normalization state
|
||||
const [normalizationInProgress, setNormalizationInProgress] = useState(false)
|
||||
const [normalizationOptions, setNormalizationOptions] = useState({
|
||||
force: false,
|
||||
onePass: false,
|
||||
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT'
|
||||
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT',
|
||||
})
|
||||
const [lastNormalizationResults, setLastNormalizationResults] = useState<NormalizationResponse | null>(null)
|
||||
const [lastNormalizationResults, setLastNormalizationResults] =
|
||||
useState<NormalizationResponse | null>(null)
|
||||
|
||||
const handleScanSounds = async () => {
|
||||
setScanningInProgress(true)
|
||||
try {
|
||||
const response = await adminService.scanSounds()
|
||||
setLastScanResults(response)
|
||||
toast.success(`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`)
|
||||
toast.success(
|
||||
`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error('Failed to scan sounds')
|
||||
console.error('Sound scan error:', error)
|
||||
@@ -48,22 +63,24 @@ export function SettingsPage() {
|
||||
setNormalizationInProgress(true)
|
||||
try {
|
||||
let response: NormalizationResponse
|
||||
|
||||
|
||||
if (normalizationOptions.soundType === 'all') {
|
||||
response = await adminService.normalizeAllSounds(
|
||||
normalizationOptions.force,
|
||||
normalizationOptions.onePass
|
||||
normalizationOptions.onePass,
|
||||
)
|
||||
} else {
|
||||
response = await adminService.normalizeSoundsByType(
|
||||
normalizationOptions.soundType,
|
||||
normalizationOptions.force,
|
||||
normalizationOptions.onePass
|
||||
normalizationOptions.onePass,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
setLastNormalizationResults(response)
|
||||
toast.success(`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`)
|
||||
toast.success(
|
||||
`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error('Failed to normalize sounds')
|
||||
console.error('Sound normalization error:', error)
|
||||
@@ -73,13 +90,13 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Admin' },
|
||||
{ label: 'Settings' }
|
||||
]
|
||||
{ label: 'Settings' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -104,11 +121,12 @@ export function SettingsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the sound directories to synchronize new, updated, and deleted audio files with the database.
|
||||
Scan the sound directories to synchronize new, updated, and
|
||||
deleted audio files with the database.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleScanSounds}
|
||||
<Button
|
||||
onClick={handleScanSounds}
|
||||
disabled={scanningInProgress}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -134,7 +152,9 @@ export function SettingsPage() {
|
||||
<div>🗑️ Deleted: {lastScanResults.results.deleted}</div>
|
||||
<div>⏭️ Skipped: {lastScanResults.results.skipped}</div>
|
||||
{lastScanResults.results.errors.length > 0 && (
|
||||
<div>❌ Errors: {lastScanResults.results.errors.length}</div>
|
||||
<div>
|
||||
❌ Errors: {lastScanResults.results.errors.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,16 +172,20 @@ export function SettingsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Normalize audio levels across all sounds using FFmpeg's loudnorm filter for consistent volume.
|
||||
Normalize audio levels across all sounds using FFmpeg's loudnorm
|
||||
filter for consistent volume.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Sound Type</Label>
|
||||
<Select
|
||||
value={normalizationOptions.soundType}
|
||||
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
|
||||
setNormalizationOptions(prev => ({ ...prev, soundType: value }))
|
||||
<Select
|
||||
value={normalizationOptions.soundType}
|
||||
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
|
||||
setNormalizationOptions(prev => ({
|
||||
...prev,
|
||||
soundType: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -177,11 +201,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
<Checkbox
|
||||
id="force-normalize"
|
||||
checked={normalizationOptions.force}
|
||||
onCheckedChange={(checked) =>
|
||||
setNormalizationOptions(prev => ({ ...prev, force: !!checked }))
|
||||
onCheckedChange={checked =>
|
||||
setNormalizationOptions(prev => ({
|
||||
...prev,
|
||||
force: !!checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="force-normalize" className="text-sm">
|
||||
@@ -190,11 +217,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
<Checkbox
|
||||
id="one-pass"
|
||||
checked={normalizationOptions.onePass}
|
||||
onCheckedChange={(checked) =>
|
||||
setNormalizationOptions(prev => ({ ...prev, onePass: !!checked }))
|
||||
onCheckedChange={checked =>
|
||||
setNormalizationOptions(prev => ({
|
||||
...prev,
|
||||
onePass: !!checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="one-pass" className="text-sm">
|
||||
@@ -203,8 +233,8 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNormalizeSounds}
|
||||
<Button
|
||||
onClick={handleNormalizeSounds}
|
||||
disabled={normalizationInProgress}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -223,19 +253,35 @@ export function SettingsPage() {
|
||||
|
||||
{lastNormalizationResults && (
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium">Last Normalization Results:</div>
|
||||
<div className="text-sm font-medium">
|
||||
Last Normalization Results:
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>🔄 Processed: {lastNormalizationResults.results.processed}</div>
|
||||
<div>✅ Normalized: {lastNormalizationResults.results.normalized}</div>
|
||||
<div>⏭️ Skipped: {lastNormalizationResults.results.skipped}</div>
|
||||
<div>❌ Errors: {lastNormalizationResults.results.errors}</div>
|
||||
{lastNormalizationResults.results.error_details.length > 0 && (
|
||||
<div>
|
||||
🔄 Processed: {lastNormalizationResults.results.processed}
|
||||
</div>
|
||||
<div>
|
||||
✅ Normalized:{' '}
|
||||
{lastNormalizationResults.results.normalized}
|
||||
</div>
|
||||
<div>
|
||||
⏭️ Skipped: {lastNormalizationResults.results.skipped}
|
||||
</div>
|
||||
<div>
|
||||
❌ Errors: {lastNormalizationResults.results.errors}
|
||||
</div>
|
||||
{lastNormalizationResults.results.error_details.length >
|
||||
0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-red-600">View Error Details</summary>
|
||||
<summary className="cursor-pointer text-red-600">
|
||||
View Error Details
|
||||
</summary>
|
||||
<div className="mt-1 text-xs text-red-600 space-y-1">
|
||||
{lastNormalizationResults.results.error_details.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
{lastNormalizationResults.results.error_details.map(
|
||||
(error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
@@ -248,4 +294,4 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { Edit, UserCheck, UserX } from 'lucide-react'
|
||||
import { adminService, type Plan } from '@/lib/api/services/admin'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { type Plan, adminService } from '@/lib/api/services/admin'
|
||||
import type { User } from '@/types/auth'
|
||||
import { Edit, UserCheck, UserX } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface EditUserData {
|
||||
name: string
|
||||
@@ -31,7 +44,7 @@ export function UsersPage() {
|
||||
name: '',
|
||||
plan_id: 0,
|
||||
credits: 0,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -43,7 +56,7 @@ export function UsersPage() {
|
||||
try {
|
||||
const [usersData, plansData] = await Promise.all([
|
||||
adminService.listUsers(),
|
||||
adminService.listPlans()
|
||||
adminService.listPlans(),
|
||||
])
|
||||
setUsers(usersData)
|
||||
setPlans(plansData)
|
||||
@@ -61,7 +74,7 @@ export function UsersPage() {
|
||||
name: user.name,
|
||||
plan_id: user.plan.id,
|
||||
credits: user.credits,
|
||||
is_active: user.is_active
|
||||
is_active: user.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,8 +83,13 @@ export function UsersPage() {
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const updatedUser = await adminService.updateUser(editingUser.id, editData)
|
||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? updatedUser : u))
|
||||
const updatedUser = await adminService.updateUser(
|
||||
editingUser.id,
|
||||
editData,
|
||||
)
|
||||
setUsers(prev =>
|
||||
prev.map(u => (u.id === editingUser.id ? updatedUser : u)),
|
||||
)
|
||||
setEditingUser(null)
|
||||
toast.success('User updated successfully')
|
||||
} catch (error) {
|
||||
@@ -117,13 +135,13 @@ export function UsersPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Admin' },
|
||||
{ label: 'Users' }
|
||||
]
|
||||
{ label: 'Users' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -152,13 +170,13 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Admin' },
|
||||
{ label: 'Users' }
|
||||
]
|
||||
{ label: 'Users' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
@@ -192,7 +210,7 @@ export function UsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
{users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
@@ -231,39 +249,62 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
{/* Edit User Sheet */}
|
||||
<Sheet open={!!editingUser} onOpenChange={(open) => !open && setEditingUser(null)}>
|
||||
<Sheet
|
||||
open={!!editingUser}
|
||||
onOpenChange={open => !open && setEditingUser(null)}
|
||||
>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<div className="px-6">
|
||||
<div className="pt-4 pb-6">
|
||||
<h2 className="text-xl font-semibold">Edit User</h2>
|
||||
</div>
|
||||
|
||||
|
||||
{editingUser && (
|
||||
<div className="space-y-8 pb-6">
|
||||
{/* User Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-base">User Information</h3>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">User ID:</span>
|
||||
<span className="col-span-2 font-mono">{editingUser.id}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
User ID:
|
||||
</span>
|
||||
<span className="col-span-2 font-mono">
|
||||
{editingUser.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Email:</span>
|
||||
<span className="col-span-2 break-all">{editingUser.email}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Email:
|
||||
</span>
|
||||
<span className="col-span-2 break-all">
|
||||
{editingUser.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Role:</span>
|
||||
<span className="col-span-2">{getRoleBadge(editingUser.role)}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Role:
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{getRoleBadge(editingUser.role)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Created:</span>
|
||||
<span className="col-span-2">{new Date(editingUser.created_at).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Created:
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{new Date(editingUser.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-muted-foreground font-medium">Last Updated:</span>
|
||||
<span className="col-span-2">{new Date(editingUser.updated_at).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Last Updated:
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{new Date(editingUser.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,11 +315,18 @@ export function UsersPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">Display Name</Label>
|
||||
<Label htmlFor="name" className="text-sm font-medium">
|
||||
Display Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, name: e.target.value }))}
|
||||
onChange={e =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter user's display name"
|
||||
className="h-10"
|
||||
/>
|
||||
@@ -288,21 +336,32 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan" className="text-sm font-medium">Subscription Plan</Label>
|
||||
<Label htmlFor="plan" className="text-sm font-medium">
|
||||
Subscription Plan
|
||||
</Label>
|
||||
<Select
|
||||
value={editData.plan_id.toString()}
|
||||
onValueChange={(value) => setEditData(prev => ({ ...prev, plan_id: parseInt(value) }))}
|
||||
onValueChange={value =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
plan_id: parseInt(value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem key={plan.id} value={plan.id.toString()}>
|
||||
{plans.map(plan => (
|
||||
<SelectItem
|
||||
key={plan.id}
|
||||
value={plan.id.toString()}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plan.max_credits.toLocaleString()} max credits
|
||||
{plan.max_credits.toLocaleString()} max
|
||||
credits
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -310,40 +369,64 @@ export function UsersPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current plan: <span className="font-medium">{editingUser.plan.name}</span>
|
||||
Current plan:{' '}
|
||||
<span className="font-medium">
|
||||
{editingUser.plan.name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits" className="text-sm font-medium">Current Credits</Label>
|
||||
<Label htmlFor="credits" className="text-sm font-medium">
|
||||
Current Credits
|
||||
</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={editData.credits}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, credits: parseInt(e.target.value) || 0 }))}
|
||||
onChange={e =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
credits: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
placeholder="Enter credit amount"
|
||||
className="h-10"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum allowed: <span className="font-medium">{editingUser.plan.max_credits.toLocaleString()}</span>
|
||||
Maximum allowed:{' '}
|
||||
<span className="font-medium">
|
||||
{editingUser.plan.max_credits.toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Account Status</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
Account Status
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Allow Login Access</span>
|
||||
<span className="text-sm font-medium">
|
||||
Allow Login Access
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{editData.is_active ? 'User can log in and use the platform' : 'User is blocked from logging in and accessing the platform'}
|
||||
{editData.is_active
|
||||
? 'User can log in and use the platform'
|
||||
: 'User is blocked from logging in and accessing the platform'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="active"
|
||||
checked={editData.is_active}
|
||||
onCheckedChange={(checked) => setEditData(prev => ({ ...prev, is_active: checked }))}
|
||||
onCheckedChange={checked =>
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
is_active: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,4 +458,4 @@ export function UsersPage() {
|
||||
</Sheet>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user