Mercurial
diff react_games/src/API_works/pagination.tsx @ 37:fb9bcd3145cb
[ReactGames] Few games I made using react just to practice few things.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 01 Dec 2025 20:22:47 -0800 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/react_games/src/API_works/pagination.tsx Mon Dec 01 20:22:47 2025 -0800 @@ -0,0 +1,236 @@ +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, +}