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

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

View File

@@ -1,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>
)
}
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
export function AuthCallbackPage() {
const navigate = useNavigate()
const { setUser } = useAuth()
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
const [status, setStatus] = useState<'processing' | 'success' | 'error'>(
'processing',
)
const [error, setError] = useState<string>('')
useEffect(() => {
@@ -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>
)
}
}

View File

@@ -1,12 +1,27 @@
import { useCallback, useEffect, useState } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Volume2, Play, Clock, HardDrive, Music, Trophy, Loader2, RefreshCw } from 'lucide-react'
import NumberFlow from '@number-flow/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
import { NumberFlowSize } from '@/components/ui/number-flow-size'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import NumberFlow from '@number-flow/react'
import {
Clock,
HardDrive,
Loader2,
Music,
Play,
RefreshCw,
Trophy,
Volume2,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
interface SoundboardStatistics {
sound_count: number
@@ -32,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>
)
}
}

View File

@@ -1,16 +1,41 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
import { toast } from 'sonner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
type ExtractionInfo,
extractionsService,
} from '@/lib/api/services/extractions'
import { formatDistanceToNow } from 'date-fns'
import {
AlertCircle,
Calendar,
CheckCircle,
Clock,
Download,
ExternalLink,
Loader2,
Plus,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
export function ExtractionsPage() {
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
@@ -63,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>
)
}
}

View File

@@ -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

View File

@@ -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>
)
}
}

View File

@@ -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>
)
}
}

View File

@@ -1,15 +1,33 @@
import { useEffect, useState } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { SoundCard } from '@/components/sounds/SoundCard'
import { soundsService, type Sound, type SoundSortField, type SortOrder } from '@/lib/api/services/sounds'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { AlertCircle, Search, SortAsc, SortDesc, X, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useTheme } from '@/hooks/use-theme'
import { soundEvents, SOUND_EVENTS } from '@/lib/events'
import {
type SortOrder,
type Sound,
type SoundSortField,
soundsService,
} from '@/lib/api/services/sounds'
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
import {
AlertCircle,
RefreshCw,
Search,
SortAsc,
SortDesc,
X,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
interface SoundPlayedEventData {
sound_id: number
@@ -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>
)
}
}

View File

@@ -1,41 +1,56 @@
import { useState } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from 'sonner'
import {
Scan,
Volume2,
Settings as SettingsIcon,
Loader2,
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>
)
}
}

View File

@@ -1,19 +1,32 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Edit, UserCheck, UserX } from 'lucide-react'
import { adminService, type Plan } from '@/lib/api/services/admin'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { type Plan, adminService } from '@/lib/api/services/admin'
import type { User } from '@/types/auth'
import { Edit, UserCheck, UserX } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
interface EditUserData {
name: string
@@ -31,7 +44,7 @@ export function UsersPage() {
name: '',
plan_id: 0,
credits: 0,
is_active: true
is_active: true,
})
const [saving, setSaving] = useState(false)
@@ -43,7 +56,7 @@ export function UsersPage() {
try {
const [usersData, plansData] = await Promise.all([
adminService.listUsers(),
adminService.listPlans()
adminService.listPlans(),
])
setUsers(usersData)
setPlans(plansData)
@@ -61,7 +74,7 @@ export function UsersPage() {
name: user.name,
plan_id: user.plan.id,
credits: user.credits,
is_active: user.is_active
is_active: user.is_active,
})
}
@@ -70,8 +83,13 @@ export function UsersPage() {
setSaving(true)
try {
const updatedUser = await adminService.updateUser(editingUser.id, editData)
setUsers(prev => prev.map(u => u.id === editingUser.id ? updatedUser : u))
const updatedUser = await adminService.updateUser(
editingUser.id,
editData,
)
setUsers(prev =>
prev.map(u => (u.id === editingUser.id ? updatedUser : u)),
)
setEditingUser(null)
toast.success('User updated successfully')
} catch (error) {
@@ -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>
)
}
}