129 lines
3.7 KiB
TypeScript
129 lines
3.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import type { CursorPage, CursorPageMeta } from "~/services/api";
|
|
|
|
export const ADMIN_PAGE_SIZE = 10;
|
|
|
|
type Fetcher<T> = (input: {
|
|
limit: number;
|
|
cursor: string | null;
|
|
q: string;
|
|
}) => Promise<CursorPage<T>>;
|
|
|
|
/**
|
|
* Cursor-stack pagination for admin tables.
|
|
* Supports prev/next and jumping to already-visited pages.
|
|
* Changing ``q`` resets to page 1.
|
|
*/
|
|
export function useCursorPage<T>(fetcher: Fetcher<T>, q: string) {
|
|
const [items, setItems] = useState<T[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [loading, setLoading] = useState(true);
|
|
const [meta, setMeta] = useState<CursorPageMeta>({
|
|
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<Record<number, string | null>>({ 1: null });
|
|
const nextCursorByPageRef = useRef<Record<number, string | null>>({});
|
|
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 }),
|
|
};
|
|
}
|