Compare commits

..

10 Commits

28 changed files with 1846 additions and 435 deletions

View File

@@ -0,0 +1,147 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface AppPaginationProps {
currentPage: number
totalPages: number
totalCount: number
pageSize: number
pageSizeOptions?: number[]
onPageChange: (page: number) => void
onPageSizeChange: (size: number) => void
itemName?: string // e.g., "items", "extractions", "playlists"
}
export function AppPagination({
currentPage,
totalPages,
totalCount,
pageSize,
pageSizeOptions = [10, 20, 50, 100],
onPageChange,
onPageSizeChange,
itemName = 'items',
}: AppPaginationProps) {
// Don't render if there are no items
if (totalCount === 0) return null
const getVisiblePages = () => {
const delta = 2
const range = []
const rangeWithDots = []
for (
let i = Math.max(2, currentPage - delta);
i <= Math.min(totalPages - 1, currentPage + delta);
i++
) {
range.push(i)
}
if (currentPage - delta > 2) {
rangeWithDots.push(1, '...')
} else {
rangeWithDots.push(1)
}
rangeWithDots.push(...range)
if (currentPage + delta < totalPages - 1) {
rangeWithDots.push('...', totalPages)
} else if (totalPages > 1) {
rangeWithDots.push(totalPages)
}
return rangeWithDots
}
const startItem = Math.min((currentPage - 1) * pageSize + 1, totalCount)
const endItem = Math.min(currentPage * pageSize, totalCount)
return (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem} to {endItem} of {totalCount} {itemName}
</p>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
if (currentPage > 1) onPageChange(currentPage - 1)
}}
className={currentPage <= 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{getVisiblePages().map((page, index) => (
<PaginationItem key={index}>
{page === '...' ? (
<PaginationEllipsis />
) : (
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault()
onPageChange(page as number)
}}
isActive={currentPage === page}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
if (currentPage < totalPages) onPageChange(currentPage + 1)
}}
className={currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<div className="flex items-center gap-2 whitespace-nowrap">
<span className="text-sm text-muted-foreground">Show</span>
<Select
value={pageSize.toString()}
onValueChange={value => onPageSizeChange(parseInt(value, 10))}
>
<SelectTrigger className="w-[75px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map(size => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">rows</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Filter, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
export type UserSortField = 'name' | 'email' | 'role' | 'credits' | 'created_at'
export type SortOrder = 'asc' | 'desc'
export type UserStatus = 'all' | 'active' | 'inactive'
interface UsersHeaderProps {
searchQuery: string
onSearchChange: (query: string) => void
sortBy: UserSortField
onSortByChange: (sortBy: UserSortField) => void
sortOrder: SortOrder
onSortOrderChange: (order: SortOrder) => void
statusFilter: UserStatus
onStatusFilterChange: (status: UserStatus) => void
onRefresh: () => void
loading: boolean
error: string | null
userCount: number
}
export function UsersHeader({
searchQuery,
onSearchChange,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
statusFilter,
onStatusFilterChange,
onRefresh,
loading,
error,
userCount,
}: UsersHeaderProps) {
return (
<>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<div className="flex items-center gap-4">
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{statusFilter !== 'all'
? `${userCount} ${statusFilter} user${userCount !== 1 ? 's' : ''}`
: `${userCount} user${userCount !== 1 ? 's' : ''}`
}
</div>
)}
<Button onClick={onRefresh} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => onSearchChange('')}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
title="Clear search"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div className="flex gap-2">
<Select
value={sortBy}
onValueChange={value => onSortByChange(value as UserSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="role">Role</SelectItem>
<SelectItem value="credits">Credits</SelectItem>
<SelectItem value="created_at">Created Date</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => onSortOrderChange(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" />
)}
</Button>
<Select
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as UserStatus)}
>
<SelectTrigger className="w-[120px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={loading}
title="Refresh users"
>
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,112 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AlertCircle, RefreshCw, Users } from 'lucide-react'
export function UsersLoading() {
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export function UsersError({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 space-y-4">
<div className="rounded-full p-3 bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Failed to load users</h3>
<p className="text-muted-foreground max-w-md">
{error}
</p>
</div>
<Button onClick={onRetry} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</Button>
</div>
)
}
export function UsersEmpty({ searchQuery, statusFilter }: {
searchQuery: string;
statusFilter: string;
}) {
return (
<div className="flex flex-col items-center justify-center py-16 space-y-4">
<div className="rounded-full p-3 bg-muted">
<Users className="h-8 w-8 text-muted-foreground" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">
{searchQuery || statusFilter !== 'all'
? 'No users found'
: 'No users yet'
}
</h3>
<p className="text-muted-foreground max-w-md">
{searchQuery
? `No users match "${searchQuery}"`
: statusFilter !== 'all'
? `No ${statusFilter} users found`
: 'Users will appear here once they are added to the system'
}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { User } from '@/types/auth'
import { Edit, UserCheck, UserX } from 'lucide-react'
interface UsersTableProps {
users: User[]
onEdit: (user: User) => void
onToggleStatus: (user: User) => void
}
export function UsersTable({ users, onEdit, onToggleStatus }: UsersTableProps) {
const getRoleBadge = (role: string) => {
return (
<Badge variant={role === 'admin' ? 'destructive' : 'secondary'}>
{role}
</Badge>
)
}
const getStatusBadge = (isActive: boolean) => {
return (
<Badge variant={isActive ? 'default' : 'secondary'}>
{isActive ? 'Active' : 'Inactive'}
</Badge>
)
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>{user.plan.name}</TableCell>
<TableCell>{user.credits.toLocaleString()}</TableCell>
<TableCell>{getStatusBadge(user.is_active)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(user)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onToggleStatus(user)}
>
{user.is_active ? (
<UserX className="h-4 w-4" />
) : (
<UserCheck className="h-4 w-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
interface CreateExtractionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
loading: boolean
url: string
onUrlChange: (url: string) => void
onSubmit: () => void
onCancel: () => void
}
export function CreateExtractionDialog({
open,
onOpenChange,
loading,
url,
onUrlChange,
onSubmit,
onCancel,
}: CreateExtractionDialogProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
onSubmit()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Extraction</DialogTitle>
<DialogDescription>
Extract audio from YouTube, SoundCloud, Vimeo, TikTok, Twitter,
Instagram, and many other platforms.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="url">URL</Label>
<Input
id="url"
placeholder="https://www.youtube.com/watch?v=..."
value={url}
onChange={e => onUrlChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<p className="text-sm text-muted-foreground mt-1">
Paste a link to extract audio from the media
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Extraction'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,157 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ExtractionSortField, ExtractionSortOrder, ExtractionStatus } from '@/lib/api/services/extractions'
import { Filter, Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
interface ExtractionsHeaderProps {
searchQuery: string
onSearchChange: (query: string) => void
sortBy: ExtractionSortField
onSortByChange: (sortBy: ExtractionSortField) => void
sortOrder: ExtractionSortOrder
onSortOrderChange: (order: ExtractionSortOrder) => void
statusFilter: ExtractionStatus | 'all'
onStatusFilterChange: (status: ExtractionStatus | 'all') => void
onRefresh: () => void
onCreateClick: () => void
loading: boolean
error: string | null
extractionCount: number
}
export function ExtractionsHeader({
searchQuery,
onSearchChange,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
statusFilter,
onStatusFilterChange,
onRefresh,
onCreateClick,
loading,
error,
extractionCount,
}: ExtractionsHeaderProps) {
return (
<>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Audio Extractions</h1>
<p className="text-muted-foreground">
Extract audio from YouTube, SoundCloud, and other platforms
</p>
</div>
<div className="flex items-center gap-4">
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{statusFilter !== 'all'
? `${extractionCount} ${statusFilter} extraction${extractionCount !== 1 ? 's' : ''}`
: `${extractionCount} extraction${extractionCount !== 1 ? 's' : ''}`
}
</div>
)}
<Button onClick={onCreateClick}>
<Plus className="h-4 w-4 mr-2" />
Add Extraction
</Button>
</div>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search extractions..."
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => onSearchChange('')}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
title="Clear search"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div className="flex gap-2">
<Select
value={sortBy}
onValueChange={value => onSortByChange(value as ExtractionSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="title">Title</SelectItem>
<SelectItem value="status">Status</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="created_at">Created Date</SelectItem>
<SelectItem value="updated_at">Updated Date</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => onSortOrderChange(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" />
)}
</Button>
<Select
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as ExtractionStatus | 'all')}
>
<SelectTrigger className="w-[120px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="processing">Processing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={loading}
title="Refresh extractions"
>
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,67 @@
import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, Download } from 'lucide-react'
import type { ExtractionStatus } from '@/lib/api/services/extractions'
interface ExtractionsLoadingProps {
count?: number
}
export function ExtractionsLoading({ count = 5 }: ExtractionsLoadingProps) {
return (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
interface ExtractionsErrorProps {
error: string
onRetry: () => void
}
export function ExtractionsError({ error, onRetry }: ExtractionsErrorProps) {
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 extractions</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={onRetry}
className="text-primary hover:underline"
>
Try again
</button>
</div>
)
}
interface ExtractionsEmptyProps {
searchQuery: string
statusFilter?: ExtractionStatus | 'all'
}
export function ExtractionsEmpty({ searchQuery, statusFilter = 'all' }: ExtractionsEmptyProps) {
const isFiltered = searchQuery || statusFilter !== 'all'
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Download className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">
{isFiltered ? 'No extractions found' : 'No extractions yet'}
</h3>
<p className="text-muted-foreground">
{isFiltered
? statusFilter !== 'all'
? `No ${statusFilter} extractions match your search criteria.`
: 'No extractions match your search criteria.'
: 'Start by adding a URL to extract audio from your favorite platforms'
}
</p>
</div>
)
}

View File

@@ -0,0 +1,135 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { TableCell, TableRow } from '@/components/ui/table'
import type { ExtractionInfo } from '@/lib/api/services/extractions'
import { formatDateDistanceToNow } from '@/utils/format-date'
import {
AlertCircle,
Calendar,
CheckCircle,
Clock,
ExternalLink,
Loader2,
User
} from 'lucide-react'
interface ExtractionsRowProps {
extraction: ExtractionInfo
}
export function ExtractionsRow({ extraction }: ExtractionsRowProps) {
const getStatusBadge = (status: ExtractionInfo['status']) => {
switch (status) {
case 'pending':
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>
)
case 'completed':
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>
)
}
}
const getServiceBadge = (service: string | undefined) => {
if (!service) return <span className="text-muted-foreground">-</span>
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',
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',
}
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}>
{service.toUpperCase()}
</Badge>
)
}
return (
<TableRow className="hover:bg-muted/50">
<TableCell>
<div className="min-w-0">
<div className="font-medium truncate">
{extraction.title || 'Extracting...'}
</div>
<div className="text-sm text-muted-foreground truncate max-w-64">
{extraction.url}
</div>
</div>
</TableCell>
<TableCell className="text-center">
{getServiceBadge(extraction.service)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{extraction.user_name || 'Unknown'}
</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
{getStatusBadge(extraction.status)}
{extraction.error && (
<div
className="text-xs text-destructive max-w-48 truncate"
title={extraction.error}
>
{extraction.error}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDateDistanceToNow(extraction.created_at)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="sm" asChild>
<a
href={extraction.url}
target="_blank"
rel="noopener noreferrer"
title="Open original URL"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,40 @@
import { ExtractionsRow } from '@/components/extractions/ExtractionsRow'
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { ExtractionInfo } from '@/lib/api/services/extractions'
interface ExtractionsTableProps {
extractions: ExtractionInfo[]
}
export function ExtractionsTable({ extractions }: ExtractionsTableProps) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead className="text-center">Service</TableHead>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extractions.map(extraction => (
<ExtractionsRow
key={extraction.id}
extraction={extraction}
/>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -4,15 +4,22 @@ import { TableCell, TableRow } from '@/components/ui/table'
import type { Playlist } from '@/lib/api/services/playlists'
import { formatDateDistanceToNow } from '@/utils/format-date'
import { formatDuration } from '@/utils/format-duration'
import { Calendar, Clock, Edit, Music, Play, User } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Calendar, Clock, Edit, Heart, Music, Play, User } from 'lucide-react'
interface PlaylistRowProps {
playlist: Playlist
onEdit: (playlist: Playlist) => void
onSetCurrent: (playlist: Playlist) => void
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
}
export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps) {
export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistRowProps) {
const handleFavoriteToggle = () => {
if (onFavoriteToggle) {
onFavoriteToggle(playlist.id, !playlist.is_favorited)
}
}
return (
<TableRow className="hover:bg-muted/50">
<TableCell>
@@ -76,6 +83,24 @@ export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
{onFavoriteToggle && (
<Button
size="sm"
variant="ghost"
onClick={handleFavoriteToggle}
className="h-8 w-8 p-0"
title={playlist.is_favorited ? "Remove from favorites" : "Add to favorites"}
>
<Heart
className={cn(
'h-4 w-4 transition-all duration-200',
playlist.is_favorited
? 'fill-current text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
/>
</Button>
)}
<Button
size="sm"
variant="ghost"

View File

@@ -12,9 +12,10 @@ interface PlaylistTableProps {
playlists: Playlist[]
onEdit: (playlist: Playlist) => void
onSetCurrent: (playlist: Playlist) => void
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
}
export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTableProps) {
export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistTableProps) {
return (
<div className="rounded-md border">
<Table>
@@ -37,6 +38,7 @@ export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTable
playlist={playlist}
onEdit={onEdit}
onSetCurrent={onSetCurrent}
onFavoriteToggle={onFavoriteToggle}
/>
))}
</TableBody>

View File

@@ -8,7 +8,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import type { PlaylistSortField, SortOrder } from '@/lib/api/services/playlists'
import { Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
import { Heart, Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
interface PlaylistsHeaderProps {
searchQuery: string
@@ -22,6 +22,8 @@ interface PlaylistsHeaderProps {
loading: boolean
error: string | null
playlistCount: number
showFavoritesOnly: boolean
onFavoritesToggle: (show: boolean) => void
}
export function PlaylistsHeader({
@@ -36,6 +38,8 @@ export function PlaylistsHeader({
loading,
error,
playlistCount,
showFavoritesOnly,
onFavoritesToggle,
}: PlaylistsHeaderProps) {
return (
<>
@@ -50,7 +54,10 @@ export function PlaylistsHeader({
<div className="flex items-center gap-4">
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{playlistCount} playlist{playlistCount !== 1 ? 's' : ''}
{showFavoritesOnly
? `${playlistCount} favorite playlist${playlistCount !== 1 ? 's' : ''}`
: `${playlistCount} playlist${playlistCount !== 1 ? 's' : ''}`
}
</div>
)}
<Button onClick={onCreateClick}>
@@ -116,6 +123,15 @@ export function PlaylistsHeader({
)}
</Button>
<Button
variant={showFavoritesOnly ? "default" : "outline"}
size="icon"
onClick={() => onFavoritesToggle(!showFavoritesOnly)}
title={showFavoritesOnly ? "Show all playlists" : "Show only favorites"}
>
<Heart className={`h-4 w-4 ${showFavoritesOnly ? 'fill-current' : ''}`} />
</Button>
<Button
variant="outline"
size="icon"

View File

@@ -1,5 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, Music } from 'lucide-react'
import { AlertCircle } from 'lucide-react'
interface PlaylistsLoadingProps {
count?: number
@@ -39,19 +39,25 @@ export function PlaylistsError({ error, onRetry }: PlaylistsErrorProps) {
interface PlaylistsEmptyProps {
searchQuery: string
showFavoritesOnly?: boolean
}
export function PlaylistsEmpty({ searchQuery }: PlaylistsEmptyProps) {
export function PlaylistsEmpty({ searchQuery, showFavoritesOnly = false }: PlaylistsEmptyProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Music className="h-6 w-6 text-muted-foreground" />
<span className="text-2xl">{showFavoritesOnly ? '💝' : '🎵'}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No playlists found</h3>
<h3 className="text-lg font-semibold mb-2">
{showFavoritesOnly ? 'No favorite playlists found' : 'No playlists found'}
</h3>
<p className="text-muted-foreground">
{searchQuery
{showFavoritesOnly
? 'You haven\'t favorited any playlists yet. Click the heart icon on playlists to add them to your favorites.'
: searchQuery
? 'No playlists match your search criteria.'
: 'No playlists are available.'}
: 'No playlists are available.'
}
</p>
</div>
)

View File

@@ -1,17 +1,21 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Playlist } from '@/lib/api/services/playlists'
import { Heart } from 'lucide-react'
import { cn } from '@/lib/utils'
interface PlaylistEditHeaderProps {
playlist: Playlist
isEditMode: boolean
onSetCurrent: () => void
onFavoriteToggle?: (playlistId: number, shouldFavorite: boolean) => void
}
export function PlaylistEditHeader({
playlist,
isEditMode,
onSetCurrent,
onFavoriteToggle,
}: PlaylistEditHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
@@ -25,6 +29,24 @@ export function PlaylistEditHeader({
</div>
<div className="flex items-center gap-2">
{onFavoriteToggle && (
<Button
variant="ghost"
size="sm"
onClick={() => onFavoriteToggle(playlist.id, !playlist.is_favorited)}
className="h-8 w-8 p-0"
title={playlist.is_favorited ? "Remove from favorites" : "Add to favorites"}
>
<Heart
className={cn(
'h-4 w-4 transition-all duration-200',
playlist.is_favorited
? 'fill-current text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
/>
</Button>
)}
{!playlist.is_current && !isEditMode && (
<Button variant="outline" onClick={onSetCurrent}>
Set as Current

View File

@@ -4,43 +4,83 @@ import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { formatSize } from '@/utils/format-size'
import NumberFlow from '@number-flow/react'
import { Clock, Play, Weight } from 'lucide-react'
import { Clock, Heart, Play, Weight } from 'lucide-react'
interface SoundCardProps {
sound: Sound
playSound: (sound: Sound) => void
onFavoriteToggle: (soundId: number, isFavorited: boolean) => void
colorClasses: string
}
export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
const handlePlaySound = () => {
export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }: SoundCardProps) {
const handlePlaySound = (e: React.MouseEvent) => {
// Don't play sound if clicking on favorite button
if ((e.target as HTMLElement).closest('[data-favorite-button]')) {
return
}
playSound(sound)
}
const handleFavoriteToggle = (e: React.MouseEvent) => {
e.stopPropagation()
onFavoriteToggle(sound.id, !sound.is_favorited)
}
return (
<Card
onClick={handlePlaySound}
className={cn(
'py-2 transition-all duration-100 shadow-sm cursor-pointer active:scale-95',
'py-2 transition-all duration-100 shadow-sm cursor-pointer active:scale-95 relative',
colorClasses,
)}
>
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
<h3 className="font-medium text-s truncate">{sound.name}</h3>
<div className="grid grid-cols-3 gap-1 text-xs text-muted-foreground">
{/* Favorite button */}
<button
data-favorite-button
onClick={handleFavoriteToggle}
className={cn(
'absolute top-2 right-2 p-1 rounded-full transition-all duration-200 hover:scale-110',
'bg-background/80 hover:bg-background/90 shadow-sm',
sound.is_favorited
? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
title={sound.is_favorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={cn(
'h-3.5 w-3.5 transition-all duration-200',
sound.is_favorited && 'fill-current'
)}
/>
</button>
<h3 className="font-medium text-s truncate pr-8">{sound.name}</h3>
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<div className="flex">
<Clock className="h-3.5 w-3.5 mr-0.5" />
<span>{formatDuration(sound.duration)}</span>
</div>
<div className="flex justify-center">
<Weight className="h-3.5 w-3.5 mr-0.5" />
<span>{formatSize(sound.size)}</span>
</div>
<div className="flex justify-end items-center">
<Play className="h-3.5 w-3.5 mr-0.5" />
<NumberFlow value={sound.play_count} />
</div>
</div>
{/* Show favorite count if > 0 */}
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<div className="flex">
<Weight className="h-3.5 w-3.5 mr-0.5" />
<span>{formatSize(sound.size)}</span>
</div>
<div className="flex justify-end items-center text-xs text-muted-foreground">
<Heart className="h-3.5 w-3.5 mr-0.5" />
<NumberFlow value={sound.favorite_count} />
</div>
</div>
</CardContent>
</Card>
)

View File

@@ -94,6 +94,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
})
newSocket.on('sound_favorited', data => {
soundEvents.emit(SOUND_EVENTS.SOUND_FAVORITED, data)
})
// Listen for user events and emit them locally
newSocket.on('user_credits_changed', data => {
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)

View File

@@ -23,6 +23,23 @@ export interface MessageResponse {
message: string
}
export interface GetUsersParams {
page?: number
limit?: number
search?: string
sort_by?: string
sort_order?: string
status_filter?: string
}
export interface GetUsersResponse {
users: User[]
total: number
page: number
limit: number
total_pages: number
}
export interface ScanResults {
added: number
updated: number
@@ -54,10 +71,32 @@ export interface NormalizationResponse {
}
export class AdminService {
async listUsers(limit = 100, offset = 0): Promise<User[]> {
return apiClient.get<User[]>(`/api/v1/admin/users/`, {
params: { limit, offset },
})
async listUsers(params?: GetUsersParams): Promise<GetUsersResponse> {
const searchParams = new URLSearchParams()
if (params?.page) {
searchParams.append('page', params.page.toString())
}
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
if (params?.search) {
searchParams.append('search', params.search)
}
if (params?.sort_by) {
searchParams.append('sort_by', params.sort_by)
}
if (params?.sort_order) {
searchParams.append('sort_order', params.sort_order)
}
if (params?.status_filter) {
searchParams.append('status_filter', params.status_filter)
}
const url = searchParams.toString()
? `/api/v1/admin/users/?${searchParams.toString()}`
: '/api/v1/admin/users/'
return apiClient.get<GetUsersResponse>(url)
}
async getUser(userId: number): Promise<User> {

View File

@@ -9,6 +9,7 @@ export interface ExtractionInfo {
service_id?: string
sound_id?: number
user_id: number
user_name?: string
error?: string
created_at: string
updated_at: string
@@ -21,6 +22,23 @@ export interface CreateExtractionResponse {
export interface GetExtractionsResponse {
extractions: ExtractionInfo[]
total: number
page: number
limit: number
total_pages: number
}
export type ExtractionSortField = 'title' | 'status' | 'service' | 'created_at' | 'updated_at'
export type ExtractionSortOrder = 'asc' | 'desc'
export type ExtractionStatus = 'pending' | 'processing' | 'completed' | 'failed'
export interface GetExtractionsParams {
search?: string
sort_by?: ExtractionSortField
sort_order?: ExtractionSortOrder
status_filter?: ExtractionStatus
page?: number
limit?: number
}
export class ExtractionsService {
@@ -44,14 +62,68 @@ export class ExtractionsService {
return response
}
/**
* Get all extractions
*/
async getAllExtractions(params?: GetExtractionsParams): Promise<GetExtractionsResponse> {
const searchParams = new URLSearchParams()
if (params?.search) {
searchParams.append('search', params.search)
}
if (params?.sort_by) {
searchParams.append('sort_by', params.sort_by)
}
if (params?.sort_order) {
searchParams.append('sort_order', params.sort_order)
}
if (params?.status_filter) {
searchParams.append('status_filter', params.status_filter)
}
if (params?.page) {
searchParams.append('page', params.page.toString())
}
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
const queryString = searchParams.toString()
const url = queryString ? `/api/v1/extractions/?${queryString}` : '/api/v1/extractions/'
const response = await apiClient.get<GetExtractionsResponse>(url)
return response
}
/**
* Get user's extractions
*/
async getUserExtractions(): Promise<ExtractionInfo[]> {
const response = await apiClient.get<GetExtractionsResponse>(
'/api/v1/extractions/',
)
return response.extractions
async getUserExtractions(params?: GetExtractionsParams): Promise<GetExtractionsResponse> {
const searchParams = new URLSearchParams()
if (params?.search) {
searchParams.append('search', params.search)
}
if (params?.sort_by) {
searchParams.append('sort_by', params.sort_by)
}
if (params?.sort_order) {
searchParams.append('sort_order', params.sort_order)
}
if (params?.status_filter) {
searchParams.append('status_filter', params.status_filter)
}
if (params?.page) {
searchParams.append('page', params.page.toString())
}
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
const queryString = searchParams.toString()
const url = queryString ? `/api/v1/extractions/user?${queryString}` : '/api/v1/extractions/user'
const response = await apiClient.get<GetExtractionsResponse>(url)
return response
}
}

View File

@@ -0,0 +1,112 @@
import { apiClient } from '../client'
export interface Favorite {
id: number
user_id: number
sound_id?: number
playlist_id?: number
created_at: string
updated_at: string
}
export interface FavoriteCountsResponse {
total: number
sounds: number
playlists: number
}
export interface FavoritesListResponse {
favorites: Favorite[]
}
export class FavoritesService {
/**
* Add a sound to favorites
*/
async addSoundFavorite(soundId: number): Promise<Favorite> {
const response = await apiClient.post<Favorite>(`/api/v1/favorites/sounds/${soundId}`)
return response
}
/**
* Remove a sound from favorites
*/
async removeSoundFavorite(soundId: number): Promise<void> {
await apiClient.delete(`/api/v1/favorites/sounds/${soundId}`)
}
/**
* Add a playlist to favorites
*/
async addPlaylistFavorite(playlistId: number): Promise<Favorite> {
const response = await apiClient.post<Favorite>(`/api/v1/favorites/playlists/${playlistId}`)
return response
}
/**
* Remove a playlist from favorites
*/
async removePlaylistFavorite(playlistId: number): Promise<void> {
await apiClient.delete(`/api/v1/favorites/playlists/${playlistId}`)
}
/**
* Get all favorites for the current user
*/
async getFavorites(limit = 50, offset = 0): Promise<Favorite[]> {
const response = await apiClient.get<FavoritesListResponse>(
`/api/v1/favorites/?limit=${limit}&offset=${offset}`
)
return response.favorites
}
/**
* Get sound favorites for the current user
*/
async getSoundFavorites(limit = 50, offset = 0): Promise<Favorite[]> {
const response = await apiClient.get<FavoritesListResponse>(
`/api/v1/favorites/sounds?limit=${limit}&offset=${offset}`
)
return response.favorites
}
/**
* Get playlist favorites for the current user
*/
async getPlaylistFavorites(limit = 50, offset = 0): Promise<Favorite[]> {
const response = await apiClient.get<FavoritesListResponse>(
`/api/v1/favorites/playlists?limit=${limit}&offset=${offset}`
)
return response.favorites
}
/**
* Get favorite counts for the current user
*/
async getFavoriteCounts(): Promise<FavoriteCountsResponse> {
const response = await apiClient.get<FavoriteCountsResponse>('/api/v1/favorites/counts')
return response
}
/**
* Check if a sound is favorited
*/
async isSoundFavorited(soundId: number): Promise<boolean> {
const response = await apiClient.get<{ is_favorited: boolean }>(
`/api/v1/favorites/sounds/${soundId}/check`
)
return response.is_favorited
}
/**
* Check if a playlist is favorited
*/
async isPlaylistFavorited(playlistId: number): Promise<boolean> {
const response = await apiClient.get<{ is_favorited: boolean }>(
`/api/v1/favorites/playlists/${playlistId}/check`
)
return response.is_favorited
}
}
export const favoritesService = new FavoritesService()

View File

@@ -3,3 +3,4 @@ export * from './sounds'
export * from './player'
export * from './files'
export * from './extractions'
export * from './favorites'

View File

@@ -19,6 +19,8 @@ export interface Playlist {
is_main: boolean
is_current: boolean
is_deletable: boolean
is_favorited: boolean
favorite_count: number
created_at: string
updated_at: string | null
sound_count: number
@@ -43,15 +45,24 @@ export interface GetPlaylistsParams {
search?: string
sort_by?: PlaylistSortField
sort_order?: SortOrder
page?: number
limit?: number
offset?: number
favorites_only?: boolean
}
export interface GetPlaylistsResponse {
playlists: Playlist[]
total: number
page: number
limit: number
total_pages: number
}
export class PlaylistsService {
/**
* Get all playlists with optional filtering, searching, and sorting
*/
async getPlaylists(params?: GetPlaylistsParams): Promise<Playlist[]> {
async getPlaylists(params?: GetPlaylistsParams): Promise<GetPlaylistsResponse> {
const searchParams = new URLSearchParams()
// Handle parameters
@@ -64,17 +75,20 @@ export class PlaylistsService {
if (params?.sort_order) {
searchParams.append('sort_order', params.sort_order)
}
if (params?.page) {
searchParams.append('page', params.page.toString())
}
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
if (params?.offset) {
searchParams.append('offset', params.offset.toString())
if (params?.favorites_only) {
searchParams.append('favorites_only', 'true')
}
const url = searchParams.toString()
? `/api/v1/playlists/?${searchParams.toString()}`
: '/api/v1/playlists/'
return apiClient.get<Playlist[]>(url)
return apiClient.get<GetPlaylistsResponse>(url)
}
/**

View File

@@ -17,6 +17,8 @@ export interface Sound {
thumbnail?: string
is_music: boolean
is_deletable: boolean
is_favorited: boolean
favorite_count: number
created_at: string
updated_at: string
}
@@ -39,6 +41,7 @@ export interface GetSoundsParams {
sort_order?: SortOrder
limit?: number
offset?: number
favorites_only?: boolean
}
export interface GetSoundsResponse {
@@ -75,6 +78,9 @@ export class SoundsService {
if (params?.offset) {
searchParams.append('offset', params.offset.toString())
}
if (params?.favorites_only) {
searchParams.append('favorites_only', 'true')
}
const url = searchParams.toString()
? `/api/v1/sounds/?${searchParams.toString()}`

View File

@@ -53,6 +53,7 @@ export const PLAYER_EVENTS = {
// Sound event types
export const SOUND_EVENTS = {
SOUND_PLAYED: 'sound_played',
SOUND_FAVORITED: 'sound_favorited',
} as const
// User event types

View File

@@ -1,68 +1,102 @@
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 { AppPagination } from '@/components/AppPagination'
import { CreateExtractionDialog } from '@/components/extractions/CreateExtractionDialog'
import { ExtractionsHeader } from '@/components/extractions/ExtractionsHeader'
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'
ExtractionsEmpty,
ExtractionsError,
ExtractionsLoading,
} from '@/components/extractions/ExtractionsLoadingStates'
import { ExtractionsTable } from '@/components/extractions/ExtractionsTable'
import {
type ExtractionInfo,
type ExtractionSortField,
type ExtractionSortOrder,
type ExtractionStatus,
extractionsService,
} from '@/lib/api/services/extractions'
import { formatDateDistanceToNow } from '@/utils/format-date'
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[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [url, setUrl] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Load extractions
const loadExtractions = async () => {
// Search, sorting, and filtering state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<ExtractionSortField>('created_at')
const [sortOrder, setSortOrder] = useState<ExtractionSortOrder>('desc')
const [statusFilter, setStatusFilter] = useState<ExtractionStatus | 'all'>('all')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [pageSize, setPageSize] = useState(10)
// Create extraction dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [url, setUrl] = useState('')
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchExtractions = async () => {
try {
setIsLoading(true)
const data = await extractionsService.getUserExtractions()
setExtractions(data)
} catch (error) {
console.error('Failed to load extractions:', error)
toast.error('Failed to load extractions')
setLoading(true)
setError(null)
const response = await extractionsService.getAllExtractions({
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
status_filter: statusFilter !== 'all' ? statusFilter : undefined,
page: currentPage,
limit: pageSize,
})
setExtractions(response.extractions)
setTotalPages(response.total_pages)
setTotalCount(response.total)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch extractions'
setError(errorMessage)
toast.error(errorMessage)
} finally {
setIsLoading(false)
setLoading(false)
}
}
useEffect(() => {
loadExtractions()
}, [])
fetchExtractions()
}, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, currentPage, pageSize])
// Reset to page 1 when filters change
useEffect(() => {
if (currentPage !== 1) {
setCurrentPage(1)
}
}, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, pageSize])
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePageSizeChange = (size: number) => {
setPageSize(size)
setCurrentPage(1) // Reset to first page when changing page size
}
// Create new extraction
const handleCreateExtraction = async () => {
if (!url.trim()) {
toast.error('Please enter a URL')
@@ -70,76 +104,56 @@ export function ExtractionsPage() {
}
try {
setIsCreating(true)
setCreateLoading(true)
const response = await extractionsService.createExtraction(url.trim())
toast.success(response.message)
// Reset form and close dialog
setUrl('')
setIsDialogOpen(false)
// Refresh the list
await loadExtractions()
} catch (error) {
console.error('Failed to create extraction:', error)
toast.error('Failed to create extraction')
setShowCreateDialog(false)
// Refresh the extractions list
fetchExtractions()
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create extraction'
toast.error(errorMessage)
} finally {
setIsCreating(false)
setCreateLoading(false)
}
}
const getStatusBadge = (status: ExtractionInfo['status']) => {
switch (status) {
case 'pending':
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>
)
case 'completed':
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>
)
}
const handleCancelCreate = () => {
setUrl('')
setShowCreateDialog(false)
}
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',
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',
const renderContent = () => {
if (loading) {
return <ExtractionsLoading />
}
const colorClass =
serviceColors[service.toLowerCase()] ||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
if (error) {
return <ExtractionsError error={error} onRetry={fetchExtractions} />
}
if (extractions.length === 0) {
return <ExtractionsEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
}
return (
<Badge variant="outline" className={colorClass}>
{service.toUpperCase()}
</Badge>
<div className="space-y-4">
<ExtractionsTable extractions={extractions} />
<AppPagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
itemName="extractions"
/>
</div>
)
}
@@ -150,163 +164,33 @@ export function ExtractionsPage() {
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Audio Extractions</h1>
<p className="text-muted-foreground">
Extract audio from YouTube, SoundCloud, and other platforms
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Add Extraction
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Extraction</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="url">URL</Label>
<Input
id="url"
placeholder="https://www.youtube.com/watch?v=..."
value={url}
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !isCreating) {
handleCreateExtraction()
}
}}
<ExtractionsHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={fetchExtractions}
onCreateClick={() => setShowCreateDialog(true)}
loading={loading}
error={error}
extractionCount={totalCount}
/>
<p className="text-sm text-muted-foreground mt-1">
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
Instagram, and more
</p>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateExtraction}
disabled={isCreating}
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Extraction'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading extractions...
</div>
) : extractions.length === 0 ? (
<Card>
<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>
<p className="text-muted-foreground mb-4">
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" />
Add Your First Extraction
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Recent Extractions ({extractions.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Service</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extractions.map(extraction => (
<TableRow key={extraction.id}>
<TableCell>
<div>
<div className="font-medium">
{extraction.title || 'Extracting...'}
</div>
<div className="text-sm text-muted-foreground truncate max-w-64">
{extraction.url}
</div>
</div>
</TableCell>
<TableCell>
{getServiceBadge(extraction.service)}
</TableCell>
<TableCell>
{getStatusBadge(extraction.status)}
{extraction.error && (
<div
className="text-xs text-destructive mt-1 max-w-48 truncate"
title={extraction.error}
>
{extraction.error}
</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDateDistanceToNow(extraction.created_at)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" asChild>
<a
href={extraction.url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<CreateExtractionDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
loading={createLoading}
url={url}
onUrlChange={setUrl}
onSubmit={handleCreateExtraction}
onCancel={handleCancelCreate}
/>
{renderContent()}
</div>
</AppLayout>
)

View File

@@ -30,6 +30,7 @@ import {
playlistsService,
} from '@/lib/api/services/playlists'
import { type Sound, soundsService } from '@/lib/api/services/sounds'
import { favoritesService } from '@/lib/api/services/favorites'
import {
DndContext,
type DragEndEvent,
@@ -235,6 +236,37 @@ export function PlaylistEditPage() {
}
}
const handleFavoriteToggle = async (playlistId: number, shouldFavorite: boolean) => {
try {
if (shouldFavorite) {
await favoritesService.addPlaylistFavorite(playlistId)
toast.success('Added to favorites')
} else {
await favoritesService.removePlaylistFavorite(playlistId)
toast.success('Removed from favorites')
}
// Update the playlist in the local state
setPlaylist(prevPlaylist =>
prevPlaylist
? {
...prevPlaylist,
is_favorited: shouldFavorite,
favorite_count: shouldFavorite
? prevPlaylist.favorite_count + 1
: Math.max(0, prevPlaylist.favorite_count - 1),
}
: null,
)
} catch (error) {
toast.error(
`Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
const handleMoveSoundUp = async (index: number) => {
if (index === 0 || sounds.length < 2) return
@@ -574,6 +606,7 @@ export function PlaylistEditPage() {
playlist={playlist}
isEditMode={isEditMode}
onSetCurrent={handleSetCurrent}
onFavoriteToggle={handleFavoriteToggle}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@@ -1,4 +1,5 @@
import { AppLayout } from '@/components/AppLayout'
import { AppPagination } from '@/components/AppPagination'
import { CreatePlaylistDialog } from '@/components/playlists/CreatePlaylistDialog'
import { PlaylistsHeader } from '@/components/playlists/PlaylistsHeader'
import {
@@ -13,6 +14,7 @@ import {
type SortOrder,
playlistsService,
} from '@/lib/api/services/playlists'
import { favoritesService } from '@/lib/api/services/favorites'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { toast } from 'sonner'
@@ -27,6 +29,13 @@ export function PlaylistsPage() {
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [pageSize, setPageSize] = useState(10)
// Create playlist dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
@@ -52,12 +61,17 @@ export function PlaylistsPage() {
try {
setLoading(true)
setError(null)
const playlistData = await playlistsService.getPlaylists({
const response = await playlistsService.getPlaylists({
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
favorites_only: showFavoritesOnly,
page: currentPage,
limit: pageSize,
})
setPlaylists(playlistData)
setPlaylists(response.playlists)
setTotalPages(response.total_pages)
setTotalCount(response.total)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch playlists'
@@ -70,7 +84,23 @@ export function PlaylistsPage() {
useEffect(() => {
fetchPlaylists()
}, [debouncedSearchQuery, sortBy, sortOrder])
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, currentPage, pageSize])
// Reset to page 1 when filters change
useEffect(() => {
if (currentPage !== 1) {
setCurrentPage(1)
}
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, pageSize])
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePageSizeChange = (size: number) => {
setPageSize(size)
setCurrentPage(1) // Reset to first page when changing page size
}
const handleCreatePlaylist = async () => {
if (!newPlaylist.name.trim()) {
@@ -126,6 +156,39 @@ export function PlaylistsPage() {
navigate(`/playlists/${playlist.id}/edit`)
}
const handleFavoriteToggle = async (playlistId: number, shouldFavorite: boolean) => {
try {
if (shouldFavorite) {
await favoritesService.addPlaylistFavorite(playlistId)
toast.success('Added to favorites')
} else {
await favoritesService.removePlaylistFavorite(playlistId)
toast.success('Removed from favorites')
}
// Update the playlist in the local state
setPlaylists(prevPlaylists =>
prevPlaylists.map(playlist =>
playlist.id === playlistId
? {
...playlist,
is_favorited: shouldFavorite,
favorite_count: shouldFavorite
? playlist.favorite_count + 1
: Math.max(0, playlist.favorite_count - 1),
}
: playlist,
),
)
} catch (error) {
toast.error(
`Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
const renderContent = () => {
if (loading) {
return <PlaylistsLoading />
@@ -136,15 +199,27 @@ export function PlaylistsPage() {
}
if (playlists.length === 0) {
return <PlaylistsEmpty searchQuery={searchQuery} />
return <PlaylistsEmpty searchQuery={searchQuery} showFavoritesOnly={showFavoritesOnly} />
}
return (
<div className="space-y-4">
<PlaylistTable
playlists={playlists}
onEdit={handleEditPlaylist}
onSetCurrent={handleSetCurrent}
onFavoriteToggle={handleFavoriteToggle}
/>
<AppPagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
itemName="playlists"
/>
</div>
)
}
@@ -166,7 +241,9 @@ export function PlaylistsPage() {
onCreateClick={() => setShowCreateDialog(true)}
loading={loading}
error={error}
playlistCount={playlists.length}
playlistCount={totalCount}
showFavoritesOnly={showFavoritesOnly}
onFavoritesToggle={setShowFavoritesOnly}
/>
<CreatePlaylistDialog

View File

@@ -17,9 +17,11 @@ import {
type SoundSortField,
soundsService,
} from '@/lib/api/services/sounds'
import { favoritesService } from '@/lib/api/services/favorites'
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
import {
AlertCircle,
Heart,
RefreshCw,
Search,
SortAsc,
@@ -37,6 +39,14 @@ interface SoundPlayedEventData {
play_count: number
}
interface SoundFavoritedEventData {
sound_id: number
sound_name: string
user_id: number
user_name: string
favorite_count: number
}
const lightModeColors = [
'bg-red-600/30 hover:bg-red-600/40 text-red-900 border-red-600/20',
'bg-blue-700/30 hover:bg-blue-700/40 text-blue-900 border-blue-700/20',
@@ -77,6 +87,7 @@ export function SoundsPage() {
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<SoundSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
const handlePlaySound = async (sound: Sound) => {
try {
@@ -89,6 +100,36 @@ export function SoundsPage() {
}
}
const handleFavoriteToggle = async (soundId: number, shouldFavorite: boolean) => {
try {
if (shouldFavorite) {
await favoritesService.addSoundFavorite(soundId)
toast.success('Added to favorites')
} else {
await favoritesService.removeSoundFavorite(soundId)
toast.success('Removed from favorites')
}
// Update the sound in the local state
setSounds(prevSounds =>
prevSounds.map(sound =>
sound.id === soundId
? {
...sound,
is_favorited: shouldFavorite,
}
: sound,
),
)
} catch (error) {
toast.error(
`Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
const { theme } = useTheme()
useEffect(() => {
@@ -112,6 +153,7 @@ export function SoundsPage() {
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
favorites_only: showFavoritesOnly,
})
setSounds(sdbSounds)
} catch (err) {
@@ -137,7 +179,7 @@ export function SoundsPage() {
useEffect(() => {
fetchSounds()
}, [debouncedSearchQuery, sortBy, sortOrder])
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
// Listen for sound_played events and update play_count
useEffect(() => {
@@ -159,6 +201,29 @@ export function SoundsPage() {
}
}, [])
// Listen for sound_favorited events and update favorite status and count
useEffect(() => {
const handleSoundFavorited = (...args: unknown[]) => {
const eventData = args[0] as SoundFavoritedEventData
setSounds(prevSounds =>
prevSounds.map(sound =>
sound.id === eventData.sound_id
? {
...sound,
favorite_count: eventData.favorite_count
}
: sound,
),
)
}
soundEvents.on(SOUND_EVENTS.SOUND_FAVORITED, handleSoundFavorited)
return () => {
soundEvents.off(SOUND_EVENTS.SOUND_FAVORITED, handleSoundFavorited)
}
}, [])
const renderContent = () => {
if (loading) {
return (
@@ -192,11 +257,16 @@ export function SoundsPage() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<span className="text-2xl">🎵</span>
<span className="text-2xl">{showFavoritesOnly ? '💝' : '🎵'}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No sounds found</h3>
<h3 className="text-lg font-semibold mb-2">
{showFavoritesOnly ? 'No favorite sounds found' : 'No sounds found'}
</h3>
<p className="text-muted-foreground">
No SDB type sounds are available in your library.
{showFavoritesOnly
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
: 'No SDB type sounds are available in your library.'
}
</p>
</div>
)
@@ -209,6 +279,7 @@ export function SoundsPage() {
key={sound.id}
sound={sound}
playSound={handlePlaySound}
onFavoriteToggle={handleFavoriteToggle}
colorClasses={getSoundColor(idx)}
/>
))}
@@ -232,7 +303,10 @@ export function SoundsPage() {
</div>
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{sounds.length} sound{sounds.length !== 1 ? 's' : ''}
{showFavoritesOnly
? `${sounds.length} favorite sound${sounds.length !== 1 ? 's' : ''}`
: `${sounds.length} sound${sounds.length !== 1 ? 's' : ''}`
}
</div>
)}
</div>
@@ -293,6 +367,15 @@ export function SoundsPage() {
)}
</Button>
<Button
variant={showFavoritesOnly ? "default" : "outline"}
size="icon"
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
title={showFavoritesOnly ? "Show all sounds" : "Show only favorites"}
>
<Heart className={`h-4 w-4 ${showFavoritesOnly ? 'fill-current' : ''}`} />
</Button>
<Button
variant="outline"
size="icon"

View File

@@ -1,7 +1,14 @@
import { AppLayout } from '@/components/AppLayout'
import { AppPagination } from '@/components/AppPagination'
import { UsersHeader, type UserSortField, type SortOrder, type UserStatus } from '@/components/admin/UsersHeader'
import {
UsersEmpty,
UsersError,
UsersLoading,
} from '@/components/admin/UsersLoadingStates'
import { UsersTable } from '@/components/admin/UsersTable'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -12,20 +19,10 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
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 { formatDate } from '@/utils/format-date'
import { Edit, UserCheck, UserX } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -40,6 +37,21 @@ export function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and filtering state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<UserSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [statusFilter, setStatusFilter] = useState<UserStatus>('all')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [pageSize, setPageSize] = useState(10)
// Edit user state
const [editingUser, setEditingUser] = useState<User | null>(null)
const [editData, setEditData] = useState<EditUserData>({
name: '',
@@ -49,26 +61,65 @@ export function UsersPage() {
})
const [saving, setSaving] = useState(false)
useEffect(() => {
loadData()
}, [])
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
const loadData = async () => {
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchUsers = async () => {
try {
const [usersData, plansData] = await Promise.all([
adminService.listUsers(),
setLoading(true)
setError(null)
const [usersResponse, plansData] = await Promise.all([
adminService.listUsers({
page: currentPage,
limit: pageSize,
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
status_filter: statusFilter !== 'all' ? statusFilter : undefined,
}),
adminService.listPlans(),
])
setUsers(usersData)
setUsers(usersResponse.users)
setTotalPages(usersResponse.total_pages)
setTotalCount(usersResponse.total)
setPlans(plansData)
} catch (error) {
toast.error('Failed to load data')
console.error('Error loading data:', error)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load users'
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, currentPage, pageSize])
// Reset to page 1 when filters change
useEffect(() => {
if (currentPage !== 1) {
setCurrentPage(1)
}
}, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, pageSize])
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePageSizeChange = (size: number) => {
setPageSize(size)
setCurrentPage(1) // Reset to first page when changing page size
}
const handleEditUser = (user: User) => {
setEditingUser(user)
setEditData({
@@ -111,7 +162,7 @@ export function UsersPage() {
toast.success('User enabled successfully')
}
// Reload data to get updated user status
loadData()
fetchUsers()
} catch (error) {
toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`)
console.error('Error toggling user status:', error)
@@ -126,47 +177,36 @@ export function UsersPage() {
)
}
const getStatusBadge = (isActive: boolean) => {
return (
<Badge variant={isActive ? 'default' : 'secondary'}>
{isActive ? 'Active' : 'Inactive'}
</Badge>
)
}
const renderContent = () => {
if (loading) {
return <UsersLoading />
}
if (error) {
return <UsersError error={error} onRetry={fetchUsers} />
}
if (users.length === 0) {
return <UsersEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
}
return (
<AppLayout
breadcrumb={{
items: [
{ label: 'Dashboard', href: '/' },
{ label: 'Admin' },
{ label: 'Users' },
],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6">
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64 mt-1" />
<div className="space-y-4">
<UsersTable
users={users}
onEdit={handleEditUser}
onToggleStatus={handleToggleUserStatus}
/>
<AppPagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
itemName="users"
/>
</div>
<Skeleton className="h-10 w-32" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
</AppLayout>
)
}
@@ -181,72 +221,22 @@ export function UsersPage() {
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<Button onClick={loadData} disabled={loading}>
Refresh
</Button>
</div>
<UsersHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={fetchUsers}
loading={loading}
error={error}
userCount={totalCount}
/>
<Card>
<CardHeader>
<CardTitle>Users ({users.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>{user.plan.name}</TableCell>
<TableCell>{user.credits.toLocaleString()}</TableCell>
<TableCell>{getStatusBadge(user.is_active)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleUserStatus(user)}
>
{user.is_active ? (
<UserX className="h-4 w-4" />
) : (
<UserCheck className="h-4 w-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{renderContent()}
</div>
{/* Edit User Sheet */}