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,
+}