view react_games/src/API_works/pagination.tsx @ 71:75de5903355c

Giagantic changes that update Dowa library to be more align with stb style array and hashmap. Updated Seobeo to be caching on server side instead of file level caching. Deleted bunch of things I don't really use.
author June Park <parkjune1995@gmail.com>
date Sun, 28 Dec 2025 20:34:22 -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,
}