feat: implement extraction management features including creation, loading states, and filtering

This commit is contained in:
JSC
2025-08-17 01:27:51 +02:00
parent 0024f1d647
commit ed888dd8d1
7 changed files with 604 additions and 263 deletions

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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
} }
} }

View File

@@ -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>
) )