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[]
|
||||
}
|
||||
|
||||
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<ExtractionInfo[]> {
|
||||
const response = await apiClient.get<GetExtractionsResponse>(
|
||||
'/api/v1/extractions/',
|
||||
)
|
||||
async getUserExtractions(params?: GetExtractionsParams): Promise<ExtractionInfo[]> {
|
||||
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<GetExtractionsResponse>(url)
|
||||
return response.extractions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ExtractionInfo[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load extractions
|
||||
const loadExtractions = async () => {
|
||||
// Search, sorting, and filtering state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<ExtractionSortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<ExtractionSortOrder>('desc')
|
||||
const [statusFilter, setStatusFilter] = useState<ExtractionStatus | 'all'>('all')
|
||||
|
||||
// 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 (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)
|
||||
case 'processing':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
)
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
const handleCancelCreate = () => {
|
||||
setUrl('')
|
||||
setShowCreateDialog(false)
|
||||
}
|
||||
|
||||
const getServiceBadge = (service: string | undefined) => {
|
||||
if (!service) return null
|
||||
|
||||
const serviceColors: Record<string, string> = {
|
||||
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
soundcloud:
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
instagram:
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <ExtractionsLoading />
|
||||
}
|
||||
|
||||
const colorClass =
|
||||
serviceColors[service.toLowerCase()] ||
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
if (error) {
|
||||
return <ExtractionsError error={error} onRetry={fetchExtractions} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colorClass}>
|
||||
{service.toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
if (extractions.length === 0) {
|
||||
return <ExtractionsEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
|
||||
}
|
||||
|
||||
return <ExtractionsTable extractions={extractions} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -150,163 +123,33 @@ export function ExtractionsPage() {
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Audio Extractions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Extract audio from YouTube, SoundCloud, and other platforms
|
||||
</p>
|
||||
</div>
|
||||
<ExtractionsHeader
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
sortBy={sortBy}
|
||||
onSortByChange={setSortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortOrderChange={setSortOrder}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onRefresh={fetchExtractions}
|
||||
onCreateClick={() => setShowCreateDialog(true)}
|
||||
loading={loading}
|
||||
error={error}
|
||||
extractionCount={extractions.length}
|
||||
/>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Extraction
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Extraction</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="url">URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !isCreating) {
|
||||
handleCreateExtraction()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
|
||||
Instagram, and more
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateExtraction}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Extraction'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<CreateExtractionDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
loading={createLoading}
|
||||
url={url}
|
||||
onUrlChange={setUrl}
|
||||
onSubmit={handleCreateExtraction}
|
||||
onCancel={handleCancelCreate}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
Loading extractions...
|
||||
</div>
|
||||
) : extractions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center">
|
||||
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
No extractions yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Start by adding a URL to extract audio from your favorite
|
||||
platforms
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Your First Extraction
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Extractions ({extractions.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extractions.map(extraction => (
|
||||
<TableRow key={extraction.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{extraction.title || 'Extracting...'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate max-w-64">
|
||||
{extraction.url}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getServiceBadge(extraction.service)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(extraction.status)}
|
||||
{extraction.error && (
|
||||
<div
|
||||
className="text-xs text-destructive mt-1 max-w-48 truncate"
|
||||
title={extraction.error}
|
||||
>
|
||||
{extraction.error}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateDistanceToNow(extraction.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={extraction.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user