import { useCallback, useEffect, useRef, useState } from "react"; import type { CursorPage, CursorPageMeta } from "~/services/api"; export const ADMIN_PAGE_SIZE = 10; type Fetcher = (input: { limit: number; cursor: string | null; q: string; }) => Promise>; /** * Cursor-stack pagination for admin tables. * Supports prev/next and jumping to already-visited pages. * Changing ``q`` resets to page 1. */ export function useCursorPage(fetcher: Fetcher, q: string) { const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [meta, setMeta] = useState({ limit: ADMIN_PAGE_SIZE, page_count: 0, total_count: 0, has_more: false, next_cursor: null, }); // cursor used to *enter* page N. Page 1 is always null. const cursorByPageRef = useRef>({ 1: null }); const nextCursorByPageRef = useRef>({}); const requestIdRef = useRef(0); const totalPages = Math.max(1, Math.ceil(meta.total_count / ADMIN_PAGE_SIZE)); const loadPage = useCallback( async (targetPage: number, options?: { resetStack?: boolean }) => { const requestId = ++requestIdRef.current; setLoading(true); try { if (options?.resetStack) { cursorByPageRef.current = { 1: null }; nextCursorByPageRef.current = {}; } const cursor = cursorByPageRef.current[targetPage] ?? null; if (targetPage > 1 && cursor === null && targetPage !== 1) { // Unvisited deep page — refuse rather than invent offset. return; } const result = await fetcher({ limit: ADMIN_PAGE_SIZE, cursor: targetPage === 1 ? null : cursor, q, }); if (requestId !== requestIdRef.current) return; setItems(result.items); setMeta(result.meta); setPage(targetPage); nextCursorByPageRef.current[targetPage] = result.meta.next_cursor; if (result.meta.next_cursor) { cursorByPageRef.current[targetPage + 1] = result.meta.next_cursor; } } catch { // Caller is responsible for notifying; keep previous items. } finally { if (requestId === requestIdRef.current) { setLoading(false); } } }, [fetcher, q], ); useEffect(() => { void loadPage(1, { resetStack: true }); }, [loadPage]); const goNext = useCallback(() => { if (!meta.has_more) return; void loadPage(page + 1); }, [loadPage, meta.has_more, page]); const goPrev = useCallback(() => { if (page <= 1) return; void loadPage(page - 1); }, [loadPage, page]); const goToPage = useCallback( (targetPage: number) => { if (targetPage < 1 || targetPage > totalPages) return; if (targetPage === page) return; // Only allow visited pages (cursor known) or page 1. if (targetPage !== 1 && cursorByPageRef.current[targetPage] == null) { return; } void loadPage(targetPage); }, [loadPage, page, totalPages], ); const canGoToPage = useCallback( (targetPage: number) => { if (targetPage < 1 || targetPage > totalPages) return false; if (targetPage === 1 || targetPage === page) return true; return cursorByPageRef.current[targetPage] != null; }, [page, totalPages], ); const reload = useCallback(() => { void loadPage(page); }, [loadPage, page]); return { items, setItems, page, loading, meta, totalPages, goNext, goPrev, goToPage, canGoToPage, reload, refreshFromStart: () => loadPage(1, { resetStack: true }), }; }