From 75ecd26e06f80b12e617a10b116ed01589f9c991 Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 17 Aug 2025 11:22:02 +0200 Subject: [PATCH] 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} />