From 2e41d5b695ece3fbd9eea5bde4bfdf249ffde697 Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 16 Aug 2025 21:16:13 +0200 Subject: [PATCH 1/9] feat: implement favorites functionality with SoundCard integration and FavoritesService --- src/components/sounds/SoundCard.tsx | 60 ++++++++++++--- src/lib/api/services/favorites.ts | 112 ++++++++++++++++++++++++++++ src/lib/api/services/index.ts | 1 + src/lib/api/services/sounds.ts | 2 + src/pages/SoundsPage.tsx | 69 +++++++++++++++-- 5 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 src/lib/api/services/favorites.ts 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/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/sounds.ts b/src/lib/api/services/sounds.ts index 4d590f2..520ba66 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 } diff --git a/src/pages/SoundsPage.tsx b/src/pages/SoundsPage.tsx index 938b981..4d90053 100644 --- a/src/pages/SoundsPage.tsx +++ b/src/pages/SoundsPage.tsx @@ -17,9 +17,11 @@ import { type SoundSortField, soundsService, } from '@/lib/api/services/sounds' +import { favoritesService } from '@/lib/api/services/favorites' import { SOUND_EVENTS, soundEvents } from '@/lib/events' import { AlertCircle, + Heart, RefreshCw, Search, SortAsc, @@ -77,6 +79,7 @@ export function SoundsPage() { const [searchQuery, setSearchQuery] = useState('') const [sortBy, setSortBy] = useState('name') const [sortOrder, setSortOrder] = useState('asc') + const [showFavoritesOnly, setShowFavoritesOnly] = useState(false) const handlePlaySound = async (sound: Sound) => { try { @@ -89,6 +92,39 @@ 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, + favorite_count: shouldFavorite + ? sound.favorite_count + 1 + : Math.max(0, sound.favorite_count - 1), + } + : sound, + ), + ) + } catch (error) { + toast.error( + `Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + } + } + const { theme } = useTheme() useEffect(() => { @@ -188,15 +224,23 @@ export function SoundsPage() { ) } - if (sounds.length === 0) { + // Filter sounds based on favorites filter + const filteredSounds = showFavoritesOnly ? sounds.filter(sound => sound.is_favorited) : sounds + + if (filteredSounds.length === 0) { 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.' + }

) @@ -204,11 +248,12 @@ export function SoundsPage() { return (
- {sounds.map((sound, idx) => ( + {filteredSounds.map((sound, idx) => ( ))} @@ -232,7 +277,10 @@ export function SoundsPage() {
{!loading && !error && (
- {sounds.length} sound{sounds.length !== 1 ? 's' : ''} + {showFavoritesOnly + ? `${sounds.filter(s => s.is_favorited).length} favorite sound${sounds.filter(s => s.is_favorited).length !== 1 ? 's' : ''}` + : `${sounds.length} sound${sounds.length !== 1 ? 's' : ''}` + }
)}
@@ -293,6 +341,15 @@ export function SoundsPage() { )} + + + )} - - - - 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()} ) From 04401092bbc11232d61512a6cb5afbdd7e4ec71e Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 17 Aug 2025 01:44:38 +0200 Subject: [PATCH 7/9] feat: add user information display in extractions table and update extraction retrieval method --- src/components/extractions/ExtractionsRow.tsx | 11 ++++++- .../extractions/ExtractionsTable.tsx | 1 + src/lib/api/services/extractions.ts | 31 +++++++++++++++++-- src/pages/ExtractionsPage.tsx | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/components/extractions/ExtractionsRow.tsx b/src/components/extractions/ExtractionsRow.tsx index 09c60e9..56e8ebd 100644 --- a/src/components/extractions/ExtractionsRow.tsx +++ b/src/components/extractions/ExtractionsRow.tsx @@ -9,7 +9,8 @@ import { CheckCircle, Clock, ExternalLink, - Loader2 + Loader2, + User } from 'lucide-react' interface ExtractionsRowProps { @@ -88,6 +89,14 @@ export function ExtractionsRow({ extraction }: ExtractionsRowProps) { {getServiceBadge(extraction.service)} + +
+ + + {extraction.user_name || 'Unknown'} + +
+
{getStatusBadge(extraction.status)} diff --git a/src/components/extractions/ExtractionsTable.tsx b/src/components/extractions/ExtractionsTable.tsx index 67a5c45..3091430 100644 --- a/src/components/extractions/ExtractionsTable.tsx +++ b/src/components/extractions/ExtractionsTable.tsx @@ -20,6 +20,7 @@ export function ExtractionsTable({ extractions }: ExtractionsTableProps) { Title Service + User Status Created Actions diff --git a/src/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts index 714cbc3..78535c5 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 @@ -56,9 +57,9 @@ export class ExtractionsService { } /** - * Get user's extractions + * Get all extractions */ - async getUserExtractions(params?: GetExtractionsParams): Promise { + async getAllExtractions(params?: GetExtractionsParams): Promise { const searchParams = new URLSearchParams() if (params?.search) { @@ -80,6 +81,32 @@ export class ExtractionsService { const response = await apiClient.get(url) return response.extractions } + + /** + * Get user's 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/user?${queryString}` : '/api/v1/extractions/user' + + const response = await apiClient.get(url) + return response.extractions + } } export const extractionsService = new ExtractionsService() diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx index 86c2bfe..22f9624 100644 --- a/src/pages/ExtractionsPage.tsx +++ b/src/pages/ExtractionsPage.tsx @@ -48,7 +48,7 @@ export function ExtractionsPage() { try { setLoading(true) setError(null) - const data = await extractionsService.getUserExtractions({ + const data = await extractionsService.getAllExtractions({ search: debouncedSearchQuery.trim() || undefined, sort_by: sortBy, sort_order: sortOrder, From 75ecd26e06f80b12e617a10b116ed01589f9c991 Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 17 Aug 2025 11:22:02 +0200 Subject: [PATCH 8/9] feat: implement pagination for extractions and playlists with updated API responses --- src/components/AppPagination.tsx | 147 ++++++++++++++++++++++++++++ src/lib/api/services/extractions.ts | 26 ++++- src/lib/api/services/playlists.ts | 20 ++-- src/pages/ExtractionsPage.tsx | 51 +++++++++- src/pages/PlaylistsPage.tsx | 58 +++++++++-- 5 files changed, 277 insertions(+), 25 deletions(-) create mode 100644 src/components/AppPagination.tsx 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/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts index 78535c5..496ba94 100644 --- a/src/lib/api/services/extractions.ts +++ b/src/lib/api/services/extractions.ts @@ -22,6 +22,10 @@ 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' @@ -33,6 +37,8 @@ export interface GetExtractionsParams { sort_by?: ExtractionSortField sort_order?: ExtractionSortOrder status_filter?: ExtractionStatus + page?: number + limit?: number } export class ExtractionsService { @@ -59,7 +65,7 @@ export class ExtractionsService { /** * Get all extractions */ - async getAllExtractions(params?: GetExtractionsParams): Promise { + async getAllExtractions(params?: GetExtractionsParams): Promise { const searchParams = new URLSearchParams() if (params?.search) { @@ -74,18 +80,24 @@ export class ExtractionsService { 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.extractions + return response } /** * Get user's extractions */ - async getUserExtractions(params?: GetExtractionsParams): Promise { + async getUserExtractions(params?: GetExtractionsParams): Promise { const searchParams = new URLSearchParams() if (params?.search) { @@ -100,12 +112,18 @@ export class ExtractionsService { 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.extractions + return response } } diff --git a/src/lib/api/services/playlists.ts b/src/lib/api/services/playlists.ts index c76631d..396e642 100644 --- a/src/lib/api/services/playlists.ts +++ b/src/lib/api/services/playlists.ts @@ -45,16 +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 @@ -67,12 +75,12 @@ 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') } @@ -80,7 +88,7 @@ export class PlaylistsService { const url = searchParams.toString() ? `/api/v1/playlists/?${searchParams.toString()}` : '/api/v1/playlists/' - return apiClient.get(url) + return apiClient.get(url) } /** diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx index 22f9624..79c576b 100644 --- a/src/pages/ExtractionsPage.tsx +++ b/src/pages/ExtractionsPage.tsx @@ -1,4 +1,5 @@ import { AppLayout } from '@/components/AppLayout' +import { AppPagination } from '@/components/AppPagination' import { CreateExtractionDialog } from '@/components/extractions/CreateExtractionDialog' import { ExtractionsHeader } from '@/components/extractions/ExtractionsHeader' import { @@ -28,6 +29,12 @@ export function ExtractionsPage() { 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) @@ -48,13 +55,17 @@ export function ExtractionsPage() { try { setLoading(true) setError(null) - const data = await extractionsService.getAllExtractions({ + 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(data) + setExtractions(response.extractions) + setTotalPages(response.total_pages) + setTotalCount(response.total) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch extractions' @@ -67,7 +78,24 @@ export function ExtractionsPage() { useEffect(() => { fetchExtractions() - }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter]) + }, [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 handleCreateExtraction = async () => { if (!url.trim()) { @@ -113,7 +141,20 @@ export function ExtractionsPage() { return } - return + return ( +
+ + +
+ ) } return ( @@ -136,7 +177,7 @@ export function ExtractionsPage() { onCreateClick={() => setShowCreateDialog(true)} loading={loading} error={error} - extractionCount={extractions.length} + extractionCount={totalCount} /> ('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) const [createLoading, setCreateLoading] = useState(false) @@ -54,13 +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' @@ -73,7 +84,23 @@ export function PlaylistsPage() { useEffect(() => { fetchPlaylists() - }, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly]) + }, [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()) { @@ -176,12 +203,23 @@ export function PlaylistsPage() { } return ( - +
+ + +
) } @@ -203,7 +241,7 @@ export function PlaylistsPage() { onCreateClick={() => setShowCreateDialog(true)} loading={loading} error={error} - playlistCount={playlists.length} + playlistCount={totalCount} showFavoritesOnly={showFavoritesOnly} onFavoritesToggle={setShowFavoritesOnly} /> From 46bfcad2718d44ea3dbfa01fbddeb888ef59c07a Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 17 Aug 2025 11:44:08 +0200 Subject: [PATCH 9/9] feat: add user management components including header, loading states, and table with pagination --- src/components/admin/UsersHeader.tsx | 156 +++++++++++++ src/components/admin/UsersLoadingStates.tsx | 112 +++++++++ src/components/admin/UsersTable.tsx | 88 +++++++ src/lib/api/services/admin.ts | 47 +++- src/pages/admin/UsersPage.tsx | 242 ++++++++++---------- 5 files changed, 515 insertions(+), 130 deletions(-) create mode 100644 src/components/admin/UsersHeader.tsx create mode 100644 src/components/admin/UsersLoadingStates.tsx create mode 100644 src/components/admin/UsersTable.tsx 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/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/pages/admin/UsersPage.tsx b/src/pages/admin/UsersPage.tsx index 02e03a4..46203cc 100644 --- a/src/pages/admin/UsersPage.tsx +++ b/src/pages/admin/UsersPage.tsx @@ -1,7 +1,14 @@ import { AppLayout } from '@/components/AppLayout' +import { AppPagination } from '@/components/AppPagination' +import { UsersHeader, type UserSortField, type SortOrder, type UserStatus } from '@/components/admin/UsersHeader' +import { + UsersEmpty, + UsersError, + UsersLoading, +} from '@/components/admin/UsersLoadingStates' +import { UsersTable } from '@/components/admin/UsersTable' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { @@ -12,20 +19,10 @@ import { SelectValue, } from '@/components/ui/select' import { Sheet, SheetContent } from '@/components/ui/sheet' -import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' import { type Plan, adminService } from '@/lib/api/services/admin' import type { User } from '@/types/auth' import { formatDate } from '@/utils/format-date' -import { Edit, UserCheck, UserX } from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -40,6 +37,21 @@ export function UsersPage() { const [users, setUsers] = useState([]) 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 -

-
- -
+ - - - 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 */}