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
+ onPageSizeChange(parseInt(value, 10))}
+ >
+
+
+
+
+ {pageSizeOptions.map(size => (
+
+ {size}
+
+ ))}
+
+
+ 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' : ''}`
+ }
+
+ )}
+
+
+ Refresh
+
+
+
+
+ {/* Search and Sort Controls */}
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {searchQuery && (
+ onSearchChange('')}
+ className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
+ title="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ onSortByChange(value as UserSortField)}
+ >
+
+
+
+
+ Name
+ Email
+ Role
+ Credits
+ Created Date
+
+
+
+ onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
+ title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
+ >
+ {sortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
+
+ onStatusFilterChange(value as UserStatus)}
+ >
+
+
+
+
+
+ All Status
+ Active
+ Inactive
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ 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}
+
+
+
+
+ Try again
+
+
+ )
+}
+
+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)}
+
+
+ onEdit(user)}
+ >
+
+
+ onToggleStatus(user)}
+ >
+ {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.
+
+
+
+
+
URL
+
onUrlChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ />
+
+ Paste a link to extract audio from the media
+
+
+
+
+
+ Cancel
+
+
+ {loading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ 'Create Extraction'
+ )}
+
+
+
+
+ )
+}
\ 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' : ''}`
+ }
+
+ )}
+
+
+ Add Extraction
+
+
+
+
+ {/* Search and Sort Controls */}
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {searchQuery && (
+ onSearchChange('')}
+ className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
+ title="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ onSortByChange(value as ExtractionSortField)}
+ >
+
+
+
+
+ Title
+ Status
+ Service
+ Created Date
+ Updated Date
+
+
+
+ onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
+ title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
+ >
+ {sortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
+
+ onStatusFilterChange(value as ExtractionStatus | 'all')}
+ >
+
+
+
+
+
+ All Status
+ Pending
+ Processing
+ Completed
+ Failed
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ 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}
+
+ Try again
+
+
+ )
+}
+
+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 && (
+
+
+
+ )}
void
onSetCurrent: (playlist: Playlist) => void
+ onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
}
-export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTableProps) {
+export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistTableProps) {
return (
@@ -37,6 +38,7 @@ export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTable
playlist={playlist}
onEdit={onEdit}
onSetCurrent={onSetCurrent}
+ onFavoriteToggle={onFavoriteToggle}
/>
))}
diff --git a/src/components/playlists/PlaylistsHeader.tsx b/src/components/playlists/PlaylistsHeader.tsx
index 4c62303..c9d2ed9 100644
--- a/src/components/playlists/PlaylistsHeader.tsx
+++ b/src/components/playlists/PlaylistsHeader.tsx
@@ -8,7 +8,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import type { PlaylistSortField, SortOrder } from '@/lib/api/services/playlists'
-import { Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
+import { Heart, Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
interface PlaylistsHeaderProps {
searchQuery: string
@@ -22,6 +22,8 @@ interface PlaylistsHeaderProps {
loading: boolean
error: string | null
playlistCount: number
+ showFavoritesOnly: boolean
+ onFavoritesToggle: (show: boolean) => void
}
export function PlaylistsHeader({
@@ -36,6 +38,8 @@ export function PlaylistsHeader({
loading,
error,
playlistCount,
+ showFavoritesOnly,
+ onFavoritesToggle,
}: PlaylistsHeaderProps) {
return (
<>
@@ -50,7 +54,10 @@ export function PlaylistsHeader({
{!loading && !error && (
- {playlistCount} playlist{playlistCount !== 1 ? 's' : ''}
+ {showFavoritesOnly
+ ? `${playlistCount} favorite playlist${playlistCount !== 1 ? 's' : ''}`
+ : `${playlistCount} playlist${playlistCount !== 1 ? 's' : ''}`
+ }
)}
@@ -116,6 +123,15 @@ export function PlaylistsHeader({
)}
+
onFavoritesToggle(!showFavoritesOnly)}
+ title={showFavoritesOnly ? "Show all playlists" : "Show only favorites"}
+ >
+
+
+
-
+ {showFavoritesOnly ? '💝' : '🎵'}
- No playlists found
+
+ {showFavoritesOnly ? 'No favorite playlists found' : 'No playlists found'}
+
- {searchQuery
- ? 'No playlists match your search criteria.'
- : 'No playlists are available.'}
+ {showFavoritesOnly
+ ? 'You haven\'t favorited any playlists yet. Click the heart icon on playlists to add them to your favorites.'
+ : searchQuery
+ ? 'No playlists match your search criteria.'
+ : 'No playlists are available.'
+ }
)
diff --git a/src/components/playlists/playlist-edit/PlaylistEditHeader.tsx b/src/components/playlists/playlist-edit/PlaylistEditHeader.tsx
index 07cb00b..f559d5b 100644
--- a/src/components/playlists/playlist-edit/PlaylistEditHeader.tsx
+++ b/src/components/playlists/playlist-edit/PlaylistEditHeader.tsx
@@ -1,17 +1,21 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Playlist } from '@/lib/api/services/playlists'
+import { Heart } from 'lucide-react'
+import { cn } from '@/lib/utils'
interface PlaylistEditHeaderProps {
playlist: Playlist
isEditMode: boolean
onSetCurrent: () => void
+ onFavoriteToggle?: (playlistId: number, shouldFavorite: boolean) => void
}
export function PlaylistEditHeader({
playlist,
isEditMode,
onSetCurrent,
+ onFavoriteToggle,
}: PlaylistEditHeaderProps) {
return (
@@ -25,6 +29,24 @@ export function PlaylistEditHeader({
+ {onFavoriteToggle && (
+
onFavoriteToggle(playlist.id, !playlist.is_favorited)}
+ className="h-8 w-8 p-0"
+ title={playlist.is_favorited ? "Remove from favorites" : "Add to favorites"}
+ >
+
+
+ )}
{!playlist.is_current && !isEditMode && (
Set as Current
diff --git a/src/components/sounds/SoundCard.tsx b/src/components/sounds/SoundCard.tsx
index dbf285d..0289cfa 100644
--- a/src/components/sounds/SoundCard.tsx
+++ b/src/components/sounds/SoundCard.tsx
@@ -4,43 +4,83 @@ import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { formatSize } from '@/utils/format-size'
import NumberFlow from '@number-flow/react'
-import { Clock, Play, Weight } from 'lucide-react'
+import { Clock, Heart, Play, Weight } from 'lucide-react'
interface SoundCardProps {
sound: Sound
playSound: (sound: Sound) => void
+ onFavoriteToggle: (soundId: number, isFavorited: boolean) => void
colorClasses: string
}
-export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
- const handlePlaySound = () => {
+export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }: SoundCardProps) {
+ const handlePlaySound = (e: React.MouseEvent) => {
+ // Don't play sound if clicking on favorite button
+ if ((e.target as HTMLElement).closest('[data-favorite-button]')) {
+ return
+ }
playSound(sound)
}
+ const handleFavoriteToggle = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onFavoriteToggle(sound.id, !sound.is_favorited)
+ }
+
return (
- {sound.name}
-
+ {/* Favorite button */}
+
+
+
+
+
{sound.name}
+
{formatDuration(sound.duration)}
-
-
- {formatSize(sound.size)}
-
+
+
+ {/* Show favorite count if > 0 */}
+
+
+
+ {formatSize(sound.size)}
+
+
+
+
+
+
)
diff --git a/src/contexts/SocketContext.tsx b/src/contexts/SocketContext.tsx
index 41151e4..857505f 100644
--- a/src/contexts/SocketContext.tsx
+++ b/src/contexts/SocketContext.tsx
@@ -94,6 +94,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
})
+ newSocket.on('sound_favorited', data => {
+ soundEvents.emit(SOUND_EVENTS.SOUND_FAVORITED, data)
+ })
+
// Listen for user events and emit them locally
newSocket.on('user_credits_changed', data => {
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
diff --git a/src/lib/api/services/admin.ts b/src/lib/api/services/admin.ts
index 958ee8e..08271c6 100644
--- a/src/lib/api/services/admin.ts
+++ b/src/lib/api/services/admin.ts
@@ -23,6 +23,23 @@ export interface MessageResponse {
message: string
}
+export interface GetUsersParams {
+ page?: number
+ limit?: number
+ search?: string
+ sort_by?: string
+ sort_order?: string
+ status_filter?: string
+}
+
+export interface GetUsersResponse {
+ users: User[]
+ total: number
+ page: number
+ limit: number
+ total_pages: number
+}
+
export interface ScanResults {
added: number
updated: number
@@ -54,10 +71,32 @@ export interface NormalizationResponse {
}
export class AdminService {
- async listUsers(limit = 100, offset = 0): Promise
{
- return apiClient.get(`/api/v1/admin/users/`, {
- params: { limit, offset },
- })
+ async listUsers(params?: GetUsersParams): Promise {
+ const searchParams = new URLSearchParams()
+
+ if (params?.page) {
+ searchParams.append('page', params.page.toString())
+ }
+ if (params?.limit) {
+ searchParams.append('limit', params.limit.toString())
+ }
+ 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 url = searchParams.toString()
+ ? `/api/v1/admin/users/?${searchParams.toString()}`
+ : '/api/v1/admin/users/'
+ return apiClient.get(url)
}
async getUser(userId: number): Promise {
diff --git a/src/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts
index a45a66e..496ba94 100644
--- a/src/lib/api/services/extractions.ts
+++ b/src/lib/api/services/extractions.ts
@@ -9,6 +9,7 @@ export interface ExtractionInfo {
service_id?: string
sound_id?: number
user_id: number
+ user_name?: string
error?: string
created_at: string
updated_at: string
@@ -21,6 +22,23 @@ export interface CreateExtractionResponse {
export interface GetExtractionsResponse {
extractions: ExtractionInfo[]
+ total: number
+ page: number
+ limit: number
+ total_pages: number
+}
+
+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
+ page?: number
+ limit?: number
}
export class ExtractionsService {
@@ -44,14 +62,68 @@ export class ExtractionsService {
return response
}
+ /**
+ * Get all extractions
+ */
+ async getAllExtractions(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)
+ }
+ if (params?.page) {
+ searchParams.append('page', params.page.toString())
+ }
+ if (params?.limit) {
+ searchParams.append('limit', params.limit.toString())
+ }
+
+ const queryString = searchParams.toString()
+ const url = queryString ? `/api/v1/extractions/?${queryString}` : '/api/v1/extractions/'
+
+ const response = await apiClient.get(url)
+ return response
+ }
+
/**
* Get user's extractions
*/
- async getUserExtractions(): Promise {
- const response = await apiClient.get(
- '/api/v1/extractions/',
- )
- return response.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)
+ }
+ if (params?.page) {
+ searchParams.append('page', params.page.toString())
+ }
+ if (params?.limit) {
+ searchParams.append('limit', params.limit.toString())
+ }
+
+ const queryString = searchParams.toString()
+ const url = queryString ? `/api/v1/extractions/user?${queryString}` : '/api/v1/extractions/user'
+
+ const response = await apiClient.get(url)
+ return response
}
}
diff --git a/src/lib/api/services/favorites.ts b/src/lib/api/services/favorites.ts
new file mode 100644
index 0000000..f0f053f
--- /dev/null
+++ b/src/lib/api/services/favorites.ts
@@ -0,0 +1,112 @@
+import { apiClient } from '../client'
+
+export interface Favorite {
+ id: number
+ user_id: number
+ sound_id?: number
+ playlist_id?: number
+ created_at: string
+ updated_at: string
+}
+
+export interface FavoriteCountsResponse {
+ total: number
+ sounds: number
+ playlists: number
+}
+
+export interface FavoritesListResponse {
+ favorites: Favorite[]
+}
+
+export class FavoritesService {
+ /**
+ * Add a sound to favorites
+ */
+ async addSoundFavorite(soundId: number): Promise {
+ const response = await apiClient.post(`/api/v1/favorites/sounds/${soundId}`)
+ return response
+ }
+
+ /**
+ * Remove a sound from favorites
+ */
+ async removeSoundFavorite(soundId: number): Promise {
+ await apiClient.delete(`/api/v1/favorites/sounds/${soundId}`)
+ }
+
+ /**
+ * Add a playlist to favorites
+ */
+ async addPlaylistFavorite(playlistId: number): Promise {
+ const response = await apiClient.post(`/api/v1/favorites/playlists/${playlistId}`)
+ return response
+ }
+
+ /**
+ * Remove a playlist from favorites
+ */
+ async removePlaylistFavorite(playlistId: number): Promise {
+ await apiClient.delete(`/api/v1/favorites/playlists/${playlistId}`)
+ }
+
+ /**
+ * Get all favorites for the current user
+ */
+ async getFavorites(limit = 50, offset = 0): Promise {
+ const response = await apiClient.get(
+ `/api/v1/favorites/?limit=${limit}&offset=${offset}`
+ )
+ return response.favorites
+ }
+
+ /**
+ * Get sound favorites for the current user
+ */
+ async getSoundFavorites(limit = 50, offset = 0): Promise {
+ const response = await apiClient.get(
+ `/api/v1/favorites/sounds?limit=${limit}&offset=${offset}`
+ )
+ return response.favorites
+ }
+
+ /**
+ * Get playlist favorites for the current user
+ */
+ async getPlaylistFavorites(limit = 50, offset = 0): Promise {
+ const response = await apiClient.get(
+ `/api/v1/favorites/playlists?limit=${limit}&offset=${offset}`
+ )
+ return response.favorites
+ }
+
+ /**
+ * Get favorite counts for the current user
+ */
+ async getFavoriteCounts(): Promise {
+ const response = await apiClient.get('/api/v1/favorites/counts')
+ return response
+ }
+
+ /**
+ * Check if a sound is favorited
+ */
+ async isSoundFavorited(soundId: number): Promise {
+ const response = await apiClient.get<{ is_favorited: boolean }>(
+ `/api/v1/favorites/sounds/${soundId}/check`
+ )
+ return response.is_favorited
+ }
+
+ /**
+ * Check if a playlist is favorited
+ */
+ async isPlaylistFavorited(playlistId: number): Promise {
+ const response = await apiClient.get<{ is_favorited: boolean }>(
+ `/api/v1/favorites/playlists/${playlistId}/check`
+ )
+ return response.is_favorited
+ }
+}
+
+export const favoritesService = new FavoritesService()
\ No newline at end of file
diff --git a/src/lib/api/services/index.ts b/src/lib/api/services/index.ts
index c2b1d7f..7e3d8cb 100644
--- a/src/lib/api/services/index.ts
+++ b/src/lib/api/services/index.ts
@@ -3,3 +3,4 @@ export * from './sounds'
export * from './player'
export * from './files'
export * from './extractions'
+export * from './favorites'
diff --git a/src/lib/api/services/playlists.ts b/src/lib/api/services/playlists.ts
index 21ee17c..396e642 100644
--- a/src/lib/api/services/playlists.ts
+++ b/src/lib/api/services/playlists.ts
@@ -19,6 +19,8 @@ export interface Playlist {
is_main: boolean
is_current: boolean
is_deletable: boolean
+ is_favorited: boolean
+ favorite_count: number
created_at: string
updated_at: string | null
sound_count: number
@@ -43,15 +45,24 @@ export interface GetPlaylistsParams {
search?: string
sort_by?: PlaylistSortField
sort_order?: SortOrder
+ page?: number
limit?: number
- offset?: number
+ favorites_only?: boolean
+}
+
+export interface GetPlaylistsResponse {
+ playlists: Playlist[]
+ total: number
+ page: number
+ limit: number
+ total_pages: number
}
export class PlaylistsService {
/**
* Get all playlists with optional filtering, searching, and sorting
*/
- async getPlaylists(params?: GetPlaylistsParams): Promise {
+ async getPlaylists(params?: GetPlaylistsParams): Promise {
const searchParams = new URLSearchParams()
// Handle parameters
@@ -64,17 +75,20 @@ export class PlaylistsService {
if (params?.sort_order) {
searchParams.append('sort_order', params.sort_order)
}
+ if (params?.page) {
+ searchParams.append('page', params.page.toString())
+ }
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
- if (params?.offset) {
- searchParams.append('offset', params.offset.toString())
+ if (params?.favorites_only) {
+ searchParams.append('favorites_only', 'true')
}
const url = searchParams.toString()
? `/api/v1/playlists/?${searchParams.toString()}`
: '/api/v1/playlists/'
- return apiClient.get(url)
+ return apiClient.get(url)
}
/**
diff --git a/src/lib/api/services/sounds.ts b/src/lib/api/services/sounds.ts
index 4d590f2..9d47a8d 100644
--- a/src/lib/api/services/sounds.ts
+++ b/src/lib/api/services/sounds.ts
@@ -17,6 +17,8 @@ export interface Sound {
thumbnail?: string
is_music: boolean
is_deletable: boolean
+ is_favorited: boolean
+ favorite_count: number
created_at: string
updated_at: string
}
@@ -39,6 +41,7 @@ export interface GetSoundsParams {
sort_order?: SortOrder
limit?: number
offset?: number
+ favorites_only?: boolean
}
export interface GetSoundsResponse {
@@ -75,6 +78,9 @@ export class SoundsService {
if (params?.offset) {
searchParams.append('offset', params.offset.toString())
}
+ if (params?.favorites_only) {
+ searchParams.append('favorites_only', 'true')
+ }
const url = searchParams.toString()
? `/api/v1/sounds/?${searchParams.toString()}`
diff --git a/src/lib/events.ts b/src/lib/events.ts
index 5b3dcd0..814b1ff 100644
--- a/src/lib/events.ts
+++ b/src/lib/events.ts
@@ -53,6 +53,7 @@ export const PLAYER_EVENTS = {
// Sound event types
export const SOUND_EVENTS = {
SOUND_PLAYED: 'sound_played',
+ SOUND_FAVORITED: 'sound_favorited',
} as const
// User event types
diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx
index c444dd7..79c576b 100644
--- a/src/pages/ExtractionsPage.tsx
+++ b/src/pages/ExtractionsPage.tsx
@@ -1,68 +1,102 @@
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 { AppPagination } from '@/components/AppPagination'
+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')
+
+ // Pagination state
+ const [currentPage, setCurrentPage] = useState(1)
+ const [totalPages, setTotalPages] = useState(1)
+ const [totalCount, setTotalCount] = useState(0)
+ const [pageSize, setPageSize] = useState(10)
+
+ // 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()
- setExtractions(data)
- } catch (error) {
- console.error('Failed to load extractions:', error)
- toast.error('Failed to load extractions')
+ setLoading(true)
+ setError(null)
+ const response = await extractionsService.getAllExtractions({
+ search: debouncedSearchQuery.trim() || undefined,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ status_filter: statusFilter !== 'all' ? statusFilter : undefined,
+ page: currentPage,
+ limit: pageSize,
+ })
+ setExtractions(response.extractions)
+ setTotalPages(response.total_pages)
+ setTotalCount(response.total)
+ } 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, currentPage, pageSize])
+
+ // Reset to page 1 when filters change
+ useEffect(() => {
+ if (currentPage !== 1) {
+ setCurrentPage(1)
+ }
+ }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, pageSize])
+
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page)
+ }
+
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size)
+ setCurrentPage(1) // Reset to first page when changing page size
+ }
+
- // Create new extraction
const handleCreateExtraction = async () => {
if (!url.trim()) {
toast.error('Please enter a URL')
@@ -70,76 +104,56 @@ 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
+ }
+
+ if (extractions.length === 0) {
+ return
+ }
return (
-
- {service.toUpperCase()}
-
+
)
}
@@ -150,163 +164,33 @@ export function ExtractionsPage() {
}}
>
-
-
-
Audio Extractions
-
- Extract audio from YouTube, SoundCloud, and other platforms
-
-
+
setShowCreateDialog(true)}
+ loading={loading}
+ error={error}
+ extractionCount={totalCount}
+ />
-
-
-
-
- Add Extraction
-
-
-
-
- Create New Extraction
-
-
-
-
URL
-
setUrl(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter' && !isCreating) {
- handleCreateExtraction()
- }
- }}
- />
-
- Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter,
- Instagram, and more
-
-
-
- setIsDialogOpen(false)}
- >
- Cancel
-
-
- {isCreating ? (
- <>
-
- Creating...
- >
- ) : (
- 'Create Extraction'
- )}
-
-
-
-
-
-
+
- {isLoading ? (
-
-
- Loading extractions...
-
- ) : extractions.length === 0 ? (
-
-
-
-
-
- No extractions yet
-
-
- Start by adding a URL to extract audio from your favorite
- platforms
-
-
setIsDialogOpen(true)} className="gap-2">
-
- Add Your First Extraction
-
-
-
-
- ) : (
-
-
- 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()}
)
diff --git a/src/pages/PlaylistEditPage.tsx b/src/pages/PlaylistEditPage.tsx
index 0f07bc6..510b496 100644
--- a/src/pages/PlaylistEditPage.tsx
+++ b/src/pages/PlaylistEditPage.tsx
@@ -30,6 +30,7 @@ import {
playlistsService,
} from '@/lib/api/services/playlists'
import { type Sound, soundsService } from '@/lib/api/services/sounds'
+import { favoritesService } from '@/lib/api/services/favorites'
import {
DndContext,
type DragEndEvent,
@@ -235,6 +236,37 @@ export function PlaylistEditPage() {
}
}
+ const handleFavoriteToggle = async (playlistId: number, shouldFavorite: boolean) => {
+ try {
+ if (shouldFavorite) {
+ await favoritesService.addPlaylistFavorite(playlistId)
+ toast.success('Added to favorites')
+ } else {
+ await favoritesService.removePlaylistFavorite(playlistId)
+ toast.success('Removed from favorites')
+ }
+
+ // Update the playlist in the local state
+ setPlaylist(prevPlaylist =>
+ prevPlaylist
+ ? {
+ ...prevPlaylist,
+ is_favorited: shouldFavorite,
+ favorite_count: shouldFavorite
+ ? prevPlaylist.favorite_count + 1
+ : Math.max(0, prevPlaylist.favorite_count - 1),
+ }
+ : null,
+ )
+ } catch (error) {
+ toast.error(
+ `Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`,
+ )
+ }
+ }
+
const handleMoveSoundUp = async (index: number) => {
if (index === 0 || sounds.length < 2) return
@@ -574,6 +606,7 @@ export function PlaylistEditPage() {
playlist={playlist}
isEditMode={isEditMode}
onSetCurrent={handleSetCurrent}
+ onFavoriteToggle={handleFavoriteToggle}
/>
diff --git a/src/pages/PlaylistsPage.tsx b/src/pages/PlaylistsPage.tsx
index 1de4057..9b21757 100644
--- a/src/pages/PlaylistsPage.tsx
+++ b/src/pages/PlaylistsPage.tsx
@@ -1,4 +1,5 @@
import { AppLayout } from '@/components/AppLayout'
+import { AppPagination } from '@/components/AppPagination'
import { CreatePlaylistDialog } from '@/components/playlists/CreatePlaylistDialog'
import { PlaylistsHeader } from '@/components/playlists/PlaylistsHeader'
import {
@@ -13,6 +14,7 @@ import {
type SortOrder,
playlistsService,
} from '@/lib/api/services/playlists'
+import { favoritesService } from '@/lib/api/services/favorites'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { toast } from 'sonner'
@@ -27,6 +29,13 @@ export function PlaylistsPage() {
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState
('name')
const [sortOrder, setSortOrder] = useState('asc')
+ const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
+
+ // Pagination state
+ const [currentPage, setCurrentPage] = useState(1)
+ const [totalPages, setTotalPages] = useState(1)
+ const [totalCount, setTotalCount] = useState(0)
+ const [pageSize, setPageSize] = useState(10)
// Create playlist dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
@@ -52,12 +61,17 @@ export function PlaylistsPage() {
try {
setLoading(true)
setError(null)
- const playlistData = await playlistsService.getPlaylists({
+ const response = await playlistsService.getPlaylists({
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
+ favorites_only: showFavoritesOnly,
+ page: currentPage,
+ limit: pageSize,
})
- setPlaylists(playlistData)
+ setPlaylists(response.playlists)
+ setTotalPages(response.total_pages)
+ setTotalCount(response.total)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch playlists'
@@ -70,7 +84,23 @@ export function PlaylistsPage() {
useEffect(() => {
fetchPlaylists()
- }, [debouncedSearchQuery, sortBy, sortOrder])
+ }, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, currentPage, pageSize])
+
+ // Reset to page 1 when filters change
+ useEffect(() => {
+ if (currentPage !== 1) {
+ setCurrentPage(1)
+ }
+ }, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, pageSize])
+
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page)
+ }
+
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size)
+ setCurrentPage(1) // Reset to first page when changing page size
+ }
const handleCreatePlaylist = async () => {
if (!newPlaylist.name.trim()) {
@@ -126,6 +156,39 @@ export function PlaylistsPage() {
navigate(`/playlists/${playlist.id}/edit`)
}
+ const handleFavoriteToggle = async (playlistId: number, shouldFavorite: boolean) => {
+ try {
+ if (shouldFavorite) {
+ await favoritesService.addPlaylistFavorite(playlistId)
+ toast.success('Added to favorites')
+ } else {
+ await favoritesService.removePlaylistFavorite(playlistId)
+ toast.success('Removed from favorites')
+ }
+
+ // Update the playlist in the local state
+ setPlaylists(prevPlaylists =>
+ prevPlaylists.map(playlist =>
+ playlist.id === playlistId
+ ? {
+ ...playlist,
+ is_favorited: shouldFavorite,
+ favorite_count: shouldFavorite
+ ? playlist.favorite_count + 1
+ : Math.max(0, playlist.favorite_count - 1),
+ }
+ : playlist,
+ ),
+ )
+ } catch (error) {
+ toast.error(
+ `Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`,
+ )
+ }
+ }
+
const renderContent = () => {
if (loading) {
return
@@ -136,15 +199,27 @@ export function PlaylistsPage() {
}
if (playlists.length === 0) {
- return
+ return
}
return (
-
+
)
}
@@ -166,7 +241,9 @@ export function PlaylistsPage() {
onCreateClick={() => setShowCreateDialog(true)}
loading={loading}
error={error}
- playlistCount={playlists.length}
+ playlistCount={totalCount}
+ showFavoritesOnly={showFavoritesOnly}
+ onFavoritesToggle={setShowFavoritesOnly}
/>
('name')
const [sortOrder, setSortOrder] = useState('asc')
+ const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
const handlePlaySound = async (sound: Sound) => {
try {
@@ -89,6 +100,36 @@ export function SoundsPage() {
}
}
+ const handleFavoriteToggle = async (soundId: number, shouldFavorite: boolean) => {
+ try {
+ if (shouldFavorite) {
+ await favoritesService.addSoundFavorite(soundId)
+ toast.success('Added to favorites')
+ } else {
+ await favoritesService.removeSoundFavorite(soundId)
+ toast.success('Removed from favorites')
+ }
+
+ // Update the sound in the local state
+ setSounds(prevSounds =>
+ prevSounds.map(sound =>
+ sound.id === soundId
+ ? {
+ ...sound,
+ is_favorited: shouldFavorite,
+ }
+ : sound,
+ ),
+ )
+ } catch (error) {
+ toast.error(
+ `Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`,
+ )
+ }
+ }
+
const { theme } = useTheme()
useEffect(() => {
@@ -112,6 +153,7 @@ export function SoundsPage() {
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
+ favorites_only: showFavoritesOnly,
})
setSounds(sdbSounds)
} catch (err) {
@@ -137,7 +179,7 @@ export function SoundsPage() {
useEffect(() => {
fetchSounds()
- }, [debouncedSearchQuery, sortBy, sortOrder])
+ }, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
// Listen for sound_played events and update play_count
useEffect(() => {
@@ -159,6 +201,29 @@ export function SoundsPage() {
}
}, [])
+ // Listen for sound_favorited events and update favorite status and count
+ useEffect(() => {
+ const handleSoundFavorited = (...args: unknown[]) => {
+ const eventData = args[0] as SoundFavoritedEventData
+ setSounds(prevSounds =>
+ prevSounds.map(sound =>
+ sound.id === eventData.sound_id
+ ? {
+ ...sound,
+ favorite_count: eventData.favorite_count
+ }
+ : sound,
+ ),
+ )
+ }
+
+ soundEvents.on(SOUND_EVENTS.SOUND_FAVORITED, handleSoundFavorited)
+
+ return () => {
+ soundEvents.off(SOUND_EVENTS.SOUND_FAVORITED, handleSoundFavorited)
+ }
+ }, [])
+
const renderContent = () => {
if (loading) {
return (
@@ -192,11 +257,16 @@ export function SoundsPage() {
return (
- 🎵
+ {showFavoritesOnly ? '💝' : '🎵'}
-
No sounds found
+
+ {showFavoritesOnly ? 'No favorite sounds found' : 'No sounds found'}
+
- No SDB type sounds are available in your library.
+ {showFavoritesOnly
+ ? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
+ : 'No SDB type sounds are available in your library.'
+ }
)
@@ -209,6 +279,7 @@ export function SoundsPage() {
key={sound.id}
sound={sound}
playSound={handlePlaySound}
+ onFavoriteToggle={handleFavoriteToggle}
colorClasses={getSoundColor(idx)}
/>
))}
@@ -232,7 +303,10 @@ export function SoundsPage() {
{!loading && !error && (
- {sounds.length} sound{sounds.length !== 1 ? 's' : ''}
+ {showFavoritesOnly
+ ? `${sounds.length} favorite sound${sounds.length !== 1 ? 's' : ''}`
+ : `${sounds.length} sound${sounds.length !== 1 ? 's' : ''}`
+ }
)}
@@ -293,6 +367,15 @@ export function SoundsPage() {
)}
+
setShowFavoritesOnly(!showFavoritesOnly)}
+ title={showFavoritesOnly ? "Show all sounds" : "Show only favorites"}
+ >
+
+
+
([])
const [plans, setPlans] = useState([])
const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // Search and filtering state
+ const [searchQuery, setSearchQuery] = useState('')
+ const [sortBy, setSortBy] = useState('name')
+ const [sortOrder, setSortOrder] = useState('asc')
+ const [statusFilter, setStatusFilter] = useState('all')
+
+ // Pagination state
+ const [currentPage, setCurrentPage] = useState(1)
+ const [totalPages, setTotalPages] = useState(1)
+ const [totalCount, setTotalCount] = useState(0)
+ const [pageSize, setPageSize] = useState(10)
+
+ // Edit user state
const [editingUser, setEditingUser] = useState(null)
const [editData, setEditData] = useState({
name: '',
@@ -49,26 +61,65 @@ export function UsersPage() {
})
const [saving, setSaving] = useState(false)
- useEffect(() => {
- loadData()
- }, [])
+ // Debounce search query
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
- const loadData = async () => {
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearchQuery(searchQuery)
+ }, 300)
+
+ return () => clearTimeout(handler)
+ }, [searchQuery])
+
+ const fetchUsers = async () => {
try {
- const [usersData, plansData] = await Promise.all([
- adminService.listUsers(),
+ setLoading(true)
+ setError(null)
+ const [usersResponse, plansData] = await Promise.all([
+ adminService.listUsers({
+ page: currentPage,
+ limit: pageSize,
+ search: debouncedSearchQuery.trim() || undefined,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ status_filter: statusFilter !== 'all' ? statusFilter : undefined,
+ }),
adminService.listPlans(),
])
- setUsers(usersData)
+ setUsers(usersResponse.users)
+ setTotalPages(usersResponse.total_pages)
+ setTotalCount(usersResponse.total)
setPlans(plansData)
- } catch (error) {
- toast.error('Failed to load data')
- console.error('Error loading data:', error)
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load users'
+ setError(errorMessage)
+ toast.error(errorMessage)
} finally {
setLoading(false)
}
}
+ useEffect(() => {
+ fetchUsers()
+ }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, currentPage, pageSize])
+
+ // Reset to page 1 when filters change
+ useEffect(() => {
+ if (currentPage !== 1) {
+ setCurrentPage(1)
+ }
+ }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, pageSize])
+
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page)
+ }
+
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size)
+ setCurrentPage(1) // Reset to first page when changing page size
+ }
+
const handleEditUser = (user: User) => {
setEditingUser(user)
setEditData({
@@ -111,7 +162,7 @@ export function UsersPage() {
toast.success('User enabled successfully')
}
// Reload data to get updated user status
- loadData()
+ fetchUsers()
} catch (error) {
toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`)
console.error('Error toggling user status:', error)
@@ -126,47 +177,36 @@ export function UsersPage() {
)
}
- const getStatusBadge = (isActive: boolean) => {
- return (
-
- {isActive ? 'Active' : 'Inactive'}
-
- )
- }
+ const renderContent = () => {
+ if (loading) {
+ return
+ }
+
+ if (error) {
+ return
+ }
+
+ if (users.length === 0) {
+ return
+ }
- if (loading) {
return (
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
-
-
-
+
)
}
@@ -181,72 +221,22 @@ export function UsersPage() {
}}
>
-
-
-
User Management
-
- Manage user accounts and permissions
-
-
-
- Refresh
-
-
+
-
-
- 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)}
-
-
- handleEditUser(user)}
- >
-
-
- handleToggleUserStatus(user)}
- >
- {user.is_active ? (
-
- ) : (
-
- )}
-
-
-
-
- ))}
-
-
-
-
+ {renderContent()}
{/* Edit User Sheet */}