From ed888dd8d14ece2a09a3d3d922b1c5720c2d3799 Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 17 Aug 2025 01:27:51 +0200 Subject: [PATCH] feat: implement extraction management features including creation, loading states, and filtering --- .../extractions/CreateExtractionDialog.tsx | 82 ++++ .../extractions/ExtractionsHeader.tsx | 157 ++++++++ .../extractions/ExtractionsLoadingStates.tsx | 67 ++++ src/components/extractions/ExtractionsRow.tsx | 126 ++++++ .../extractions/ExtractionsTable.tsx | 39 ++ src/lib/api/services/extractions.ts | 35 +- src/pages/ExtractionsPage.tsx | 361 +++++------------- 7 files changed, 604 insertions(+), 263 deletions(-) create mode 100644 src/components/extractions/CreateExtractionDialog.tsx create mode 100644 src/components/extractions/ExtractionsHeader.tsx create mode 100644 src/components/extractions/ExtractionsLoadingStates.tsx create mode 100644 src/components/extractions/ExtractionsRow.tsx create mode 100644 src/components/extractions/ExtractionsTable.tsx diff --git a/src/components/extractions/CreateExtractionDialog.tsx b/src/components/extractions/CreateExtractionDialog.tsx new file mode 100644 index 0000000..bd4dcb2 --- /dev/null +++ b/src/components/extractions/CreateExtractionDialog.tsx @@ -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 ( + + + + Create New Extraction + + Extract audio from YouTube, SoundCloud, Vimeo, TikTok, Twitter, + Instagram, and many other platforms. + + +
+
+ + onUrlChange(e.target.value)} + onKeyDown={handleKeyDown} + /> +

+ Paste a link to extract audio from the media +

+
+
+ + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/extractions/ExtractionsHeader.tsx b/src/components/extractions/ExtractionsHeader.tsx new file mode 100644 index 0000000..6cce580 --- /dev/null +++ b/src/components/extractions/ExtractionsHeader.tsx @@ -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 */} +
+
+

Audio Extractions

+

+ Extract audio from YouTube, SoundCloud, and other platforms +

+
+
+ {!loading && !error && ( +
+ {statusFilter !== 'all' + ? `${extractionCount} ${statusFilter} extraction${extractionCount !== 1 ? 's' : ''}` + : `${extractionCount} extraction${extractionCount !== 1 ? 's' : ''}` + } +
+ )} + +
+
+ + {/* Search and Sort Controls */} +
+
+
+ + onSearchChange(e.target.value)} + className="pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+
+ +
+ + + + + + + +
+
+ + ) +} \ No newline at end of file diff --git a/src/components/extractions/ExtractionsLoadingStates.tsx b/src/components/extractions/ExtractionsLoadingStates.tsx new file mode 100644 index 0000000..86b0628 --- /dev/null +++ b/src/components/extractions/ExtractionsLoadingStates.tsx @@ -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 ( +
+ + {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +interface ExtractionsErrorProps { + error: string + onRetry: () => void +} + +export function ExtractionsError({ error, onRetry }: ExtractionsErrorProps) { + return ( +
+ +

Failed to load extractions

+

{error}

+ +
+ ) +} + +interface ExtractionsEmptyProps { + searchQuery: string + statusFilter?: ExtractionStatus | 'all' +} + +export function ExtractionsEmpty({ searchQuery, statusFilter = 'all' }: ExtractionsEmptyProps) { + const isFiltered = searchQuery || statusFilter !== 'all' + + return ( +
+
+ +
+

+ {isFiltered ? 'No extractions found' : 'No extractions yet'} +

+

+ {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' + } +

+
+ ) +} \ No newline at end of file diff --git a/src/components/extractions/ExtractionsRow.tsx b/src/components/extractions/ExtractionsRow.tsx new file mode 100644 index 0000000..09c60e9 --- /dev/null +++ b/src/components/extractions/ExtractionsRow.tsx @@ -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 ( + + + Pending + + ) + case 'processing': + return ( + + + Processing + + ) + case 'completed': + return ( + + + Completed + + ) + case 'failed': + return ( + + + Failed + + ) + } + } + + const getServiceBadge = (service: string | undefined) => { + if (!service) return - + + const serviceColors: Record = { + 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 ( + + {service.toUpperCase()} + + ) + } + + return ( + + +
+
+ {extraction.title || 'Extracting...'} +
+
+ {extraction.url} +
+
+
+ + {getServiceBadge(extraction.service)} + + +
+ {getStatusBadge(extraction.status)} + {extraction.error && ( +
+ {extraction.error} +
+ )} +
+
+ +
+ + {formatDateDistanceToNow(extraction.created_at)} +
+
+ +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/extractions/ExtractionsTable.tsx b/src/components/extractions/ExtractionsTable.tsx new file mode 100644 index 0000000..67a5c45 --- /dev/null +++ b/src/components/extractions/ExtractionsTable.tsx @@ -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 ( +
+ + + + Title + Service + Status + Created + Actions + + + + {extractions.map(extraction => ( + + ))} + +
+
+ ) +} \ No newline at end of file diff --git a/src/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts index a45a66e..714cbc3 100644 --- a/src/lib/api/services/extractions.ts +++ b/src/lib/api/services/extractions.ts @@ -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 { - const response = await apiClient.get( - '/api/v1/extractions/', - ) + async getUserExtractions(params?: GetExtractionsParams): Promise { + 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(url) return response.extractions } } diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx index c444dd7..86c2bfe 100644 --- a/src/pages/ExtractionsPage.tsx +++ b/src/pages/ExtractionsPage.tsx @@ -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([]) - 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(null) - // Load extractions - const loadExtractions = async () => { + // Search, sorting, and filtering state + const [searchQuery, setSearchQuery] = useState('') + const [sortBy, setSortBy] = useState('created_at') + const [sortOrder, setSortOrder] = useState('desc') + const [statusFilter, setStatusFilter] = useState('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 ( - - - Pending - - ) - case 'processing': - return ( - - - Processing - - ) - case 'completed': - return ( - - - Completed - - ) - case 'failed': - return ( - - - Failed - - ) - } + const handleCancelCreate = () => { + setUrl('') + setShowCreateDialog(false) } - const getServiceBadge = (service: string | undefined) => { - if (!service) return null - - const serviceColors: Record = { - 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 } - const colorClass = - serviceColors[service.toLowerCase()] || - 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300' + if (error) { + return + } - return ( - - {service.toUpperCase()} - - ) + if (extractions.length === 0) { + return + } + + return } return ( @@ -150,163 +123,33 @@ export function ExtractionsPage() { }} >
-
-
-

Audio Extractions

-

- Extract audio from YouTube, SoundCloud, and other platforms -

-
+ setShowCreateDialog(true)} + loading={loading} + error={error} + extractionCount={extractions.length} + /> - - - - - - - Create New Extraction - -
-
- - setUrl(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && !isCreating) { - handleCreateExtraction() - } - }} - /> -

- Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, - Instagram, and more -

-
-
- - -
-
-
-
-
+ - {isLoading ? ( -
- - Loading extractions... -
- ) : extractions.length === 0 ? ( - - -
- -

- No extractions yet -

-

- Start by adding a URL to extract audio from your favorite - platforms -

- -
-
-
- ) : ( - - - Recent Extractions ({extractions.length}) - - - - - - Title - Service - Status - Created - Actions - - - - {extractions.map(extraction => ( - - -
-
- {extraction.title || 'Extracting...'} -
-
- {extraction.url} -
-
-
- - {getServiceBadge(extraction.service)} - - - {getStatusBadge(extraction.status)} - {extraction.error && ( -
- {extraction.error} -
- )} -
- -
- - {formatDateDistanceToNow(extraction.created_at)} -
-
- -
- -
-
-
- ))} -
-
-
-
- )} + {renderContent()}
)