feat: add audio extraction management interface and services
- Implemented ExtractionsPage component for managing audio extractions. - Added ExtractionsService for handling extraction API calls. - Created Playlist component for displaying audio tracks. - Introduced ScrollArea component for better UI scrolling experience. - Developed FilesService for file download and thumbnail management. - Added PlayerService for controlling audio playback and state. - Updated API services index to include new services.
This commit is contained in:
@@ -1,6 +1,99 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
|
||||
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
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={{
|
||||
@@ -10,11 +103,162 @@ export function ExtractionsPage() {
|
||||
]
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Audio Extractions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Audio extraction management interface coming soon...
|
||||
</p>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-6">
|
||||
<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" />
|
||||
{(() => {
|
||||
try {
|
||||
const date = new Date(extraction.created_at)
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid date'
|
||||
}
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
} catch {
|
||||
return 'Invalid date'
|
||||
}
|
||||
})()}
|
||||
</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>
|
||||
{extraction.status === 'completed' && extraction.sound_id && (
|
||||
<Button variant="ghost" size="sm" title="View in Sounds">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user