diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d293209e..b6e82a7c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: '🚀 Deploy Next.js Docker App' on: push: - branches: [main] + branches: ['main'] jobs: build-and-deploy: diff --git a/src/app/jobs/JobsPageClient.tsx b/src/app/jobs/JobsPageClient.tsx index f9c3d7d9..05ccdcad 100644 --- a/src/app/jobs/JobsPageClient.tsx +++ b/src/app/jobs/JobsPageClient.tsx @@ -1,32 +1,27 @@ "use client" -import React, { useEffect, useMemo, useRef, useState } from 'react' -import { useSearchParams } from 'next/navigation' +import React from 'react' import JobsTable from '@/components/jobs/JobsTable' import type { ApiResponse, JobsListData, JobWithRelations } from '@/lib/jobs/types' - -type JobsFetchResult = { - items: JobWithRelations[] - totalCount: number -} +import type { PaginatedResponse } from '@/lib/pagination' +import { usePagination } from '@/lib/pagination' const JOBS_PER_PAGE = 10 const POLL_INTERVAL_MS = 6000 -const MIN_REFRESH_INDICATOR_MS = 500 -async function fetchJobsPage( - page: number, - limit: number, - signal?: AbortSignal -): Promise { - const res = await fetch(`https://api.codebuilder.org/jobs?page=${page}&limit=${limit}`, { signal }) +/** + * Fetch function for jobs API using offset-based pagination + */ +async function fetchJobs(url: string, signal?: AbortSignal): Promise> { + const res = await fetch(url, { signal }) const json: ApiResponse | any = await res.json() - // Primary shape (current backend): { success: true, data: { items: [...], totalCount } } + // Primary shape (current backend): { success: true, data: { items: [...], totalCount, pageInfo } } if (json?.success === true && Array.isArray(json?.data?.items)) { return { items: json.data.items, totalCount: typeof json.data.totalCount === 'number' ? json.data.totalCount : 0, + pageInfo: json.data.pageInfo, } } @@ -42,129 +37,21 @@ async function fetchJobsPage( } export default function JobsPageClient() { - const searchParams = useSearchParams() - const [jobs, setJobs] = useState([]) - const [totalJobs, setTotalJobs] = useState(0) - const [currentPage, setCurrentPage] = useState(1) - const [isInitialLoading, setIsInitialLoading] = useState(true) - const [isRefreshing, setIsRefreshing] = useState(false) - const [refreshSecondsRemaining, setRefreshSecondsRemaining] = useState(null) - - const refreshInFlightRef = useRef(false) - const nextRefreshAtRef = useRef(0) - - const lastIdsSignatureRef = useRef('') - const lastTotalCountRef = useRef(0) - const didInitialLoadRef = useRef(false) - - const postsPerPage = JOBS_PER_PAGE - - const pageFromParams = useMemo(() => { - return parseInt(searchParams.get('page') || '1', 10) - }, [searchParams]) - - useEffect(() => { - const controller = new AbortController() - const page = Number.isFinite(pageFromParams) && pageFromParams > 0 ? pageFromParams : 1 - setCurrentPage(page) - - const isFirstEverLoad = !didInitialLoadRef.current - if (isFirstEverLoad) setIsInitialLoading(true) - - fetchJobsPage(page, postsPerPage, controller.signal) - .then(({ items, totalCount }) => { - setJobs(items) - setTotalJobs(totalCount) - lastTotalCountRef.current = totalCount - lastIdsSignatureRef.current = items.map((j) => j.id).join(',') - }) - .catch(() => { - setJobs([]) - setTotalJobs(0) - lastTotalCountRef.current = 0 - lastIdsSignatureRef.current = '' - }) - .finally(() => { - didInitialLoadRef.current = true - setIsInitialLoading(false) - }) - - return () => controller.abort() - }, [pageFromParams, postsPerPage]) - - useEffect(() => { - if (isInitialLoading) return - - let isUnmounted = false - const controller = new AbortController() - let clearRefreshingTimeoutId: number | null = null - - const tick = async () => { - if (refreshInFlightRef.current) return - refreshInFlightRef.current = true - setIsRefreshing(true) - const startedAt = Date.now() - try { - const { items, totalCount } = await fetchJobsPage(currentPage, postsPerPage, controller.signal) - if (isUnmounted) return - - const nextIdsSignature = items.map((j) => j.id).join(',') - const totalIncreased = totalCount > lastTotalCountRef.current - const rowsChanged = nextIdsSignature !== lastIdsSignatureRef.current - - if (totalIncreased || rowsChanged) { - setJobs(items) - setTotalJobs(totalCount) - lastTotalCountRef.current = totalCount - lastIdsSignatureRef.current = nextIdsSignature - } - } catch { - // Keep existing jobs on refresh failure; this is a background enhancement. - } finally { - refreshInFlightRef.current = false - if (!isUnmounted) { - const elapsed = Date.now() - startedAt - const remaining = Math.max(0, MIN_REFRESH_INDICATOR_MS - elapsed) - - if (clearRefreshingTimeoutId !== null) { - window.clearTimeout(clearRefreshingTimeoutId) - } - - clearRefreshingTimeoutId = window.setTimeout(() => { - if (!isUnmounted) setIsRefreshing(false) - }, remaining) - } - nextRefreshAtRef.current = Date.now() + POLL_INTERVAL_MS - } - } - - nextRefreshAtRef.current = Date.now() + POLL_INTERVAL_MS - - const countdownId = window.setInterval(() => { - const nextAt = nextRefreshAtRef.current - if (!nextAt) { - setRefreshSecondsRemaining(null) - return - } - const seconds = Math.max(0, Math.ceil((nextAt - Date.now()) / 1000)) - setRefreshSecondsRemaining(seconds) - }, 250) - - // Do one immediate background refresh after initial load. - void tick() - - const intervalId = window.setInterval(tick, POLL_INTERVAL_MS) - return () => { - isUnmounted = true - controller.abort() - window.clearInterval(intervalId) - window.clearInterval(countdownId) - - if (clearRefreshingTimeoutId !== null) { - window.clearTimeout(clearRefreshingTimeoutId) - } - } - }, [currentPage, postsPerPage, isInitialLoading]) + const { + items: jobs, + paginationState, + isInitialLoading, + isRefreshing, + refreshSecondsRemaining, + } = usePagination({ + config: { + type: 'offset-based', // Using offset-based pagination for the API + itemsPerPage: JOBS_PER_PAGE, + }, + fetchFn: fetchJobs, + baseUrl: 'https://api.codebuilder.org/jobs', + pollInterval: POLL_INTERVAL_MS, + }) return (
@@ -173,9 +60,9 @@ export default function JobsPageClient() {

Job Listings

> { + const res = await fetch(url, { signal }) + const json = await res.json() + + return { + items: json.data.items, + totalCount: json.data.totalCount, + pageInfo: json.data.pageInfo, // Optional: for cursor pagination + } +} + +// Use in your component +function JobsPage() { + const { + items: jobs, + paginationState, + isInitialLoading, + isRefreshing, + refreshSecondsRemaining, + goToPage, + goToNextPage, + goToPreviousPage, + } = usePagination({ + config: { + type: 'offset-based', // or 'page-based' + itemsPerPage: 10, + }, + fetchFn: fetchJobs, + baseUrl: 'https://api.example.com/jobs', + pollInterval: 6000, // Optional: auto-refresh every 6 seconds + }) + + return ( +
+ {isInitialLoading ? ( +
Loading...
+ ) : ( + <> +
    + {jobs.map((job) => ( +
  • {job.title}
  • + ))} +
+ + `/jobs?page=${page}`} + /> + + )} +
+ ) +} +``` + +### Page-Based Pagination Example + +```tsx +const { items, paginationState } = usePagination({ + config: { + type: 'page-based', // Will use ?page=X&limit=Y + itemsPerPage: 20, + initialPage: 1, + }, + fetchFn: fetchUsers, + baseUrl: 'https://api.example.com/users', +}) +``` + +### Without Auto-Refresh + +```tsx +const { items, paginationState } = usePagination({ + config: { + type: 'offset-based', + itemsPerPage: 10, + }, + fetchFn: fetchData, + baseUrl: 'https://api.example.com/data', + // Don't pass pollInterval +}) +``` + +## API Reference + +### `usePagination(options)` + +Hook for managing paginated data. + +#### Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `config` | `PaginationConfig` | Yes | Pagination configuration | +| `fetchFn` | `(url, signal?) => Promise>` | Yes | Function to fetch data | +| `baseUrl` | `string` | Yes | Base API endpoint URL | +| `pollInterval` | `number` | No | Auto-refresh interval in ms | +| `minRefreshDuration` | `number` | No | Minimum refresh indicator duration (default: 500ms) | + +#### Returns + +| Property | Type | Description | +|----------|------|-------------| +| `items` | `T[]` | Current page items | +| `paginationState` | `PaginationState` | Full pagination state | +| `isInitialLoading` | `boolean` | Loading state for first load | +| `isRefreshing` | `boolean` | Background refresh state | +| `refreshSecondsRemaining` | `number \| null` | Countdown to next refresh | +| `goToPage` | `(page: number) => void` | Navigate to specific page | +| `goToNextPage` | `() => void` | Navigate to next page | +| `goToPreviousPage` | `() => void` | Navigate to previous page | + +### Types + +#### `PaginatedResponse` + +```tsx +type PaginatedResponse = { + items: T[] + totalCount: number + pageInfo?: PageInfo // Optional for offset-based pagination +} +``` + +#### `PaginationConfig` + +```tsx +type PaginationConfig = { + type: 'page-based' | 'offset-based' + itemsPerPage: number + initialPage?: number // Default: 1 +} +``` + +#### `PaginationState` + +```tsx +type PaginationState = { + currentPage: number + itemsPerPage: number + totalItems: number + totalPages: number + hasNextPage: boolean + hasPreviousPage: boolean +} +``` + +## How It Works + +### Offset-Based Pagination + +When `type: 'offset-based'` is configured: +- Converts page numbers to `skip` and `first` parameters +- Example: Page 2 with 10 items → `?first=10&skip=10` +- Supports cursor-based pagination with `after`, `before`, `first`, `last` + +### Page-Based Pagination + +When `type: 'page-based'` is configured: +- Uses traditional `page` and `limit` parameters +- Example: Page 2 with 10 items → `?page=2&limit=10` + +### URL Synchronization + +The hook automatically: +- Reads the current page from `?page=X` query parameter +- Updates the URL when navigating between pages +- Maintains browser history for back/forward navigation + +### Background Polling + +When `pollInterval` is provided: +- Fetches new data in the background at specified intervals +- Shows refresh indicators (`isRefreshing`, `refreshSecondsRemaining`) +- Only updates state if data has changed +- Gracefully handles errors (keeps existing data) + +## Utility Functions + +### `buildPaginationQuery(params: PaginationParams): string` + +Builds query string from pagination parameters. + +```tsx +import { buildPaginationQuery, pageToOffsetParams } from '@/lib/pagination' + +const params = pageToOffsetParams(2, 10) +const query = buildPaginationQuery(params) +// Returns: "first=10&skip=10" +``` + +### `pageToOffsetParams(page: number, itemsPerPage: number): PaginationParams` + +Converts page number to offset-based parameters. + +### `pageToPageParams(page: number, itemsPerPage: number): PaginationParams` + +Converts page number to page-based parameters. + +### `parsePageFromParams(searchParams: URLSearchParams, defaultPage?: number): number` + +Parses and validates page number from URL search params. + +## Example: Multiple Paginated Lists + +You can use the hook multiple times in the same component: + +```tsx +function Dashboard() { + const jobs = usePagination({ + config: { type: 'offset-based', itemsPerPage: 10 }, + fetchFn: fetchJobs, + baseUrl: 'https://api.example.com/jobs', + }) + + const users = usePagination({ + config: { type: 'page-based', itemsPerPage: 20 }, + fetchFn: fetchUsers, + baseUrl: 'https://api.example.com/users', + }) + + return ( +
+
+
+
+ ) +} +``` + +## Real-World Example + +See [JobsPageClient.tsx](/src/app/jobs/JobsPageClient.tsx) for a complete implementation using offset-based pagination with auto-refresh. diff --git a/src/lib/pagination/example-cursor-based.tsx b/src/lib/pagination/example-cursor-based.tsx new file mode 100644 index 00000000..e9f13a41 --- /dev/null +++ b/src/lib/pagination/example-cursor-based.tsx @@ -0,0 +1,96 @@ +/** + * Example: Using the pagination system with cursor-based API + * + * This example shows how to use the pagination hook with a cursor-based API + * that uses GraphQL Relay-style cursor pagination. + */ + +'use client' + +import React from 'react' +import { usePagination } from '@/lib/pagination' +import type { PaginatedResponse } from '@/lib/pagination' +import Pagination from '@/components/pagination' + +type Post = { + id: string + title: string + content: string + createdAt: string +} + +/** + * Fetch function for a cursor-based API + */ +async function fetchPosts(url: string, signal?: AbortSignal): Promise> { + const res = await fetch(url, { signal }) + const json = await res.json() + + return { + items: json.edges.map((edge: any) => edge.node), + totalCount: json.totalCount, + pageInfo: json.pageInfo, // Include cursor info for proper navigation + } +} + +export default function PostsPageExample() { + const { + items: posts, + paginationState, + isInitialLoading, + isRefreshing, + refreshSecondsRemaining, + } = usePagination({ + config: { + type: 'offset-based', // API expects ?first=X&after=Y or ?skip=X + itemsPerPage: 15, + }, + fetchFn: fetchPosts, + baseUrl: 'https://api.example.com/posts', + pollInterval: 10000, // Refresh every 10 seconds + }) + + return ( +
+
+

Posts

+ {isRefreshing && ( + + Refreshing... + + )} + {!isRefreshing && typeof refreshSecondsRemaining === 'number' && ( + + Next refresh in {refreshSecondsRemaining}s + + )} +
+ + {isInitialLoading ? ( +
Loading posts...
+ ) : ( + <> +
+ {posts.map((post) => ( +
+

{post.title}

+

{post.content}

+ +
+ ))} +
+ +
+ `/posts?page=${page}`} + /> +
+ + )} +
+ ) +} diff --git a/src/lib/pagination/example-page-based.tsx b/src/lib/pagination/example-page-based.tsx new file mode 100644 index 00000000..9682ae49 --- /dev/null +++ b/src/lib/pagination/example-page-based.tsx @@ -0,0 +1,75 @@ +/** + * Example: Using the pagination system with a page-based API + * + * This example shows how to use the pagination hook with a traditional + * page-based API that uses ?page=X&limit=Y parameters. + */ + +'use client' + +import React from 'react' +import { usePagination } from '@/lib/pagination' +import type { PaginatedResponse } from '@/lib/pagination' +import Pagination from '@/components/pagination' + +type User = { + id: number + name: string + email: string +} + +/** + * Fetch function for a page-based API + */ +async function fetchUsers(url: string, signal?: AbortSignal): Promise> { + const res = await fetch(url, { signal }) + const json = await res.json() + + return { + items: json.users, + totalCount: json.total, + } +} + +export default function UsersPageExample() { + const { + items: users, + paginationState, + isInitialLoading, + } = usePagination({ + config: { + type: 'page-based', // API expects ?page=X&limit=Y + itemsPerPage: 20, + }, + fetchFn: fetchUsers, + baseUrl: 'https://api.example.com/users', + // No pollInterval = no auto-refresh + }) + + if (isInitialLoading) { + return
Loading users...
+ } + + return ( +
+

Users

+ +
+ {users.map((user) => ( +
+

{user.name}

+

{user.email}

+
+ ))} +
+ +
+ `/users?page=${page}`} + /> +
+
+ ) +} diff --git a/src/lib/pagination/index.ts b/src/lib/pagination/index.ts new file mode 100644 index 00000000..2de795d9 --- /dev/null +++ b/src/lib/pagination/index.ts @@ -0,0 +1,7 @@ +/** + * Extensible pagination system supporting both page-based and offset-based pagination + */ + +export * from './types' +export * from './utils' +export * from './usePagination' diff --git a/src/lib/pagination/types.ts b/src/lib/pagination/types.ts new file mode 100644 index 00000000..2266e144 --- /dev/null +++ b/src/lib/pagination/types.ts @@ -0,0 +1,70 @@ +/** + * Pagination system types supporting both page-based and cursor/offset-based pagination + */ + +/** + * Page-based pagination params + */ +export type PageBasedParams = { + type: 'page-based' + page: number + limit: number +} + +/** + * Offset-based pagination params (cursor-based) + * Follows Relay-style cursor pagination with GraphQL conventions + */ +export type OffsetBasedParams = { + type: 'offset-based' + first?: number // Number of items to fetch forward from cursor + after?: string // Cursor to fetch after (for forward pagination) + last?: number // Number of items to fetch backward from cursor + before?: string // Cursor to fetch before (for backward pagination) + skip?: number // Number of items to skip (alternative to cursor) +} + +/** + * Union of all pagination param types + */ +export type PaginationParams = PageBasedParams | OffsetBasedParams + +/** + * Cursor/PageInfo returned by offset-based APIs + */ +export type PageInfo = { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string | null + endCursor?: string | null +} + +/** + * Generic paginated response structure + */ +export type PaginatedResponse = { + items: T[] + totalCount: number + pageInfo?: PageInfo +} + +/** + * Pagination state for UI components + */ +export type PaginationState = { + currentPage: number + itemsPerPage: number + totalItems: number + totalPages: number + hasNextPage: boolean + hasPreviousPage: boolean +} + +/** + * Configuration for pagination behavior + */ +export type PaginationConfig = { + type: 'page-based' | 'offset-based' + itemsPerPage: number + initialPage?: number +} diff --git a/src/lib/pagination/usePagination.ts b/src/lib/pagination/usePagination.ts new file mode 100644 index 00000000..41179ef9 --- /dev/null +++ b/src/lib/pagination/usePagination.ts @@ -0,0 +1,271 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import type { PaginatedResponse, PaginationConfig, PaginationState } from './types' +import { + buildPaginationQuery, + calculatePaginationState, + clampPage, + pageToOffsetParams, + pageToPageParams, + parsePageFromParams, +} from './utils' + +/** + * Options for pagination hook + */ +type UsePaginationOptions = { + /** Pagination configuration */ + config: PaginationConfig + /** Fetch function that takes a URL and returns paginated data */ + fetchFn: (url: string, signal?: AbortSignal) => Promise> + /** Base URL for API endpoint */ + baseUrl: string + /** Poll interval in milliseconds (optional) */ + pollInterval?: number + /** Minimum refresh indicator duration in ms */ + minRefreshDuration?: number +} + +/** + * Return type for pagination hook + */ +type UsePaginationReturn = { + /** Current page items */ + items: T[] + /** Pagination state */ + paginationState: PaginationState + /** Is initial loading */ + isInitialLoading: boolean + /** Is refreshing (background update) */ + isRefreshing: boolean + /** Seconds until next refresh */ + refreshSecondsRemaining: number | null + /** Navigate to specific page */ + goToPage: (page: number) => void + /** Navigate to next page */ + goToNextPage: () => void + /** Navigate to previous page */ + goToPreviousPage: () => void +} + +/** + * Extensible pagination hook supporting both page-based and offset-based pagination + * + * @example + * ```tsx + * const { items, paginationState, isInitialLoading } = usePagination({ + * config: { type: 'offset-based', itemsPerPage: 10 }, + * fetchFn: fetchJobs, + * baseUrl: 'https://api.example.com/jobs', + * pollInterval: 6000, + * }) + * ``` + */ +export function usePagination({ + config, + fetchFn, + baseUrl, + pollInterval, + minRefreshDuration = 500, +}: UsePaginationOptions): UsePaginationReturn { + const router = useRouter() + const searchParams = useSearchParams() + + const [items, setItems] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [pageInfo, setPageInfo] = useState['pageInfo']>() + const [isInitialLoading, setIsInitialLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [refreshSecondsRemaining, setRefreshSecondsRemaining] = useState(null) + + const refreshInFlightRef = useRef(false) + const nextRefreshAtRef = useRef(0) + const didInitialLoadRef = useRef(false) + + // Parse current page from URL + const currentPage = useMemo(() => { + const page = parsePageFromParams(searchParams, config.initialPage ?? 1) + return clampPage(page, Math.ceil(totalCount / config.itemsPerPage) || 1) + }, [searchParams, config.initialPage, totalCount, config.itemsPerPage]) + + // Build fetch URL based on pagination type + const buildFetchUrl = useCallback( + (page: number): string => { + const params = + config.type === 'page-based' + ? pageToPageParams(page, config.itemsPerPage) + : pageToOffsetParams(page, config.itemsPerPage) + + const queryString = buildPaginationQuery(params) + return `${baseUrl}?${queryString}` + }, + [baseUrl, config.type, config.itemsPerPage] + ) + + // Calculate pagination state + const paginationState = useMemo( + () => calculatePaginationState(currentPage, config.itemsPerPage, totalCount, pageInfo), + [currentPage, config.itemsPerPage, totalCount, pageInfo] + ) + + // Navigation functions + const goToPage = useCallback( + (page: number) => { + const clampedPage = clampPage(page, paginationState.totalPages) + const url = new URL(window.location.href) + url.searchParams.set('page', String(clampedPage)) + router.push(url.pathname + url.search) + }, + [router, paginationState.totalPages] + ) + + const goToNextPage = useCallback(() => { + if (paginationState.hasNextPage) { + goToPage(currentPage + 1) + } + }, [currentPage, paginationState.hasNextPage, goToPage]) + + const goToPreviousPage = useCallback(() => { + if (paginationState.hasPreviousPage) { + goToPage(currentPage - 1) + } + }, [currentPage, paginationState.hasPreviousPage, goToPage]) + + // Fetch data for current page + useEffect(() => { + const controller = new AbortController() + const isFirstEverLoad = !didInitialLoadRef.current + + if (isFirstEverLoad) { + setIsInitialLoading(true) + } + + const url = buildFetchUrl(currentPage) + + fetchFn(url, controller.signal) + .then((response) => { + setItems(response.items) + setTotalCount(response.totalCount) + setPageInfo(response.pageInfo) + }) + .catch((error) => { + // Only handle non-abort errors + if (error.name !== 'AbortError') { + setItems([]) + setTotalCount(0) + setPageInfo(undefined) + } + }) + .finally(() => { + didInitialLoadRef.current = true + setIsInitialLoading(false) + }) + + return () => controller.abort() + }, [currentPage, buildFetchUrl, fetchFn]) + + // Background polling (if enabled) + useEffect(() => { + if (!pollInterval || isInitialLoading) return + + let isUnmounted = false + const controller = new AbortController() + let clearRefreshingTimeoutId: number | null = null + + const tick = async () => { + if (refreshInFlightRef.current) return + + refreshInFlightRef.current = true + setIsRefreshing(true) + const startedAt = Date.now() + + try { + const url = buildFetchUrl(currentPage) + const response = await fetchFn(url, controller.signal) + + if (!isUnmounted) { + // Only update if data changed + const itemsChanged = JSON.stringify(response.items) !== JSON.stringify(items) + const countChanged = response.totalCount !== totalCount + + if (itemsChanged || countChanged) { + setItems(response.items) + setTotalCount(response.totalCount) + setPageInfo(response.pageInfo) + } + } + } catch (error) { + // Keep existing data on refresh failure + if (error instanceof Error && error.name !== 'AbortError') { + console.error('Background refresh failed:', error) + } + } finally { + refreshInFlightRef.current = false + + if (!isUnmounted) { + const elapsed = Date.now() - startedAt + const remaining = Math.max(0, minRefreshDuration - elapsed) + + if (clearRefreshingTimeoutId !== null) { + window.clearTimeout(clearRefreshingTimeoutId) + } + + clearRefreshingTimeoutId = window.setTimeout(() => { + if (!isUnmounted) setIsRefreshing(false) + }, remaining) + } + + nextRefreshAtRef.current = Date.now() + pollInterval + } + } + + // Set initial next refresh time + nextRefreshAtRef.current = Date.now() + pollInterval + + // Countdown timer + const countdownId = window.setInterval(() => { + const nextAt = nextRefreshAtRef.current + if (!nextAt) { + setRefreshSecondsRemaining(null) + return + } + const seconds = Math.max(0, Math.ceil((nextAt - Date.now()) / 1000)) + setRefreshSecondsRemaining(seconds) + }, 250) + + // Do initial refresh and start interval + void tick() + const intervalId = window.setInterval(tick, pollInterval) + + return () => { + isUnmounted = true + controller.abort() + window.clearInterval(intervalId) + window.clearInterval(countdownId) + + if (clearRefreshingTimeoutId !== null) { + window.clearTimeout(clearRefreshingTimeoutId) + } + } + }, [ + currentPage, + buildFetchUrl, + fetchFn, + isInitialLoading, + pollInterval, + minRefreshDuration, + items, + totalCount, + ]) + + return { + items, + paginationState, + isInitialLoading, + isRefreshing, + refreshSecondsRemaining, + goToPage, + goToNextPage, + goToPreviousPage, + } +} diff --git a/src/lib/pagination/utils.ts b/src/lib/pagination/utils.ts new file mode 100644 index 00000000..1f9d0e14 --- /dev/null +++ b/src/lib/pagination/utils.ts @@ -0,0 +1,89 @@ +import type { PaginationParams, PaginationState, PageInfo } from './types' + +/** + * Convert page number to offset-based params + */ +export function pageToOffsetParams(page: number, itemsPerPage: number): PaginationParams { + const skip = (page - 1) * itemsPerPage + return { + type: 'offset-based', + first: itemsPerPage, + skip, + } +} + +/** + * Convert page number to page-based params + */ +export function pageToPageParams(page: number, itemsPerPage: number): PaginationParams { + return { + type: 'page-based', + page, + limit: itemsPerPage, + } +} + +/** + * Build query string for pagination params + */ +export function buildPaginationQuery(params: PaginationParams): string { + const searchParams = new URLSearchParams() + + if (params.type === 'page-based') { + searchParams.set('page', String(params.page)) + searchParams.set('limit', String(params.limit)) + } else { + if (params.first !== undefined) searchParams.set('first', String(params.first)) + if (params.after !== undefined) searchParams.set('after', params.after) + if (params.last !== undefined) searchParams.set('last', String(params.last)) + if (params.before !== undefined) searchParams.set('before', params.before) + if (params.skip !== undefined) searchParams.set('skip', String(params.skip)) + } + + return searchParams.toString() +} + +/** + * Calculate pagination state from response data + */ +export function calculatePaginationState( + currentPage: number, + itemsPerPage: number, + totalItems: number, + pageInfo?: PageInfo +): PaginationState { + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)) + + // For offset-based pagination, use pageInfo if available + const hasNextPage = pageInfo?.hasNextPage ?? currentPage < totalPages + const hasPreviousPage = pageInfo?.hasPreviousPage ?? currentPage > 1 + + return { + currentPage, + itemsPerPage, + totalItems, + totalPages, + hasNextPage, + hasPreviousPage, + } +} + +/** + * Clamp page number to valid range + */ +export function clampPage(page: number, totalPages: number): number { + if (!Number.isFinite(page)) return 1 + if (totalPages <= 0) return 1 + return Math.min(Math.max(1, Math.trunc(page)), totalPages) +} + +/** + * Parse page number from URL search params + */ +export function parsePageFromParams(searchParams: URLSearchParams, defaultPage = 1): number { + const pageStr = searchParams.get('page') + if (!pageStr) return defaultPage + + const page = parseInt(pageStr, 10) + return Number.isFinite(page) && page > 0 ? page : defaultPage +} diff --git a/typings/animate.css.d.ts b/typings/animate.css.d.ts new file mode 100644 index 00000000..775f0248 --- /dev/null +++ b/typings/animate.css.d.ts @@ -0,0 +1 @@ +declare module 'animate.css'