comparison 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
comparison
equal deleted inserted replaced
36:84672efec192 37:fb9bcd3145cb
1 import { CSSProperties, startTransition, useCallback, useEffect, useReducer, useRef } from "react";
2 import ReactDOM from "react-dom/client";
3
4 const API_URL = "https://swapi.py4e.com/api/people/";
5
6 /* ---------- Types ---------- */
7 interface PeopleJSON { name: string; birth_year: string; }
8 interface APIResponse { next: string | null; previous: string | null; results: PeopleJSON[]; }
9 interface PageData extends APIResponse { isLoading: boolean; curr: string; }
10
11 type Dir = "next" | "prev";
12 type Mode = "none" | "page" | "offset" | "cursor" | "link";
13 type UrlParams =
14 | { mode: "none" }
15 | { mode: "page"; page: number; limit?: number }
16 | { mode: "offset"; offset: number; limit?: number }
17 | { mode: "cursor"; cursor: string; limit?: number; dir?: Dir }
18 | { mode: "link"; curr: string; next?: string; prev?: string };
19
20 /* ---------- URL <-> Args ---------- */
21 function parseArgsFromSearch(): UrlParams {
22 const sp = new URLSearchParams(location.search);
23 const mode = (sp.get("mode") as Mode) || "none";
24 switch (mode) {
25 case "none": return { mode };
26 case "page": return { mode, page: Number(sp.get("page") || 1), limit: sp.get("limit") ? Number(sp.get("limit")) : undefined };
27 case "offset": return { mode, offset: Number(sp.get("offset") || 0), limit: sp.get("limit") ? Number(sp.get("limit")) : undefined };
28 case "cursor": return { mode, cursor: sp.get("cursor") || "", limit: sp.get("limit") ? Number(sp.get("limit")) : undefined, dir: (sp.get("dir") as Dir) || undefined };
29 case "link": return { mode, curr: sp.get("curr") || API_URL, next: sp.get("next") || undefined, prev: sp.get("prev") || undefined };
30 }
31 }
32
33 function uiUrl(basePath: string, args: UrlParams): string {
34 const sp = new URLSearchParams();
35 switch (args.mode) {
36 case "none":
37 return basePath;
38 case "page":
39 sp.set("mode", "page");
40 sp.set("page", String(args.page));
41 if (args.limit != null) sp.set("limit", String(args.limit));
42 break;
43 case "offset":
44 sp.set("mode", "offset");
45 sp.set("offset", String(args.offset));
46 if (args.limit != null) sp.set("limit", String(args.limit));
47 break;
48 case "cursor":
49 sp.set("mode", "cursor");
50 sp.set("cursor", args.cursor);
51 if (args.limit != null) sp.set("limit", String(args.limit));
52 if (args.dir) sp.set("dir", args.dir);
53 break;
54 case "link":
55 sp.set("mode", "link");
56 sp.set("curr", args.curr);
57 if (args.next) sp.set("next", args.next);
58 if (args.prev) sp.set("prev", args.prev);
59 break;
60 }
61 const q = sp.toString();
62 return q ? `${basePath}?${q}` : basePath;
63 }
64
65 /** For this API, upstream URL comes from args. */
66 function toUpstreamUrl(args: UrlParams): string {
67 switch (args.mode) {
68 case "none": return API_URL;
69 case "link": return args.curr || API_URL;
70 case "page": return `${API_URL}?page=${args.page}${args.limit != null ? `&limit=${args.limit}` : ""}`;
71 case "offset": return `${API_URL}?offset=${args.offset}${args.limit != null ? `&limit=${args.limit}` : ""}`;
72 case "cursor": return `${API_URL}?cursor=${args.cursor}${args.limit != null ? `&limit=${args.limit}` : ""}${args.dir ? `&dir=${args.dir}` : ""}`;
73 }
74 }
75
76 function argsFromResponse(currUrl: string, resp: APIResponse): UrlParams {
77 // Prefer LINK mode when API provides next/previous URLs
78 return { mode: "link", curr: currUrl, next: resp.next ?? undefined, prev: resp.previous ?? undefined };
79 }
80
81 /* ---------- In-flight dedupe (not a cache) ---------- */
82 const inflight = new Map<string, Promise<PageData>>();
83
84 async function fetchPage(url: string, signal?: AbortSignal): Promise<PageData> {
85 if (inflight.has(url)) return inflight.get(url)!;
86
87 const p = (async () => {
88 const res = await fetch(url, { signal });
89 console.log(res.headers.get("content-type"));
90 if (!res.ok) throw new Error(`HTTP ${res.status}`);
91 const json: APIResponse = await res.json();
92 return { ...json, isLoading: false, curr: url } as PageData;
93 })().finally(() => inflight.delete(url));
94
95 inflight.set(url, p);
96 return p;
97 }
98
99 /* ---------- Derive next/prev target args (mode-agnostic) ---------- */
100 function deriveNav(current: UrlParams, pd: PageData) {
101 let nextArgs: UrlParams | undefined;
102 let prevArgs: UrlParams | undefined;
103
104 switch (current.mode) {
105 case "none":
106 case "link": {
107 nextArgs = pd.next ? { mode: "link", curr: pd.next, next: pd.next, prev: pd.prev || undefined } : undefined;
108 prevArgs = pd.previous ? { mode: "link", curr: pd.previous, prev: pd.previous, next: pd.next || undefined } : undefined;
109 break;
110 }
111 case "page": {
112 const limit = current.limit;
113 nextArgs = { mode: "page", page: current.page + 1, limit };
114 prevArgs = current.page > 1 ? { mode: "page", page: current.page - 1, limit } : undefined;
115 break;
116 }
117 case "offset": {
118 const limit = current.limit ?? 10;
119 nextArgs = { mode: "offset", offset: current.offset + limit, limit };
120 prevArgs = current.offset - limit >= 0 ? { mode: "offset", offset: current.offset - limit, limit } : undefined;
121 break;
122 }
123 case "cursor": {
124 // needs server-provided cursors; we’d stash them in pd if available
125 // For this API we don't have cursors, so fall back to link behavior above.
126 break;
127 }
128 }
129 return { nextArgs, prevArgs };
130 }
131
132 /* ---------- State ---------- */
133 enum ActionType { HYDRATE, LOADING, ERROR }
134 type Action =
135 | { type: ActionType.HYDRATE; data: PageData }
136 | { type: ActionType.LOADING }
137 | { type: ActionType.ERROR; error: string };
138
139 const INITIAL: PageData = { next: null, previous: null, results: [], isLoading: false, curr: API_URL };
140
141 function reducer(state: PageData, action: Action): PageData {
142 switch (action.type) {
143 case ActionType.HYDRATE: return action.data;
144 case ActionType.LOADING: return { ...state, isLoading: true };
145 case ActionType.ERROR: return { ...state, isLoading: false };
146 default: return state;
147 }
148 }
149
150 /* ---------- Styles & Row ---------- */
151 const styles: { row: CSSProperties; toolbar: CSSProperties } = {
152 row: { display: "grid", gridTemplateColumns: "repeat(2, 1fr)" },
153 toolbar: { display: "flex", gap: 8, marginBottom: 8 },
154 };
155
156 const PersonRow = ({ p }: { p: PeopleJSON }) => (
157 <div style={styles.row}>
158 <p>{p.name}</p>
159 <p>{p.birth_year}</p>
160 </div>
161 );
162
163 /* ---------- Component ---------- */
164 const People = () => {
165 const [page, dispatch] = useReducer(reducer, INITIAL);
166 const ctrlRef = useRef<AbortController | null>(null);
167
168 const load = useCallback((args: UrlParams, pushUrl = true) => {
169 dispatch({ type: ActionType.LOADING });
170
171 startTransition(async () => {
172 ctrlRef.current?.abort();
173 const ctrl = new AbortController();
174 ctrlRef.current = ctrl;
175
176 try {
177 const upstream = toUpstreamUrl(args);
178 const data = await fetchPage(upstream, ctrl.signal);
179
180 // Auto-detect LINK and sync UI URL *from the response* (URL is the source of truth)
181 const linkArgs = argsFromResponse(upstream, data);
182 if (pushUrl) history.pushState(null, "", uiUrl(location.pathname, linkArgs));
183
184 dispatch({ type: ActionType.HYDRATE, data });
185 } catch (e: any) {
186 if (e?.name !== "AbortError") dispatch({ type: ActionType.ERROR, error: String(e?.message ?? e) });
187 }
188 });
189 }, []);
190
191 // Initial load + back/forward
192 useEffect(() => {
193 const applyFromLocation = () => load(parseArgsFromSearch(), false);
194 window.addEventListener("popstate", applyFromLocation);
195 applyFromLocation();
196 return () => window.removeEventListener("popstate", applyFromLocation);
197 }, [load]);
198
199 // Click handlers: derive target solely from current URL + current page data
200 const navigate = useCallback((dir: Dir) => {
201 const current = parseArgsFromSearch();
202 const { nextArgs, prevArgs } = deriveNav(current, page);
203 const target = dir === "next" ? nextArgs : prevArgs;
204 if (!target) return;
205 // push new URL first so URL remains the source of truth
206 history.pushState(null, "", uiUrl(location.pathname, target));
207 load(target, false); // do not push again inside load
208 }, [page, load]);
209
210 const { nextArgs, prevArgs } = deriveNav(parseArgsFromSearch(), page);
211
212 return (
213 <div>
214 <div style={styles.toolbar}>
215 <button disabled={!prevArgs} onClick={() => navigate("prev")}>prev</button>
216 <button disabled={!nextArgs} onClick={() => navigate("next")}>next</button>
217 </div>
218
219 {page.isLoading && <p>Loading…</p>}
220
221 {!page.isLoading && (
222 <>
223 <div style={{ ...styles.row, borderBottom: "1px solid #000" }}>
224 <strong>Name</strong>
225 <strong>Birth Year</strong>
226 </div>
227 {page.results.map((p) => <PersonRow key={p.name} p={p} />)}
228 </>
229 )}
230 </div>
231 );
232 };
233
234 export {
235 People,
236 }