Merge branch 'favorite'
This commit is contained in:
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 type { Playlist } from '@/lib/api/services/playlists'
|
||||||
import { formatDateDistanceToNow } from '@/utils/format-date'
|
import { formatDateDistanceToNow } from '@/utils/format-date'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
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 {
|
interface PlaylistRowProps {
|
||||||
playlist: Playlist
|
playlist: Playlist
|
||||||
onEdit: (playlist: Playlist) => void
|
onEdit: (playlist: Playlist) => void
|
||||||
onSetCurrent: (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 (
|
return (
|
||||||
<TableRow className="hover:bg-muted/50">
|
<TableRow className="hover:bg-muted/50">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -76,6 +83,24 @@ export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface PlaylistTableProps {
|
|||||||
playlists: Playlist[]
|
playlists: Playlist[]
|
||||||
onEdit: (playlist: Playlist) => void
|
onEdit: (playlist: Playlist) => void
|
||||||
onSetCurrent: (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 (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -37,6 +38,7 @@ export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTable
|
|||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onSetCurrent={onSetCurrent}
|
onSetCurrent={onSetCurrent}
|
||||||
|
onFavoriteToggle={onFavoriteToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import type { PlaylistSortField, SortOrder } from '@/lib/api/services/playlists'
|
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 {
|
interface PlaylistsHeaderProps {
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
@@ -22,6 +22,8 @@ interface PlaylistsHeaderProps {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
playlistCount: number
|
playlistCount: number
|
||||||
|
showFavoritesOnly: boolean
|
||||||
|
onFavoritesToggle: (show: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistsHeader({
|
export function PlaylistsHeader({
|
||||||
@@ -36,6 +38,8 @@ export function PlaylistsHeader({
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
playlistCount,
|
playlistCount,
|
||||||
|
showFavoritesOnly,
|
||||||
|
onFavoritesToggle,
|
||||||
}: PlaylistsHeaderProps) {
|
}: PlaylistsHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -50,7 +54,10 @@ export function PlaylistsHeader({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button onClick={onCreateClick}>
|
<Button onClick={onCreateClick}>
|
||||||
@@ -116,6 +123,15 @@ export function PlaylistsHeader({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { AlertCircle, Music } from 'lucide-react'
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
interface PlaylistsLoadingProps {
|
interface PlaylistsLoadingProps {
|
||||||
count?: number
|
count?: number
|
||||||
@@ -39,19 +39,25 @@ export function PlaylistsError({ error, onRetry }: PlaylistsErrorProps) {
|
|||||||
|
|
||||||
interface PlaylistsEmptyProps {
|
interface PlaylistsEmptyProps {
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
|
showFavoritesOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistsEmpty({ searchQuery }: PlaylistsEmptyProps) {
|
export function PlaylistsEmpty({ searchQuery, showFavoritesOnly = false }: PlaylistsEmptyProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
<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>
|
</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">
|
<p className="text-muted-foreground">
|
||||||
{searchQuery
|
{showFavoritesOnly
|
||||||
|
? 'You haven\'t favorited any playlists yet. Click the heart icon on playlists to add them to your favorites.'
|
||||||
|
: searchQuery
|
||||||
? 'No playlists match your search criteria.'
|
? 'No playlists match your search criteria.'
|
||||||
: 'No playlists are available.'}
|
: 'No playlists are available.'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { Playlist } from '@/lib/api/services/playlists'
|
import type { Playlist } from '@/lib/api/services/playlists'
|
||||||
|
import { Heart } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface PlaylistEditHeaderProps {
|
interface PlaylistEditHeaderProps {
|
||||||
playlist: Playlist
|
playlist: Playlist
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
onSetCurrent: () => void
|
onSetCurrent: () => void
|
||||||
|
onFavoriteToggle?: (playlistId: number, shouldFavorite: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistEditHeader({
|
export function PlaylistEditHeader({
|
||||||
playlist,
|
playlist,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
onSetCurrent,
|
onSetCurrent,
|
||||||
|
onFavoriteToggle,
|
||||||
}: PlaylistEditHeaderProps) {
|
}: PlaylistEditHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -25,6 +29,24 @@ export function PlaylistEditHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{!playlist.is_current && !isEditMode && (
|
||||||
<Button variant="outline" onClick={onSetCurrent}>
|
<Button variant="outline" onClick={onSetCurrent}>
|
||||||
Set as Current
|
Set as Current
|
||||||
|
|||||||
@@ -4,43 +4,83 @@ import { cn } from '@/lib/utils'
|
|||||||
import { formatDuration } from '@/utils/format-duration'
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
import { formatSize } from '@/utils/format-size'
|
import { formatSize } from '@/utils/format-size'
|
||||||
import NumberFlow from '@number-flow/react'
|
import NumberFlow from '@number-flow/react'
|
||||||
import { Clock, Play, Weight } from 'lucide-react'
|
import { Clock, Heart, Play, Weight } from 'lucide-react'
|
||||||
|
|
||||||
interface SoundCardProps {
|
interface SoundCardProps {
|
||||||
sound: Sound
|
sound: Sound
|
||||||
playSound: (sound: Sound) => void
|
playSound: (sound: Sound) => void
|
||||||
|
onFavoriteToggle: (soundId: number, isFavorited: boolean) => void
|
||||||
colorClasses: string
|
colorClasses: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
|
export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }: SoundCardProps) {
|
||||||
const handlePlaySound = () => {
|
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)
|
playSound(sound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onFavoriteToggle(sound.id, !sound.is_favorited)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={handlePlaySound}
|
onClick={handlePlaySound}
|
||||||
className={cn(
|
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,
|
colorClasses,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
|
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
|
||||||
<h3 className="font-medium text-s truncate">{sound.name}</h3>
|
{/* Favorite button */}
|
||||||
<div className="grid grid-cols-3 gap-1 text-xs text-muted-foreground">
|
<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">
|
<div className="flex">
|
||||||
<Clock className="h-3.5 w-3.5 mr-0.5" />
|
<Clock className="h-3.5 w-3.5 mr-0.5" />
|
||||||
<span>{formatDuration(sound.duration)}</span>
|
<span>{formatDuration(sound.duration)}</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end items-center">
|
||||||
<Play className="h-3.5 w-3.5 mr-0.5" />
|
<Play className="h-3.5 w-3.5 mr-0.5" />
|
||||||
<NumberFlow value={sound.play_count} />
|
<NumberFlow value={sound.play_count} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
|||||||
soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
|
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
|
// Listen for user events and emit them locally
|
||||||
newSocket.on('user_credits_changed', data => {
|
newSocket.on('user_credits_changed', data => {
|
||||||
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
|
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ export interface MessageResponse {
|
|||||||
message: string
|
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 {
|
export interface ScanResults {
|
||||||
added: number
|
added: number
|
||||||
updated: number
|
updated: number
|
||||||
@@ -54,10 +71,32 @@ export interface NormalizationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
async listUsers(limit = 100, offset = 0): Promise<User[]> {
|
async listUsers(params?: GetUsersParams): Promise<GetUsersResponse> {
|
||||||
return apiClient.get<User[]>(`/api/v1/admin/users/`, {
|
const searchParams = new URLSearchParams()
|
||||||
params: { limit, offset },
|
|
||||||
})
|
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> {
|
async getUser(userId: number): Promise<User> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ExtractionInfo {
|
|||||||
service_id?: string
|
service_id?: string
|
||||||
sound_id?: number
|
sound_id?: number
|
||||||
user_id: number
|
user_id: number
|
||||||
|
user_name?: string
|
||||||
error?: string
|
error?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -21,6 +22,23 @@ export interface CreateExtractionResponse {
|
|||||||
|
|
||||||
export interface GetExtractionsResponse {
|
export interface GetExtractionsResponse {
|
||||||
extractions: ExtractionInfo[]
|
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 {
|
export class ExtractionsService {
|
||||||
@@ -44,14 +62,68 @@ export class ExtractionsService {
|
|||||||
return response
|
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
|
* Get user's extractions
|
||||||
*/
|
*/
|
||||||
async getUserExtractions(): Promise<ExtractionInfo[]> {
|
async getUserExtractions(params?: GetExtractionsParams): Promise<GetExtractionsResponse> {
|
||||||
const response = await apiClient.get<GetExtractionsResponse>(
|
const searchParams = new URLSearchParams()
|
||||||
'/api/v1/extractions/',
|
|
||||||
)
|
if (params?.search) {
|
||||||
return response.extractions
|
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 './player'
|
||||||
export * from './files'
|
export * from './files'
|
||||||
export * from './extractions'
|
export * from './extractions'
|
||||||
|
export * from './favorites'
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface Playlist {
|
|||||||
is_main: boolean
|
is_main: boolean
|
||||||
is_current: boolean
|
is_current: boolean
|
||||||
is_deletable: boolean
|
is_deletable: boolean
|
||||||
|
is_favorited: boolean
|
||||||
|
favorite_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
sound_count: number
|
sound_count: number
|
||||||
@@ -43,15 +45,24 @@ export interface GetPlaylistsParams {
|
|||||||
search?: string
|
search?: string
|
||||||
sort_by?: PlaylistSortField
|
sort_by?: PlaylistSortField
|
||||||
sort_order?: SortOrder
|
sort_order?: SortOrder
|
||||||
|
page?: number
|
||||||
limit?: 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 {
|
export class PlaylistsService {
|
||||||
/**
|
/**
|
||||||
* Get all playlists with optional filtering, searching, and sorting
|
* 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()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
// Handle parameters
|
// Handle parameters
|
||||||
@@ -64,17 +75,20 @@ export class PlaylistsService {
|
|||||||
if (params?.sort_order) {
|
if (params?.sort_order) {
|
||||||
searchParams.append('sort_order', params.sort_order)
|
searchParams.append('sort_order', params.sort_order)
|
||||||
}
|
}
|
||||||
|
if (params?.page) {
|
||||||
|
searchParams.append('page', params.page.toString())
|
||||||
|
}
|
||||||
if (params?.limit) {
|
if (params?.limit) {
|
||||||
searchParams.append('limit', params.limit.toString())
|
searchParams.append('limit', params.limit.toString())
|
||||||
}
|
}
|
||||||
if (params?.offset) {
|
if (params?.favorites_only) {
|
||||||
searchParams.append('offset', params.offset.toString())
|
searchParams.append('favorites_only', 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = searchParams.toString()
|
const url = searchParams.toString()
|
||||||
? `/api/v1/playlists/?${searchParams.toString()}`
|
? `/api/v1/playlists/?${searchParams.toString()}`
|
||||||
: '/api/v1/playlists/'
|
: '/api/v1/playlists/'
|
||||||
return apiClient.get<Playlist[]>(url)
|
return apiClient.get<GetPlaylistsResponse>(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface Sound {
|
|||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
is_music: boolean
|
is_music: boolean
|
||||||
is_deletable: boolean
|
is_deletable: boolean
|
||||||
|
is_favorited: boolean
|
||||||
|
favorite_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,7 @@ export interface GetSoundsParams {
|
|||||||
sort_order?: SortOrder
|
sort_order?: SortOrder
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
|
favorites_only?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetSoundsResponse {
|
export interface GetSoundsResponse {
|
||||||
@@ -75,6 +78,9 @@ export class SoundsService {
|
|||||||
if (params?.offset) {
|
if (params?.offset) {
|
||||||
searchParams.append('offset', params.offset.toString())
|
searchParams.append('offset', params.offset.toString())
|
||||||
}
|
}
|
||||||
|
if (params?.favorites_only) {
|
||||||
|
searchParams.append('favorites_only', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
const url = searchParams.toString()
|
const url = searchParams.toString()
|
||||||
? `/api/v1/sounds/?${searchParams.toString()}`
|
? `/api/v1/sounds/?${searchParams.toString()}`
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const PLAYER_EVENTS = {
|
|||||||
// Sound event types
|
// Sound event types
|
||||||
export const SOUND_EVENTS = {
|
export const SOUND_EVENTS = {
|
||||||
SOUND_PLAYED: 'sound_played',
|
SOUND_PLAYED: 'sound_played',
|
||||||
|
SOUND_FAVORITED: 'sound_favorited',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// User event types
|
// User event types
|
||||||
|
|||||||
@@ -1,68 +1,102 @@
|
|||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { AppPagination } from '@/components/AppPagination'
|
||||||
import { Button } from '@/components/ui/button'
|
import { CreateExtractionDialog } from '@/components/extractions/CreateExtractionDialog'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { ExtractionsHeader } from '@/components/extractions/ExtractionsHeader'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
ExtractionsEmpty,
|
||||||
DialogContent,
|
ExtractionsError,
|
||||||
DialogHeader,
|
ExtractionsLoading,
|
||||||
DialogTitle,
|
} from '@/components/extractions/ExtractionsLoadingStates'
|
||||||
DialogTrigger,
|
import { ExtractionsTable } from '@/components/extractions/ExtractionsTable'
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
import {
|
||||||
type ExtractionInfo,
|
type ExtractionInfo,
|
||||||
|
type ExtractionSortField,
|
||||||
|
type ExtractionSortOrder,
|
||||||
|
type ExtractionStatus,
|
||||||
extractionsService,
|
extractionsService,
|
||||||
} from '@/lib/api/services/extractions'
|
} 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 { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export function ExtractionsPage() {
|
export function ExtractionsPage() {
|
||||||
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
|
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
|
||||||
|
|
||||||
// Load extractions
|
// Search, sorting, and filtering state
|
||||||
const loadExtractions = async () => {
|
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 {
|
try {
|
||||||
setIsLoading(true)
|
setLoading(true)
|
||||||
const data = await extractionsService.getUserExtractions()
|
setError(null)
|
||||||
setExtractions(data)
|
const response = await extractionsService.getAllExtractions({
|
||||||
} catch (error) {
|
search: debouncedSearchQuery.trim() || undefined,
|
||||||
console.error('Failed to load extractions:', error)
|
sort_by: sortBy,
|
||||||
toast.error('Failed to load extractions')
|
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 {
|
} finally {
|
||||||
setIsLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
const handleCreateExtraction = async () => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
toast.error('Please enter a URL')
|
toast.error('Please enter a URL')
|
||||||
@@ -70,76 +104,56 @@ export function ExtractionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsCreating(true)
|
setCreateLoading(true)
|
||||||
const response = await extractionsService.createExtraction(url.trim())
|
const response = await extractionsService.createExtraction(url.trim())
|
||||||
toast.success(response.message)
|
toast.success(response.message)
|
||||||
|
|
||||||
|
// Reset form and close dialog
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setIsDialogOpen(false)
|
setShowCreateDialog(false)
|
||||||
// Refresh the list
|
|
||||||
await loadExtractions()
|
// Refresh the extractions list
|
||||||
} catch (error) {
|
fetchExtractions()
|
||||||
console.error('Failed to create extraction:', error)
|
} catch (err) {
|
||||||
toast.error('Failed to create extraction')
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to create extraction'
|
||||||
|
toast.error(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false)
|
setCreateLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (status: ExtractionInfo['status']) => {
|
const handleCancelCreate = () => {
|
||||||
switch (status) {
|
setUrl('')
|
||||||
case 'pending':
|
setShowCreateDialog(false)
|
||||||
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) => {
|
const renderContent = () => {
|
||||||
if (!service) return null
|
if (loading) {
|
||||||
|
return <ExtractionsLoading />
|
||||||
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 =
|
if (error) {
|
||||||
serviceColors[service.toLowerCase()] ||
|
return <ExtractionsError error={error} onRetry={fetchExtractions} />
|
||||||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
}
|
||||||
|
|
||||||
|
if (extractions.length === 0) {
|
||||||
|
return <ExtractionsEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={colorClass}>
|
<div className="space-y-4">
|
||||||
{service.toUpperCase()}
|
<ExtractionsTable extractions={extractions} />
|
||||||
</Badge>
|
<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-1 rounded-xl bg-muted/50 p-4">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<ExtractionsHeader
|
||||||
<div>
|
searchQuery={searchQuery}
|
||||||
<h1 className="text-2xl font-bold">Audio Extractions</h1>
|
onSearchChange={setSearchQuery}
|
||||||
<p className="text-muted-foreground">
|
sortBy={sortBy}
|
||||||
Extract audio from YouTube, SoundCloud, and other platforms
|
onSortByChange={setSortBy}
|
||||||
</p>
|
sortOrder={sortOrder}
|
||||||
</div>
|
onSortOrderChange={setSortOrder}
|
||||||
|
statusFilter={statusFilter}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
onStatusFilterChange={setStatusFilter}
|
||||||
<DialogTrigger asChild>
|
onRefresh={fetchExtractions}
|
||||||
<Button className="gap-2">
|
onCreateClick={() => setShowCreateDialog(true)}
|
||||||
<Plus className="h-4 w-4" />
|
loading={loading}
|
||||||
Add Extraction
|
error={error}
|
||||||
</Button>
|
extractionCount={totalCount}
|
||||||
</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>
|
|
||||||
|
|
||||||
{isLoading ? (
|
<CreateExtractionDialog
|
||||||
<div className="flex items-center justify-center py-8">
|
open={showCreateDialog}
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
onOpenChange={setShowCreateDialog}
|
||||||
Loading extractions...
|
loading={createLoading}
|
||||||
</div>
|
url={url}
|
||||||
) : extractions.length === 0 ? (
|
onUrlChange={setUrl}
|
||||||
<Card>
|
onSubmit={handleCreateExtraction}
|
||||||
<CardContent className="py-8">
|
onCancel={handleCancelCreate}
|
||||||
<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">
|
{renderContent()}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
playlistsService,
|
playlistsService,
|
||||||
} from '@/lib/api/services/playlists'
|
} from '@/lib/api/services/playlists'
|
||||||
import { type Sound, soundsService } from '@/lib/api/services/sounds'
|
import { type Sound, soundsService } from '@/lib/api/services/sounds'
|
||||||
|
import { favoritesService } from '@/lib/api/services/favorites'
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
type DragEndEvent,
|
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) => {
|
const handleMoveSoundUp = async (index: number) => {
|
||||||
if (index === 0 || sounds.length < 2) return
|
if (index === 0 || sounds.length < 2) return
|
||||||
|
|
||||||
@@ -574,6 +606,7 @@ export function PlaylistEditPage() {
|
|||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onSetCurrent={handleSetCurrent}
|
onSetCurrent={handleSetCurrent}
|
||||||
|
onFavoriteToggle={handleFavoriteToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
import { AppPagination } from '@/components/AppPagination'
|
||||||
import { CreatePlaylistDialog } from '@/components/playlists/CreatePlaylistDialog'
|
import { CreatePlaylistDialog } from '@/components/playlists/CreatePlaylistDialog'
|
||||||
import { PlaylistsHeader } from '@/components/playlists/PlaylistsHeader'
|
import { PlaylistsHeader } from '@/components/playlists/PlaylistsHeader'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
type SortOrder,
|
type SortOrder,
|
||||||
playlistsService,
|
playlistsService,
|
||||||
} from '@/lib/api/services/playlists'
|
} from '@/lib/api/services/playlists'
|
||||||
|
import { favoritesService } from '@/lib/api/services/favorites'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -27,6 +29,13 @@ export function PlaylistsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
|
const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
|
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
|
// Create playlist dialog state
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
@@ -52,12 +61,17 @@ export function PlaylistsPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const playlistData = await playlistsService.getPlaylists({
|
const response = await playlistsService.getPlaylists({
|
||||||
search: debouncedSearchQuery.trim() || undefined,
|
search: debouncedSearchQuery.trim() || undefined,
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
|
favorites_only: showFavoritesOnly,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
})
|
})
|
||||||
setPlaylists(playlistData)
|
setPlaylists(response.playlists)
|
||||||
|
setTotalPages(response.total_pages)
|
||||||
|
setTotalCount(response.total)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : 'Failed to fetch playlists'
|
err instanceof Error ? err.message : 'Failed to fetch playlists'
|
||||||
@@ -70,7 +84,23 @@ export function PlaylistsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlaylists()
|
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 () => {
|
const handleCreatePlaylist = async () => {
|
||||||
if (!newPlaylist.name.trim()) {
|
if (!newPlaylist.name.trim()) {
|
||||||
@@ -126,6 +156,39 @@ export function PlaylistsPage() {
|
|||||||
navigate(`/playlists/${playlist.id}/edit`)
|
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 = () => {
|
const renderContent = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <PlaylistsLoading />
|
return <PlaylistsLoading />
|
||||||
@@ -136,15 +199,27 @@ export function PlaylistsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playlists.length === 0) {
|
if (playlists.length === 0) {
|
||||||
return <PlaylistsEmpty searchQuery={searchQuery} />
|
return <PlaylistsEmpty searchQuery={searchQuery} showFavoritesOnly={showFavoritesOnly} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
<PlaylistTable
|
<PlaylistTable
|
||||||
playlists={playlists}
|
playlists={playlists}
|
||||||
onEdit={handleEditPlaylist}
|
onEdit={handleEditPlaylist}
|
||||||
onSetCurrent={handleSetCurrent}
|
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)}
|
onCreateClick={() => setShowCreateDialog(true)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
playlistCount={playlists.length}
|
playlistCount={totalCount}
|
||||||
|
showFavoritesOnly={showFavoritesOnly}
|
||||||
|
onFavoritesToggle={setShowFavoritesOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreatePlaylistDialog
|
<CreatePlaylistDialog
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
type SoundSortField,
|
type SoundSortField,
|
||||||
soundsService,
|
soundsService,
|
||||||
} from '@/lib/api/services/sounds'
|
} from '@/lib/api/services/sounds'
|
||||||
|
import { favoritesService } from '@/lib/api/services/favorites'
|
||||||
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
|
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Heart,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
SortAsc,
|
SortAsc,
|
||||||
@@ -37,6 +39,14 @@ interface SoundPlayedEventData {
|
|||||||
play_count: number
|
play_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SoundFavoritedEventData {
|
||||||
|
sound_id: number
|
||||||
|
sound_name: string
|
||||||
|
user_id: number
|
||||||
|
user_name: string
|
||||||
|
favorite_count: number
|
||||||
|
}
|
||||||
|
|
||||||
const lightModeColors = [
|
const lightModeColors = [
|
||||||
'bg-red-600/30 hover:bg-red-600/40 text-red-900 border-red-600/20',
|
'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',
|
'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 [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
|
||||||
|
|
||||||
const handlePlaySound = async (sound: Sound) => {
|
const handlePlaySound = async (sound: Sound) => {
|
||||||
try {
|
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()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -112,6 +153,7 @@ export function SoundsPage() {
|
|||||||
search: debouncedSearchQuery.trim() || undefined,
|
search: debouncedSearchQuery.trim() || undefined,
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
|
favorites_only: showFavoritesOnly,
|
||||||
})
|
})
|
||||||
setSounds(sdbSounds)
|
setSounds(sdbSounds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -137,7 +179,7 @@ export function SoundsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSounds()
|
fetchSounds()
|
||||||
}, [debouncedSearchQuery, sortBy, sortOrder])
|
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
|
||||||
|
|
||||||
// Listen for sound_played events and update play_count
|
// Listen for sound_played events and update play_count
|
||||||
useEffect(() => {
|
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 = () => {
|
const renderContent = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -192,11 +257,16 @@ export function SoundsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
<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>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -209,6 +279,7 @@ export function SoundsPage() {
|
|||||||
key={sound.id}
|
key={sound.id}
|
||||||
sound={sound}
|
sound={sound}
|
||||||
playSound={handlePlaySound}
|
playSound={handlePlaySound}
|
||||||
|
onFavoriteToggle={handleFavoriteToggle}
|
||||||
colorClasses={getSoundColor(idx)}
|
colorClasses={getSoundColor(idx)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -232,7 +303,10 @@ export function SoundsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -293,6 +367,15 @@ export function SoundsPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { AppLayout } from '@/components/AppLayout'
|
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 { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -12,20 +19,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
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 Plan, adminService } from '@/lib/api/services/admin'
|
||||||
import type { User } from '@/types/auth'
|
import type { User } from '@/types/auth'
|
||||||
import { formatDate } from '@/utils/format-date'
|
import { formatDate } from '@/utils/format-date'
|
||||||
import { Edit, UserCheck, UserX } from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -40,6 +37,21 @@ export function UsersPage() {
|
|||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
const [plans, setPlans] = useState<Plan[]>([])
|
const [plans, setPlans] = useState<Plan[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
const [editData, setEditData] = useState<EditUserData>({
|
const [editData, setEditData] = useState<EditUserData>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -49,26 +61,65 @@ export function UsersPage() {
|
|||||||
})
|
})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
// Debounce search query
|
||||||
loadData()
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadData = async () => {
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedSearchQuery(searchQuery)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(handler)
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const [usersData, plansData] = await Promise.all([
|
setLoading(true)
|
||||||
adminService.listUsers(),
|
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(),
|
adminService.listPlans(),
|
||||||
])
|
])
|
||||||
setUsers(usersData)
|
setUsers(usersResponse.users)
|
||||||
|
setTotalPages(usersResponse.total_pages)
|
||||||
|
setTotalCount(usersResponse.total)
|
||||||
setPlans(plansData)
|
setPlans(plansData)
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
toast.error('Failed to load data')
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load users'
|
||||||
console.error('Error loading data:', error)
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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) => {
|
const handleEditUser = (user: User) => {
|
||||||
setEditingUser(user)
|
setEditingUser(user)
|
||||||
setEditData({
|
setEditData({
|
||||||
@@ -111,7 +162,7 @@ export function UsersPage() {
|
|||||||
toast.success('User enabled successfully')
|
toast.success('User enabled successfully')
|
||||||
}
|
}
|
||||||
// Reload data to get updated user status
|
// Reload data to get updated user status
|
||||||
loadData()
|
fetchUsers()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`)
|
toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`)
|
||||||
console.error('Error toggling user status:', error)
|
console.error('Error toggling user status:', error)
|
||||||
@@ -126,47 +177,36 @@ export function UsersPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (isActive: boolean) => {
|
const renderContent = () => {
|
||||||
return (
|
|
||||||
<Badge variant={isActive ? 'default' : 'secondary'}>
|
|
||||||
{isActive ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
return <UsersLoading />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <UsersError error={error} onRetry={fetchUsers} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return <UsersEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<div className="space-y-4">
|
||||||
breadcrumb={{
|
<UsersTable
|
||||||
items: [
|
users={users}
|
||||||
{ label: 'Dashboard', href: '/' },
|
onEdit={handleEditUser}
|
||||||
{ label: 'Admin' },
|
onToggleStatus={handleToggleUserStatus}
|
||||||
{ label: 'Users' },
|
/>
|
||||||
],
|
<AppPagination
|
||||||
}}
|
currentPage={currentPage}
|
||||||
>
|
totalPages={totalPages}
|
||||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
totalCount={totalCount}
|
||||||
<div className="flex items-center justify-between mb-6">
|
pageSize={pageSize}
|
||||||
<div>
|
onPageChange={handlePageChange}
|
||||||
<Skeleton className="h-8 w-48" />
|
onPageSizeChange={handlePageSizeChange}
|
||||||
<Skeleton className="h-4 w-64 mt-1" />
|
itemName="users"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-10 w-32" />
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,72 +221,22 @@ export function UsersPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<UsersHeader
|
||||||
<div>
|
searchQuery={searchQuery}
|
||||||
<h1 className="text-2xl font-bold">User Management</h1>
|
onSearchChange={setSearchQuery}
|
||||||
<p className="text-muted-foreground">
|
sortBy={sortBy}
|
||||||
Manage user accounts and permissions
|
onSortByChange={setSortBy}
|
||||||
</p>
|
sortOrder={sortOrder}
|
||||||
</div>
|
onSortOrderChange={setSortOrder}
|
||||||
<Button onClick={loadData} disabled={loading}>
|
statusFilter={statusFilter}
|
||||||
Refresh
|
onStatusFilterChange={setStatusFilter}
|
||||||
</Button>
|
onRefresh={fetchUsers}
|
||||||
</div>
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
userCount={totalCount}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
{renderContent()}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit User Sheet */}
|
{/* Edit User Sheet */}
|
||||||
|
|||||||
Reference in New Issue
Block a user