feat: implement extraction management features including creation, loading states, and filtering
This commit is contained in:
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
src/components/extractions/ExtractionsRow.tsx
Normal file
126
src/components/extractions/ExtractionsRow.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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
|
||||||
|
} 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="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/components/extractions/ExtractionsTable.tsx
Normal file
39
src/components/extractions/ExtractionsTable.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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>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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,17 @@ export interface GetExtractionsResponse {
|
|||||||
extractions: ExtractionInfo[]
|
extractions: ExtractionInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
export class ExtractionsService {
|
export class ExtractionsService {
|
||||||
/**
|
/**
|
||||||
* Create a new extraction job
|
* Create a new extraction job
|
||||||
@@ -47,10 +58,26 @@ export class ExtractionsService {
|
|||||||
/**
|
/**
|
||||||
* Get user's extractions
|
* Get user's extractions
|
||||||
*/
|
*/
|
||||||
async getUserExtractions(): Promise<ExtractionInfo[]> {
|
async getUserExtractions(params?: GetExtractionsParams): Promise<ExtractionInfo[]> {
|
||||||
const response = await apiClient.get<GetExtractionsResponse>(
|
const searchParams = new URLSearchParams()
|
||||||
'/api/v1/extractions/',
|
|
||||||
)
|
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 queryString = searchParams.toString()
|
||||||
|
const url = queryString ? `/api/v1/extractions/?${queryString}` : '/api/v1/extractions/'
|
||||||
|
|
||||||
|
const response = await apiClient.get<GetExtractionsResponse>(url)
|
||||||
return response.extractions
|
return response.extractions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,74 @@
|
|||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { CreateExtractionDialog } from '@/components/extractions/CreateExtractionDialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { ExtractionsHeader } from '@/components/extractions/ExtractionsHeader'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
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')
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
const data = await extractionsService.getUserExtractions({
|
||||||
|
search: debouncedSearchQuery.trim() || undefined,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
status_filter: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
})
|
||||||
setExtractions(data)
|
setExtractions(data)
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to load extractions:', error)
|
const errorMessage =
|
||||||
toast.error('Failed to load extractions')
|
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])
|
||||||
|
|
||||||
// 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,77 +76,44 @@ 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'
|
}
|
||||||
|
|
||||||
return (
|
if (extractions.length === 0) {
|
||||||
<Badge variant="outline" className={colorClass}>
|
return <ExtractionsEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
|
||||||
{service.toUpperCase()}
|
}
|
||||||
</Badge>
|
|
||||||
)
|
return <ExtractionsTable extractions={extractions} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,163 +123,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={extractions.length}
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user