view react_games/src/API_works/pagination.tsx @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
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,
}