Compare commits
10 Commits
ecb17e9f94
...
b76b34ea4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76b34ea4f | ||
|
|
46bfcad271 | ||
|
|
75ecd26e06 | ||
|
|
04401092bb | ||
|
|
ed888dd8d1 | ||
|
|
0024f1d647 | ||
|
|
af1d543669 | ||
|
|
ad466e2f91 | ||
|
|
1027a67e37 | ||
|
|
2e41d5b695 |
147
src/components/AppPagination.tsx
Normal file
147
src/components/AppPagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
src/components/admin/UsersHeader.tsx
Normal file
156
src/components/admin/UsersHeader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
112
src/components/admin/UsersLoadingStates.tsx
Normal file
112
src/components/admin/UsersLoadingStates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/components/admin/UsersTable.tsx
Normal file
88
src/components/admin/UsersTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
src/components/extractions/CreateExtractionDialog.tsx
Normal file
82
src/components/extractions/CreateExtractionDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
src/components/extractions/ExtractionsHeader.tsx
Normal file
157
src/components/extractions/ExtractionsHeader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
67
src/components/extractions/ExtractionsLoadingStates.tsx
Normal file
67
src/components/extractions/ExtractionsLoadingStates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
src/components/extractions/ExtractionsRow.tsx
Normal file
135
src/components/extractions/ExtractionsRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/extractions/ExtractionsTable.tsx
Normal file
40
src/components/extractions/ExtractionsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
? 'No playlists match your search criteria.'
|
||||
: 'No playlists are available.'}
|
||||
{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.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
src/lib/api/services/favorites.ts
Normal file
112
src/lib/api/services/favorites.ts
Normal 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()
|
||||
@@ -3,3 +3,4 @@ export * from './sounds'
|
||||
export * from './player'
|
||||
export * from './files'
|
||||
export * from './extractions'
|
||||
export * from './favorites'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<CreateExtractionDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
loading={createLoading}
|
||||
url={url}
|
||||
onUrlChange={setUrl}
|
||||
onSubmit={handleCreateExtraction}
|
||||
onCancel={handleCancelCreate}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
<PlaylistTable
|
||||
playlists={playlists}
|
||||
onEdit={handleEditPlaylist}
|
||||
onSetCurrent={handleSetCurrent}
|
||||
/>
|
||||
<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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user