diff --git a/src/components/extractions/CreateExtractionDialog.tsx b/src/components/extractions/CreateExtractionDialog.tsx
new file mode 100644
index 0000000..bd4dcb2
--- /dev/null
+++ b/src/components/extractions/CreateExtractionDialog.tsx
@@ -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 (
+
+
+
+ Create New Extraction
+
+ Extract audio from YouTube, SoundCloud, Vimeo, TikTok, Twitter,
+ Instagram, and many other platforms.
+
+
+
+
+
URL
+
onUrlChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ />
+
+ Paste a link to extract audio from the media
+
+
+
+
+
+ Cancel
+
+
+ {loading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ 'Create Extraction'
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/extractions/ExtractionsHeader.tsx b/src/components/extractions/ExtractionsHeader.tsx
new file mode 100644
index 0000000..6cce580
--- /dev/null
+++ b/src/components/extractions/ExtractionsHeader.tsx
@@ -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 */}
+
+
+
Audio Extractions
+
+ Extract audio from YouTube, SoundCloud, and other platforms
+
+
+
+ {!loading && !error && (
+
+ {statusFilter !== 'all'
+ ? `${extractionCount} ${statusFilter} extraction${extractionCount !== 1 ? 's' : ''}`
+ : `${extractionCount} extraction${extractionCount !== 1 ? 's' : ''}`
+ }
+
+ )}
+
+
+ Add Extraction
+
+
+
+
+ {/* Search and Sort Controls */}
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {searchQuery && (
+ 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"
+ >
+
+
+ )}
+
+
+
+
+ onSortByChange(value as ExtractionSortField)}
+ >
+
+
+
+
+ Title
+ Status
+ Service
+ Created Date
+ Updated Date
+
+
+
+ onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
+ title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
+ >
+ {sortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
+
+ onStatusFilterChange(value as ExtractionStatus | 'all')}
+ >
+
+
+
+
+
+ All Status
+ Pending
+ Processing
+ Completed
+ Failed
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/src/components/extractions/ExtractionsLoadingStates.tsx b/src/components/extractions/ExtractionsLoadingStates.tsx
new file mode 100644
index 0000000..86b0628
--- /dev/null
+++ b/src/components/extractions/ExtractionsLoadingStates.tsx
@@ -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 (
+
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+interface ExtractionsErrorProps {
+ error: string
+ onRetry: () => void
+}
+
+export function ExtractionsError({ error, onRetry }: ExtractionsErrorProps) {
+ return (
+
+
+
Failed to load extractions
+
{error}
+
+ Try again
+
+
+ )
+}
+
+interface ExtractionsEmptyProps {
+ searchQuery: string
+ statusFilter?: ExtractionStatus | 'all'
+}
+
+export function ExtractionsEmpty({ searchQuery, statusFilter = 'all' }: ExtractionsEmptyProps) {
+ const isFiltered = searchQuery || statusFilter !== 'all'
+
+ return (
+
+
+
+
+
+ {isFiltered ? 'No extractions found' : 'No extractions yet'}
+
+
+ {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'
+ }
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/extractions/ExtractionsRow.tsx b/src/components/extractions/ExtractionsRow.tsx
new file mode 100644
index 0000000..09c60e9
--- /dev/null
+++ b/src/components/extractions/ExtractionsRow.tsx
@@ -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 (
+
+
+ Pending
+
+ )
+ case 'processing':
+ return (
+
+
+ Processing
+
+ )
+ case 'completed':
+ return (
+
+
+ Completed
+
+ )
+ case 'failed':
+ return (
+
+
+ Failed
+
+ )
+ }
+ }
+
+ const getServiceBadge = (service: string | undefined) => {
+ if (!service) return -
+
+ const serviceColors: Record = {
+ 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 (
+
+ {service.toUpperCase()}
+
+ )
+ }
+
+ return (
+
+
+
+
+ {extraction.title || 'Extracting...'}
+
+
+ {extraction.url}
+
+
+
+
+ {getServiceBadge(extraction.service)}
+
+
+
+ {getStatusBadge(extraction.status)}
+ {extraction.error && (
+
+ {extraction.error}
+
+ )}
+
+
+
+
+
+ {formatDateDistanceToNow(extraction.created_at)}
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/extractions/ExtractionsTable.tsx b/src/components/extractions/ExtractionsTable.tsx
new file mode 100644
index 0000000..67a5c45
--- /dev/null
+++ b/src/components/extractions/ExtractionsTable.tsx
@@ -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 (
+
+
+
+
+ Title
+ Service
+ Status
+ Created
+ Actions
+
+
+
+ {extractions.map(extraction => (
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts
index a45a66e..714cbc3 100644
--- a/src/lib/api/services/extractions.ts
+++ b/src/lib/api/services/extractions.ts
@@ -23,6 +23,17 @@ export interface GetExtractionsResponse {
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 {
/**
* Create a new extraction job
@@ -47,10 +58,26 @@ export class ExtractionsService {
/**
* Get user's extractions
*/
- async getUserExtractions(): Promise {
- const response = await apiClient.get(
- '/api/v1/extractions/',
- )
+ async getUserExtractions(params?: GetExtractionsParams): Promise {
+ 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)
+ }
+
+ const queryString = searchParams.toString()
+ const url = queryString ? `/api/v1/extractions/?${queryString}` : '/api/v1/extractions/'
+
+ const response = await apiClient.get(url)
return response.extractions
}
}
diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx
index c444dd7..86c2bfe 100644
--- a/src/pages/ExtractionsPage.tsx
+++ b/src/pages/ExtractionsPage.tsx
@@ -1,68 +1,74 @@
import { AppLayout } from '@/components/AppLayout'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { CreateExtractionDialog } from '@/components/extractions/CreateExtractionDialog'
+import { ExtractionsHeader } from '@/components/extractions/ExtractionsHeader'
import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
+ ExtractionsEmpty,
+ ExtractionsError,
+ ExtractionsLoading,
+} from '@/components/extractions/ExtractionsLoadingStates'
+import { ExtractionsTable } from '@/components/extractions/ExtractionsTable'
import {
type ExtractionInfo,
+ type ExtractionSortField,
+ type ExtractionSortOrder,
+ type ExtractionStatus,
extractionsService,
} from '@/lib/api/services/extractions'
-import { formatDateDistanceToNow } from '@/utils/format-date'
-import {
- AlertCircle,
- Calendar,
- CheckCircle,
- Clock,
- Download,
- ExternalLink,
- Loader2,
- Plus,
-} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
export function ExtractionsPage() {
const [extractions, setExtractions] = useState([])
- const [isLoading, setIsLoading] = useState(true)
- const [isDialogOpen, setIsDialogOpen] = useState(false)
- const [url, setUrl] = useState('')
- const [isCreating, setIsCreating] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
- // Load extractions
- const loadExtractions = async () => {
+ // Search, sorting, and filtering state
+ const [searchQuery, setSearchQuery] = useState('')
+ const [sortBy, setSortBy] = useState('created_at')
+ const [sortOrder, setSortOrder] = useState('desc')
+ const [statusFilter, setStatusFilter] = useState('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 {
- setIsLoading(true)
- const data = await extractionsService.getUserExtractions()
+ setLoading(true)
+ 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)
- } catch (error) {
- console.error('Failed to load extractions:', error)
- toast.error('Failed to load extractions')
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Failed to fetch extractions'
+ setError(errorMessage)
+ toast.error(errorMessage)
} finally {
- setIsLoading(false)
+ setLoading(false)
}
}
useEffect(() => {
- loadExtractions()
- }, [])
+ fetchExtractions()
+ }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter])
- // Create new extraction
const handleCreateExtraction = async () => {
if (!url.trim()) {
toast.error('Please enter a URL')
@@ -70,77 +76,44 @@ export function ExtractionsPage() {
}
try {
- setIsCreating(true)
+ setCreateLoading(true)
const response = await extractionsService.createExtraction(url.trim())
toast.success(response.message)
+
+ // Reset form and close dialog
setUrl('')
- setIsDialogOpen(false)
- // Refresh the list
- await loadExtractions()
- } catch (error) {
- console.error('Failed to create extraction:', error)
- toast.error('Failed to create extraction')
+ setShowCreateDialog(false)
+
+ // Refresh the extractions list
+ fetchExtractions()
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Failed to create extraction'
+ toast.error(errorMessage)
} finally {
- setIsCreating(false)
+ setCreateLoading(false)
}
}
- const getStatusBadge = (status: ExtractionInfo['status']) => {
- switch (status) {
- case 'pending':
- return (
-
-
- Pending
-
- )
- case 'processing':
- return (
-
-
- Processing
-
- )
- case 'completed':
- return (
-
-
- Completed
-
- )
- case 'failed':
- return (
-
-
- Failed
-
- )
- }
+ const handleCancelCreate = () => {
+ setUrl('')
+ setShowCreateDialog(false)
}
- const getServiceBadge = (service: string | undefined) => {
- if (!service) return null
-
- const serviceColors: Record = {
- youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
- soundcloud:
- 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
- vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
- tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
- twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
- instagram:
- 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
+ const renderContent = () => {
+ if (loading) {
+ return
}
- const colorClass =
- serviceColors[service.toLowerCase()] ||
- 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
+ if (error) {
+ return
+ }
- return (
-
- {service.toUpperCase()}
-
- )
+ if (extractions.length === 0) {
+ return
+ }
+
+ return
}
return (
@@ -150,163 +123,33 @@ export function ExtractionsPage() {
}}
>
-
-
-
Audio Extractions
-
- Extract audio from YouTube, SoundCloud, and other platforms
-
-
+
setShowCreateDialog(true)}
+ loading={loading}
+ error={error}
+ extractionCount={extractions.length}
+ />
-
-
-
-
- Add Extraction
-
-
-
-
- Create New Extraction
-
-
-
-
URL
-
setUrl(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter' && !isCreating) {
- handleCreateExtraction()
- }
- }}
- />
-
- Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
- Instagram, and more
-
-
-
- setIsDialogOpen(false)}
- >
- Cancel
-
-
- {isCreating ? (
- <>
-
- Creating...
- >
- ) : (
- 'Create Extraction'
- )}
-
-
-
-
-
-
+
- {isLoading ? (
-
-
- Loading extractions...
-
- ) : extractions.length === 0 ? (
-
-
-
-
-
- No extractions yet
-
-
- Start by adding a URL to extract audio from your favorite
- platforms
-
-
setIsDialogOpen(true)} className="gap-2">
-
- Add Your First Extraction
-
-
-
-
- ) : (
-
-
- Recent Extractions ({extractions.length})
-
-
-
-
-
- Title
- Service
- Status
- Created
- Actions
-
-
-
- {extractions.map(extraction => (
-
-
-
-
- {extraction.title || 'Extracting...'}
-
-
- {extraction.url}
-
-
-
-
- {getServiceBadge(extraction.service)}
-
-
- {getStatusBadge(extraction.status)}
- {extraction.error && (
-
- {extraction.error}
-
- )}
-
-
-
-
- {formatDateDistanceToNow(extraction.created_at)}
-
-
-
-
-
-
- ))}
-
-
-
-
- )}
+ {renderContent()}
)