diff --git a/src/components/AppPagination.tsx b/src/components/AppPagination.tsx new file mode 100644 index 0000000..84a72a1 --- /dev/null +++ b/src/components/AppPagination.tsx @@ -0,0 +1,147 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +interface AppPaginationProps { + currentPage: number + totalPages: number + totalCount: number + pageSize: number + pageSizeOptions?: number[] + onPageChange: (page: number) => void + onPageSizeChange: (size: number) => void + itemName?: string // e.g., "items", "extractions", "playlists" +} + +export function AppPagination({ + currentPage, + totalPages, + totalCount, + pageSize, + pageSizeOptions = [10, 20, 50, 100], + onPageChange, + onPageSizeChange, + itemName = 'items', +}: AppPaginationProps) { + // Don't render if there are no items + if (totalCount === 0) return null + + const getVisiblePages = () => { + const delta = 2 + const range = [] + const rangeWithDots = [] + + for ( + let i = Math.max(2, currentPage - delta); + i <= Math.min(totalPages - 1, currentPage + delta); + i++ + ) { + range.push(i) + } + + if (currentPage - delta > 2) { + rangeWithDots.push(1, '...') + } else { + rangeWithDots.push(1) + } + + rangeWithDots.push(...range) + + if (currentPage + delta < totalPages - 1) { + rangeWithDots.push('...', totalPages) + } else if (totalPages > 1) { + rangeWithDots.push(totalPages) + } + + return rangeWithDots + } + + const startItem = Math.min((currentPage - 1) * pageSize + 1, totalCount) + const endItem = Math.min(currentPage * pageSize, totalCount) + + return ( +
+

+ Showing {startItem} to {endItem} of {totalCount} {itemName} +

+ + + + + { + e.preventDefault() + if (currentPage > 1) onPageChange(currentPage - 1) + }} + className={currentPage <= 1 ? 'pointer-events-none opacity-50' : ''} + /> + + + {getVisiblePages().map((page, index) => ( + + {page === '...' ? ( + + ) : ( + { + e.preventDefault() + onPageChange(page as number) + }} + isActive={currentPage === page} + > + {page} + + )} + + ))} + + + { + e.preventDefault() + if (currentPage < totalPages) onPageChange(currentPage + 1) + }} + className={currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''} + /> + + + + +
+ Show + + rows +
+
+ ) +} \ No newline at end of file diff --git a/src/components/admin/UsersHeader.tsx b/src/components/admin/UsersHeader.tsx new file mode 100644 index 0000000..5027fda --- /dev/null +++ b/src/components/admin/UsersHeader.tsx @@ -0,0 +1,156 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Filter, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react' + +export type UserSortField = 'name' | 'email' | 'role' | 'credits' | 'created_at' +export type SortOrder = 'asc' | 'desc' +export type UserStatus = 'all' | 'active' | 'inactive' + +interface UsersHeaderProps { + searchQuery: string + onSearchChange: (query: string) => void + sortBy: UserSortField + onSortByChange: (sortBy: UserSortField) => void + sortOrder: SortOrder + onSortOrderChange: (order: SortOrder) => void + statusFilter: UserStatus + onStatusFilterChange: (status: UserStatus) => void + onRefresh: () => void + loading: boolean + error: string | null + userCount: number +} + +export function UsersHeader({ + searchQuery, + onSearchChange, + sortBy, + onSortByChange, + sortOrder, + onSortOrderChange, + statusFilter, + onStatusFilterChange, + onRefresh, + loading, + error, + userCount, +}: UsersHeaderProps) { + return ( + <> + {/* Header */} +
+
+

User Management

+

+ Manage user accounts and permissions +

+
+
+ {!loading && !error && ( +
+ {statusFilter !== 'all' + ? `${userCount} ${statusFilter} user${userCount !== 1 ? 's' : ''}` + : `${userCount} user${userCount !== 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/admin/UsersLoadingStates.tsx b/src/components/admin/UsersLoadingStates.tsx new file mode 100644 index 0000000..07d2805 --- /dev/null +++ b/src/components/admin/UsersLoadingStates.tsx @@ -0,0 +1,112 @@ +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { AlertCircle, RefreshCw, Users } from 'lucide-react' + +export function UsersLoading() { + return ( +
+
+ + + + Name + Email + Role + Plan + Credits + Status + Actions + + + + {Array.from({ length: 10 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ ))} +
+
+
+
+ ) +} + +export function UsersError({ error, onRetry }: { error: string; onRetry: () => void }) { + return ( +
+
+ +
+
+

Failed to load users

+

+ {error} +

+
+ +
+ ) +} + +export function UsersEmpty({ searchQuery, statusFilter }: { + searchQuery: string; + statusFilter: string; +}) { + return ( +
+
+ +
+
+

+ {searchQuery || statusFilter !== 'all' + ? 'No users found' + : 'No users yet' + } +

+

+ {searchQuery + ? `No users match "${searchQuery}"` + : statusFilter !== 'all' + ? `No ${statusFilter} users found` + : 'Users will appear here once they are added to the system' + } +

+
+
+ ) +} \ No newline at end of file diff --git a/src/components/admin/UsersTable.tsx b/src/components/admin/UsersTable.tsx new file mode 100644 index 0000000..13cd5ee --- /dev/null +++ b/src/components/admin/UsersTable.tsx @@ -0,0 +1,88 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { User } from '@/types/auth' +import { Edit, UserCheck, UserX } from 'lucide-react' + +interface UsersTableProps { + users: User[] + onEdit: (user: User) => void + onToggleStatus: (user: User) => void +} + +export function UsersTable({ users, onEdit, onToggleStatus }: UsersTableProps) { + const getRoleBadge = (role: string) => { + return ( + + {role} + + ) + } + + const getStatusBadge = (isActive: boolean) => { + return ( + + {isActive ? 'Active' : 'Inactive'} + + ) + } + + return ( +
+ + + + Name + Email + Role + Plan + Credits + Status + Actions + + + + {users.map(user => ( + + {user.name} + {user.email} + {getRoleBadge(user.role)} + {user.plan.name} + {user.credits.toLocaleString()} + {getStatusBadge(user.is_active)} + +
+ + +
+
+
+ ))} +
+
+
+ ) +} \ No newline at end of file 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..56e8ebd --- /dev/null +++ b/src/components/extractions/ExtractionsRow.tsx @@ -0,0 +1,135 @@ +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, + User +} 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)} + + +
+ + + {extraction.user_name || 'Unknown'} + +
+
+ +
+ {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..3091430 --- /dev/null +++ b/src/components/extractions/ExtractionsTable.tsx @@ -0,0 +1,40 @@ +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 + User + Status + Created + Actions + + + + {extractions.map(extraction => ( + + ))} + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/playlists/PlaylistRow.tsx b/src/components/playlists/PlaylistRow.tsx index ae2279a..ea0511f 100644 --- a/src/components/playlists/PlaylistRow.tsx +++ b/src/components/playlists/PlaylistRow.tsx @@ -4,15 +4,22 @@ import { TableCell, TableRow } from '@/components/ui/table' import type { Playlist } from '@/lib/api/services/playlists' import { formatDateDistanceToNow } from '@/utils/format-date' import { formatDuration } from '@/utils/format-duration' -import { Calendar, Clock, Edit, Music, Play, User } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Calendar, Clock, Edit, Heart, Music, Play, User } from 'lucide-react' interface PlaylistRowProps { playlist: Playlist onEdit: (playlist: Playlist) => void onSetCurrent: (playlist: Playlist) => void + onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void } -export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps) { +export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistRowProps) { + const handleFavoriteToggle = () => { + if (onFavoriteToggle) { + onFavoriteToggle(playlist.id, !playlist.is_favorited) + } + } return ( @@ -76,6 +83,24 @@ export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps
+ {onFavoriteToggle && ( + + )}
@@ -293,6 +367,15 @@ export function SoundsPage() { )} + + - + - - - Users ({users.length}) - - - - - - Name - Email - Role - Plan - Credits - Status - Actions - - - - {users.map(user => ( - - {user.name} - {user.email} - {getRoleBadge(user.role)} - {user.plan.name} - {user.credits.toLocaleString()} - {getStatusBadge(user.is_active)} - -
- - -
-
-
- ))} -
-
-
-
+ {renderContent()} {/* Edit User Sheet */}