Mercurial
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 } |