Mercurial
view react_games/src/API_works/pagination.tsx @ 174:1ba8c1df082c hg-web
Remove playground stuff.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 19 Jan 2026 18:59:23 -0800 |
| parents | fb9bcd3145cb |
| children |
line wrap: on
line source
import { CSSProperties, startTransition, useCallback, useEffect, useReducer, useRef } from "react"; import ReactDOM from "react-dom/client"; const API_URL = "https://swapi.py4e.com/api/people/"; /* ---------- Types ---------- */ interface PeopleJSON { name: string; birth_year: string; } interface APIResponse { next: string | null; previous: string | null; results: PeopleJSON[]; } interface PageData extends APIResponse { isLoading: boolean; curr: string; } type Dir = "next" | "prev"; type Mode = "none" | "page" | "offset" | "cursor" | "link"; type UrlParams = | { mode: "none" } | { mode: "page"; page: number; limit?: number } | { mode: "offset"; offset: number; limit?: number } | { mode: "cursor"; cursor: string; limit?: number; dir?: Dir } | { mode: "link"; curr: string; next?: string; prev?: string }; /* ---------- URL <-> Args ---------- */ function parseArgsFromSearch(): UrlParams { const sp = new URLSearchParams(location.search); const mode = (sp.get("mode") as Mode) || "none"; switch (mode) { case "none": return { mode }; case "page": return { mode, page: Number(sp.get("page") || 1), limit: sp.get("limit") ? Number(sp.get("limit")) : undefined }; case "offset": return { mode, offset: Number(sp.get("offset") || 0), limit: sp.get("limit") ? Number(sp.get("limit")) : undefined }; case "cursor": return { mode, cursor: sp.get("cursor") || "", limit: sp.get("limit") ? Number(sp.get("limit")) : undefined, dir: (sp.get("dir") as Dir) || undefined }; case "link": return { mode, curr: sp.get("curr") || API_URL, next: sp.get("next") || undefined, prev: sp.get("prev") || undefined }; } } function uiUrl(basePath: string, args: UrlParams): string { const sp = new URLSearchParams(); switch (args.mode) { case "none": return basePath; case "page": sp.set("mode", "page"); sp.set("page", String(args.page)); if (args.limit != null) sp.set("limit", String(args.limit)); break; case "offset": sp.set("mode", "offset"); sp.set("offset", String(args.offset)); if (args.limit != null) sp.set("limit", String(args.limit)); break; case "cursor": sp.set("mode", "cursor"); sp.set("cursor", args.cursor); if (args.limit != null) sp.set("limit", String(args.limit)); if (args.dir) sp.set("dir", args.dir); break; case "link": sp.set("mode", "link"); sp.set("curr", args.curr); if (args.next) sp.set("next", args.next); if (args.prev) sp.set("prev", args.prev); break; } const q = sp.toString(); return q ? `${basePath}?${q}` : basePath; } /** For this API, upstream URL comes from args. */ function toUpstreamUrl(args: UrlParams): string { switch (args.mode) { case "none": return API_URL; case "link": return args.curr || API_URL; case "page": return `${API_URL}?page=${args.page}${args.limit != null ? `&limit=${args.limit}` : ""}`; case "offset": return `${API_URL}?offset=${args.offset}${args.limit != null ? `&limit=${args.limit}` : ""}`; case "cursor": return `${API_URL}?cursor=${args.cursor}${args.limit != null ? `&limit=${args.limit}` : ""}${args.dir ? `&dir=${args.dir}` : ""}`; } } function argsFromResponse(currUrl: string, resp: APIResponse): UrlParams { // Prefer LINK mode when API provides next/previous URLs return { mode: "link", curr: currUrl, next: resp.next ?? undefined, prev: resp.previous ?? undefined }; } /* ---------- In-flight dedupe (not a cache) ---------- */ const inflight = new Map<string, Promise<PageData>>(); async function fetchPage(url: string, signal?: AbortSignal): Promise<PageData> { if (inflight.has(url)) return inflight.get(url)!; const p = (async () => { const res = await fetch(url, { signal }); console.log(res.headers.get("content-type")); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: APIResponse = await res.json(); return { ...json, isLoading: false, curr: url } as PageData; })().finally(() => inflight.delete(url)); inflight.set(url, p); return p; } /* ---------- Derive next/prev target args (mode-agnostic) ---------- */ function deriveNav(current: UrlParams, pd: PageData) { let nextArgs: UrlParams | undefined; let prevArgs: UrlParams | undefined; switch (current.mode) { case "none": case "link": { nextArgs = pd.next ? { mode: "link", curr: pd.next, next: pd.next, prev: pd.prev || undefined } : undefined; prevArgs = pd.previous ? { mode: "link", curr: pd.previous, prev: pd.previous, next: pd.next || undefined } : undefined; break; } case "page": { const limit = current.limit; nextArgs = { mode: "page", page: current.page + 1, limit }; prevArgs = current.page > 1 ? { mode: "page", page: current.page - 1, limit } : undefined; break; } case "offset": { const limit = current.limit ?? 10; nextArgs = { mode: "offset", offset: current.offset + limit, limit }; prevArgs = current.offset - limit >= 0 ? { mode: "offset", offset: current.offset - limit, limit } : undefined; break; } case "cursor": { // needs server-provided cursors; we’d stash them in pd if available // For this API we don't have cursors, so fall back to link behavior above. break; } } return { nextArgs, prevArgs }; } /* ---------- State ---------- */ enum ActionType { HYDRATE, LOADING, ERROR } type Action = | { type: ActionType.HYDRATE; data: PageData } | { type: ActionType.LOADING } | { type: ActionType.ERROR; error: string }; const INITIAL: PageData = { next: null, previous: null, results: [], isLoading: false, curr: API_URL }; function reducer(state: PageData, action: Action): PageData { switch (action.type) { case ActionType.HYDRATE: return action.data; case ActionType.LOADING: return { ...state, isLoading: true }; case ActionType.ERROR: return { ...state, isLoading: false }; default: return state; } } /* ---------- Styles & Row ---------- */ const styles: { row: CSSProperties; toolbar: CSSProperties } = { row: { display: "grid", gridTemplateColumns: "repeat(2, 1fr)" }, toolbar: { display: "flex", gap: 8, marginBottom: 8 }, }; const PersonRow = ({ p }: { p: PeopleJSON }) => ( <div style={styles.row}> <p>{p.name}</p> <p>{p.birth_year}</p> </div> ); /* ---------- Component ---------- */ const People = () => { const [page, dispatch] = useReducer(reducer, INITIAL); const ctrlRef = useRef<AbortController | null>(null); const load = useCallback((args: UrlParams, pushUrl = true) => { dispatch({ type: ActionType.LOADING }); startTransition(async () => { ctrlRef.current?.abort(); const ctrl = new AbortController(); ctrlRef.current = ctrl; try { const upstream = toUpstreamUrl(args); const data = await fetchPage(upstream, ctrl.signal); // Auto-detect LINK and sync UI URL *from the response* (URL is the source of truth) const linkArgs = argsFromResponse(upstream, data); if (pushUrl) history.pushState(null, "", uiUrl(location.pathname, linkArgs)); dispatch({ type: ActionType.HYDRATE, data }); } catch (e: any) { if (e?.name !== "AbortError") dispatch({ type: ActionType.ERROR, error: String(e?.message ?? e) }); } }); }, []); // Initial load + back/forward useEffect(() => { const applyFromLocation = () => load(parseArgsFromSearch(), false); window.addEventListener("popstate", applyFromLocation); applyFromLocation(); return () => window.removeEventListener("popstate", applyFromLocation); }, [load]); // Click handlers: derive target solely from current URL + current page data const navigate = useCallback((dir: Dir) => { const current = parseArgsFromSearch(); const { nextArgs, prevArgs } = deriveNav(current, page); const target = dir === "next" ? nextArgs : prevArgs; if (!target) return; // push new URL first so URL remains the source of truth history.pushState(null, "", uiUrl(location.pathname, target)); load(target, false); // do not push again inside load }, [page, load]); const { nextArgs, prevArgs } = deriveNav(parseArgsFromSearch(), page); return ( <div> <div style={styles.toolbar}> <button disabled={!prevArgs} onClick={() => navigate("prev")}>prev</button> <button disabled={!nextArgs} onClick={() => navigate("next")}>next</button> </div> {page.isLoading && <p>Loading…</p>} {!page.isLoading && ( <> <div style={{ ...styles.row, borderBottom: "1px solid #000" }}> <strong>Name</strong> <strong>Birth Year</strong> </div> {page.results.map((p) => <PersonRow key={p.name} p={p} />)} </> )} </div> ); }; export { People, }