314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
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 {
|
|
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'
|
|
import {
|
|
type ExtractionInfo,
|
|
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)
|
|
|
|
// Load extractions
|
|
const loadExtractions = async () => {
|
|
try {
|
|
setIsLoading(true)
|
|
const data = await extractionsService.getUserExtractions()
|
|
setExtractions(data)
|
|
} catch (error) {
|
|
console.error('Failed to load extractions:', error)
|
|
toast.error('Failed to load extractions')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadExtractions()
|
|
}, [])
|
|
|
|
// Create new extraction
|
|
const handleCreateExtraction = async () => {
|
|
if (!url.trim()) {
|
|
toast.error('Please enter a URL')
|
|
return
|
|
}
|
|
|
|
try {
|
|
setIsCreating(true)
|
|
const response = await extractionsService.createExtraction(url.trim())
|
|
toast.success(response.message)
|
|
setUrl('')
|
|
setIsDialogOpen(false)
|
|
// Refresh the list
|
|
await loadExtractions()
|
|
} catch (error) {
|
|
console.error('Failed to create extraction:', error)
|
|
toast.error('Failed to create extraction')
|
|
} finally {
|
|
setIsCreating(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 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 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 (
|
|
<AppLayout
|
|
breadcrumb={{
|
|
items: [{ label: 'Dashboard', href: '/' }, { label: 'Extractions' }],
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{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>
|
|
)}
|
|
</div>
|
|
</AppLayout>
|
|
)
|
|
}
|