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

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