From ca1a2bf1cad04c62e9cf88eeccc604d9b901865a Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 00:36:08 +0200 Subject: [PATCH 01/10] Ditch old css. Use Material UI. Add Modal. Make it work on mobile. --- src/App.tsx | 147 ++++++++++++++++++++++++++++++++++++++++++++------ src/index.css | 41 -------------- src/main.tsx | 2 +- src/style.css | 3 ++ 4 files changed, 134 insertions(+), 59 deletions(-) delete mode 100644 src/index.css create mode 100644 src/style.css diff --git a/src/App.tsx b/src/App.tsx index 2722b3d..5904588 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,14 @@ import { z } from "zod" import { useReducer, useRef, useEffect, createContext, useContext } from "react" +import { + CssBaseline, + Box, + Button, + Typography, + Skeleton, + Dialog, + DialogContent, +} from "@mui/material" // TODO: Use Material UI later. // TODO: Improve error-handling. Introduce some proper server response. @@ -26,8 +35,8 @@ type Dimension = { height: number } const Dimension = { - medium: { width: 200, height: 200 } as Dimension, - big: { width: 300, height: 500 } as Dimension, + medium: { width: 180, height: 180 } as Dimension, + big: { width: 720, height: 720 } as Dimension, } // === Page === @@ -62,6 +71,9 @@ const Page = { key(page: Page): PageKey { return `${page.page}:${page.limit}` }, + isFirst(page: Page): boolean { + return page.page === FIRST_PAGE + }, } // === api === @@ -230,7 +242,7 @@ function useDispatch() { function RemoteImages({ images }: { images: Remote }) { switch (images.tag) { case "loading": - return
Loading...
+ return case "error": return
Error: TODO
case "loaded": @@ -238,21 +250,82 @@ function RemoteImages({ images }: { images: Remote }) { } } +function imageGridStyle(dimension: Dimension) { + return { + display: "grid", + gridTemplateColumns: { + xs: `repeat(2, ${dimension.width}px)`, + md: `repeat(5, ${dimension.width}px)`, + }, + gridAutoRows: `${dimension.height}px`, + gap: 2, + } +} + +// 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) +function ImagesSkeleton() { + const images = Array.from({ length: 10 }) + return ( + + {images.map((_, index) => ( + + ))} + + ) +} + function Images({ images }: { images: ImageId[] }) { const dispatch = useDispatch() - // TODO: Is there some basic `Col/Row` component? return ( -
+ {images.map((imageId) => ( - dispatch({ tag: "imageClicked", imageId })} + sx={{ + width: Dimension.medium.width, + height: Dimension.medium.height, + objectFit: "cover", + cursor: "pointer", + }} + key={imageId} alt="" /> ))} -
+ + ) +} + +function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { + const dispatch = useDispatch() + return ( + dispatch({ tag: "modalCloseButtonClicked" })} + maxWidth={false} + > + + {selectedImage !== undefined && ( + + )} + + ) } @@ -260,13 +333,53 @@ export default function App() { const [state, dispatch] = useApp() return ( - -
-

Picturarium

- - - -
-
+ <> + + + + Picturarium + + + + + + + {state.page.page} + + + + + + + + ) } diff --git a/src/index.css b/src/index.css deleted file mode 100644 index bd1da64..0000000 --- a/src/index.css +++ /dev/null @@ -1,41 +0,0 @@ -/* === Reset === */ -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - line-height: 1.45; -} - -body { - margin: 0; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - margin: 0; -} - -p { - margin: 0; -} - -ul { - list-style: none; - padding: 0; - margin: 0; -} - -button { - border: none; - padding: 0; - cursor: pointer; -} - -/* === main === */ diff --git a/src/main.tsx b/src/main.tsx index 57d77e4..5a7820c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" import App from "./App" -import "./index.css" +import "./style.css" const rootElement = document.getElementById("root") diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..5467feb --- /dev/null +++ b/src/style.css @@ -0,0 +1,3 @@ +:root { + --base-hue: 260; +} From 9138a187a959ac6825b536e603fb3e9018d7b386 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 00:51:24 +0200 Subject: [PATCH 02/10] Cleanup. Fix simple linter error. --- eslint.config.js | 1 + src/App.tsx | 67 ++++++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index e50a55f..bb094c8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,7 @@ export default tseslint.config( "error", { prefer: "type-imports", fixStyle: "inline-type-imports" }, ], + "@typescript-eslint/consistent-type-definitions": "off", "@typescript-eslint/no-unused-vars": [ "error", { diff --git a/src/App.tsx b/src/App.tsx index 5904588..d18676a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,24 +10,12 @@ import { DialogContent, } from "@mui/material" -// TODO: Use Material UI later. // TODO: Improve error-handling. Introduce some proper server response. -// For better type-error messages when you have inexhaustive switch cases. -function assertNever(value: never): never { - throw new Error(`Unexpected value: ${value}`) -} - // === Image === type ImageId = string type ImageRef = string -type Image = { - id: ImageId - dimension: Dimension - source: ImageRef -} - // === Dimension === type Dimension = { @@ -35,8 +23,11 @@ type Dimension = { height: number } const Dimension = { - medium: { width: 180, height: 180 } as Dimension, - big: { width: 720, height: 720 } as Dimension, + medium: { width: 180, height: 180 }, + big: { width: 720, height: 720 }, +} satisfies { + medium: Dimension + big: Dimension } // === Page === @@ -69,7 +60,7 @@ const Page = { }, // for hashing in maps to avoid identity problems key(page: Page): PageKey { - return `${page.page}:${page.limit}` + return `${String(page.page)}:${String(page.limit)}` }, isFirst(page: Page): boolean { return page.page === FIRST_PAGE @@ -88,9 +79,11 @@ const picsumApiImageSchema = z.object({ type PicsumApiImage = z.infer async function getPicsumImages({ page, limit }: Page): Promise { - const response = await fetch(`https://picsum.photos/v2/list?limit=${limit}&page=${page}`) + const response = await fetch( + `https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`, + ) if (!response.ok) { - throw new Error(`Failed to fetch images: ${response.status}`) + throw new Error(`Failed to fetch images: ${String(response.status)}`) } const json: unknown = await response.json() @@ -99,16 +92,6 @@ async function getPicsumImages({ page, limit }: Page): Promise return data } -// TODO: We don't really need this. -async function getImages(page: Page): Promise { - const data = await getPicsumImages(page) - return data.map(({ id, download_url, width, height }) => ({ - id: id as ImageId, - dimension: { width, height }, - source: download_url as ImageRef, - })) -} - async function getImageIds(page: Page): Promise { const data = await getPicsumImages(page) return data.map(({ id }) => id) @@ -116,7 +99,7 @@ async function getImageIds(page: Page): Promise { // Use this for `` function getImageSource(id: ImageId, dimension: Dimension): ImageRef { - return `https://picsum.photos/id/${id}/${dimension.width}/${dimension.height}` + return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}` } // === Generic Remote Data === @@ -218,7 +201,7 @@ function useApp(): [State, Dispatch] { return { ...state, selectedImage: undefined } } default: - return assertNever(msg) + return msg satisfies never } } @@ -254,10 +237,10 @@ function imageGridStyle(dimension: Dimension) { return { display: "grid", gridTemplateColumns: { - xs: `repeat(2, ${dimension.width}px)`, - md: `repeat(5, ${dimension.width}px)`, + xs: `repeat(2, ${String(dimension.width)}px)`, + md: `repeat(5, ${String(dimension.width)}px)`, }, - gridAutoRows: `${dimension.height}px`, + gridAutoRows: `${String(dimension.height)}px`, gap: 2, } } @@ -287,7 +270,9 @@ function Images({ images }: { images: ImageId[] }) { dispatch({ tag: "imageClicked", imageId })} + onClick={() => { + dispatch({ tag: "imageClicked", imageId }) + }} sx={{ width: Dimension.medium.width, height: Dimension.medium.height, @@ -307,7 +292,9 @@ function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { return ( dispatch({ tag: "modalCloseButtonClicked" })} + onClose={() => { + dispatch({ tag: "modalCloseButtonClicked" }) + }} maxWidth={false} > @@ -359,7 +346,9 @@ export default function App() { }} > + From 65fd45fc16dcb10f6066b0a71b1e3a33d2a1ae48 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 10:43:18 +0200 Subject: [PATCH 03/10] Introduce result type and generic safe fetch functions. Do proper error handling/display. --- src/App.tsx | 324 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 245 insertions(+), 79 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d18676a..a4a3aac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { z } from "zod" -import { useReducer, useRef, useEffect, createContext, useContext } from "react" +import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" import { CssBaseline, Box, @@ -12,12 +12,51 @@ import { // TODO: Improve error-handling. Introduce some proper server response. +type Result = { tag: "ok"; value: A } | { tag: "error"; error: E } + +const Result = { + ok(value: A): Result { + return { tag: "ok", value } + }, + err(error: E): Result { + return { tag: "error", error } + }, + map(result: Result, f: (x: A) => B): Result { + switch (result.tag) { + case "ok": + return { tag: "ok", value: f(result.value) } + case "error": + return { tag: "error", error: result.error } + } + }, +} + +type RequestError = + | { tag: "networkError"; error: unknown } + | { tag: "httpError"; status: number; statusText: string } + | { tag: "jsonParseError"; error: unknown } + | { tag: "invalidResponse"; error: z.ZodError } + +const RequestError = { + toString(error: RequestError): string { + switch (error.tag) { + case "networkError": + return "Network error. Check your connection and try again." + case "httpError": + return `Server returned ${String(error.status)} ${error.statusText}.` + case "jsonParseError": + return "The server returned an invalid response." + case "invalidResponse": + return "The image data had an unexpected format." + } + }, +} + // === Image === type ImageId = string type ImageRef = string // === Dimension === - type Dimension = { width: number height: number @@ -67,6 +106,69 @@ const Page = { }, } +// === Generic safe fetch functions === +async function fetchJsonSafe(url: string): Promise> { + let response: Response + try { + response = await fetch(url) + } catch (error: unknown) { + return Result.err({ tag: "networkError", error }) + } + + if (!response.ok) { + return Result.err({ + tag: "httpError", + status: response.status, + statusText: response.statusText, + }) + } else { + try { + const json: unknown = await response.json() + return Result.ok(json) + } catch (error: unknown) { + return Result.err({ tag: "jsonParseError", error }) + } + } +} + +// Assumes `f` is some sort of a zod parsing function that may throw `z.ZodError` +async function fetchJsonSafeWith( + url: string, + f: (json: unknown) => A, +): Promise> { + const result = await fetchJsonSafe(url) + switch (result.tag) { + case "error": + return Result.err(result.error) + case "ok": + // TODO + try { + return Result.ok(f(result.value)) + } catch (error: unknown) { + if (error instanceof z.ZodError) { + return Result.err({ tag: "invalidResponse", error }) + } else { + throw error + } + } + } +} + +// === Generic Remote Data === +type Remote = { tag: "loading" } | Result + +const Remote = { + loading(): Remote { + return { tag: "loading" } + }, + error(error: E): Remote { + return { tag: "error", error } + }, + loaded(value: A): Remote { + return { tag: "ok", value } + }, +} + // === api === const picsumApiImageSchema = z.object({ id: z.string(), @@ -78,23 +180,19 @@ const picsumApiImageSchema = z.object({ }) type PicsumApiImage = z.infer -async function getPicsumImages({ page, limit }: Page): Promise { - const response = await fetch( +async function getPicsumImages({ + page, + limit, +}: Page): Promise> { + return fetchJsonSafeWith( `https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`, + (json) => z.array(picsumApiImageSchema).parse(json), ) - if (!response.ok) { - throw new Error(`Failed to fetch images: ${String(response.status)}`) - } - - const json: unknown = await response.json() - const data = z.array(picsumApiImageSchema).parse(json) - - return data } -async function getImageIds(page: Page): Promise { - const data = await getPicsumImages(page) - return data.map(({ id }) => id) +async function getImageIds(page: Page): Promise> { + const result = await getPicsumImages(page) + return Result.map(result, (data) => data.map(({ id }) => id)) } // Use this for `` @@ -102,29 +200,15 @@ function getImageSource(id: ImageId, dimension: Dimension): ImageRef { return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}` } -// === Generic Remote Data === -type Remote = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A } - -const Remote = { - loading(): Remote { - return { tag: "loading" } - }, - error(error: E): Remote { - return { tag: "error", error } - }, - loaded(value: A): Remote { - return { tag: "loaded", value } - }, -} - // === App === // TODO: introduce proper error type -type AppError = string +type AppError = RequestError type State = { imageIds: Remote page: Page selectedImage: ImageId | undefined + refreshSignal: boolean // This is here only because of the refresh button. It is an artificial way to trigger a reload without the current page changing. The value is meaningless. It just has to change. } const State = { @@ -133,12 +217,14 @@ const State = { imageIds: Remote.loading(), page: Page.init(), selectedImage: undefined, + refreshSignal: false, } }, } type Msg = - | { tag: "imagesReceived"; imageIds: ImageId[]; forPage: Page } + | { tag: "imagesReceived"; result: Result; forPage: Page } + | { tag: "retryButtonClicked" } | { tag: "previousButtonClicked" } | { tag: "nextButtonClicked" } | { tag: "imageClicked"; imageId: ImageId } @@ -151,36 +237,42 @@ function useApp(): [State, Dispatch] { // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) const cacheRef = useRef>(new Map()) - async function getImageIdsCached(page: Page): Promise { + async function getImageIdsCached(page: Page): Promise> { const pageKey = Page.key(page) const maybeImages = cacheRef.current.get(pageKey) if (maybeImages === undefined) { - console.log("CACHE-MISS") - const images = await getImageIds(page) - cacheRef.current.set(pageKey, images) - return images + // console.log("CACHE-MISS") + const result = await getImageIds(page) + if (result.tag === "ok") { + cacheRef.current.set(pageKey, result.value) + } + return result } else { - console.log("CACHE-HIT") - return maybeImages + // console.log("CACHE-HIT") + return Result.ok(maybeImages) } } // === initialization & reloading === useEffect(() => { // TODO: error-handling - getImageIdsCached(state.page).then((imageIds) => { - dispatch({ tag: "imagesReceived", forPage: state.page, imageIds }) + void getImageIdsCached(state.page).then((result) => { + dispatch({ tag: "imagesReceived", forPage: state.page, result }) }) - }, [state.page]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm + }, [state.page, state.refreshSignal]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm function update(state: State, msg: Msg): State { switch (msg.tag) { case "imagesReceived": if (Page.eq(state.page, msg.forPage)) { - return { ...state, imageIds: Remote.loaded(msg.imageIds) } + return { ...state, imageIds: msg.result } } else { return state } + case "retryButtonClicked": + console.log("TODO") + // TODO: This actually needs to trigger the reload somehow /facepalm. But we can refresh only on page change. + return { ...state, imageIds: Remote.loading(), refreshSignal: !state.refreshSignal } case "previousButtonClicked": { const newPage = Page.previous(state.page) if (Page.eq(newPage, state.page)) { @@ -193,11 +285,9 @@ function useApp(): [State, Dispatch] { return { ...state, page: Page.next(state.page), imageIds: Remote.loading() } } case "imageClicked": { - console.log(msg.imageId) return { ...state, selectedImage: msg.imageId } } case "modalCloseButtonClicked": { - console.log("modal closed") return { ...state, selectedImage: undefined } } default: @@ -223,16 +313,51 @@ function useDispatch() { // === Views === function RemoteImages({ images }: { images: Remote }) { + const dispatch = useDispatch() switch (images.tag) { case "loading": return case "error": - return
Error: TODO
- case "loaded": + return ( + + + + { + dispatch({ tag: "retryButtonClicked" }) + }} + /> + + + ) + case "ok": return } } +function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) { + return ( + + Could not load images + + {RequestError.toString(error)} + + + + ) +} + function imageGridStyle(dimension: Dimension) { return { display: "grid", @@ -246,18 +371,29 @@ function imageGridStyle(dimension: Dimension) { } // 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) -function ImagesSkeleton() { +function ImagesSkeleton({ visible = true }: { visible?: boolean }) { const images = Array.from({ length: 10 }) return ( - {images.map((_, index) => ( - - ))} + {images.map((_, index) => + visible ? ( + + ) : ( + + ), + )} ) } @@ -267,26 +403,68 @@ function Images({ images }: { images: ImageId[] }) { return ( {images.map((imageId) => ( - { dispatch({ tag: "imageClicked", imageId }) }} - sx={{ - width: Dimension.medium.width, - height: Dimension.medium.height, - objectFit: "cover", - cursor: "pointer", - }} key={imageId} - alt="" /> ))} ) } +function Image({ + imageId, + dimension, + onClick, +}: { + imageId: ImageId + dimension: Dimension + onClick?: () => void +}) { + const [loaded, setLoaded] = useState(false) + + return ( + + {!loaded && ( + + )} + { + setLoaded(true) + }} + onClick={onClick} + sx={{ + position: "absolute", + inset: 0, + width: dimension.width, + height: dimension.height, + objectFit: "cover", + cursor: onClick === undefined ? "default" : "pointer", + opacity: loaded ? 1 : 0, + }} + alt="" + /> + + ) +} + function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { const dispatch = useDispatch() return ( @@ -298,19 +476,7 @@ function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { maxWidth={false} > - {selectedImage !== undefined && ( - - )} + {selectedImage !== undefined && } ) From 5f9bdd5511b78c771589d2f2ff8abf743bd51a40 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 10:44:11 +0200 Subject: [PATCH 04/10] Cleanup TODOs --- src/App.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a4a3aac..47e0711 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,6 @@ import { DialogContent, } from "@mui/material" -// TODO: Improve error-handling. Introduce some proper server response. - type Result = { tag: "ok"; value: A } | { tag: "error"; error: E } const Result = { @@ -141,7 +139,6 @@ async function fetchJsonSafeWith
( case "error": return Result.err(result.error) case "ok": - // TODO try { return Result.ok(f(result.value)) } catch (error: unknown) { @@ -201,7 +198,6 @@ function getImageSource(id: ImageId, dimension: Dimension): ImageRef { } // === App === -// TODO: introduce proper error type type AppError = RequestError type State = { @@ -255,7 +251,6 @@ function useApp(): [State, Dispatch] { // === initialization & reloading === useEffect(() => { - // TODO: error-handling void getImageIdsCached(state.page).then((result) => { dispatch({ tag: "imagesReceived", forPage: state.page, result }) }) @@ -270,8 +265,6 @@ function useApp(): [State, Dispatch] { return state } case "retryButtonClicked": - console.log("TODO") - // TODO: This actually needs to trigger the reload somehow /facepalm. But we can refresh only on page change. return { ...state, imageIds: Remote.loading(), refreshSignal: !state.refreshSignal } case "previousButtonClicked": { const newPage = Page.previous(state.page) From c3c629943c3b5377cf14907161bbcefac19ad963 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 11:12:42 +0200 Subject: [PATCH 05/10] Reorganization. Split remote/result/request. Split picsum specific api. --- src/App.tsx | 535 +---------------------------------------- src/api/picsum.ts | 29 +++ src/remote.ts | 15 ++ src/request.ts | 71 ++++++ src/result.ts | 18 ++ src/ui/Picturarium.tsx | 414 +++++++++++++++++++++++++++++++ 6 files changed, 550 insertions(+), 532 deletions(-) create mode 100644 src/api/picsum.ts create mode 100644 src/remote.ts create mode 100644 src/request.ts create mode 100644 src/result.ts create mode 100644 src/ui/Picturarium.tsx diff --git a/src/App.tsx b/src/App.tsx index 47e0711..8970234 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,539 +1,10 @@ -import { z } from "zod" -import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" -import { - CssBaseline, - Box, - Button, - Typography, - Skeleton, - Dialog, - DialogContent, -} from "@mui/material" - -type Result = { tag: "ok"; value: A } | { tag: "error"; error: E } - -const Result = { - ok(value: A): Result { - return { tag: "ok", value } - }, - err(error: E): Result { - return { tag: "error", error } - }, - map(result: Result, f: (x: A) => B): Result { - switch (result.tag) { - case "ok": - return { tag: "ok", value: f(result.value) } - case "error": - return { tag: "error", error: result.error } - } - }, -} - -type RequestError = - | { tag: "networkError"; error: unknown } - | { tag: "httpError"; status: number; statusText: string } - | { tag: "jsonParseError"; error: unknown } - | { tag: "invalidResponse"; error: z.ZodError } - -const RequestError = { - toString(error: RequestError): string { - switch (error.tag) { - case "networkError": - return "Network error. Check your connection and try again." - case "httpError": - return `Server returned ${String(error.status)} ${error.statusText}.` - case "jsonParseError": - return "The server returned an invalid response." - case "invalidResponse": - return "The image data had an unexpected format." - } - }, -} - -// === Image === -type ImageId = string -type ImageRef = string - -// === Dimension === -type Dimension = { - width: number - height: number -} -const Dimension = { - medium: { width: 180, height: 180 }, - big: { width: 720, height: 720 }, -} satisfies { - medium: Dimension - big: Dimension -} - -// === Page === -type PageNumber = number - -type Page = { - page: PageNumber - limit: number // page size -} -type PageKey = string - -const LIMIT = 10 -const FIRST_PAGE: PageNumber = 1 - -const Page = { - init(): Page { - return { page: FIRST_PAGE, limit: LIMIT } - }, - next(page: Page): Page { - return { ...page, page: page.page + 1 } - }, - previous(page: Page): Page { - // Could do - // if (page.page == FIRST_PAGE) { return page } - // this preserves identity of object, so is nicer for `useEffect`, but it's waaaay to subtle. So I'm not relying on that. - return { ...page, page: Math.max(FIRST_PAGE, page.page - 1) } - }, - eq(page0: Page, page1: Page): boolean { - return page0.page === page1.page && page0.limit === page1.limit - }, - // for hashing in maps to avoid identity problems - key(page: Page): PageKey { - return `${String(page.page)}:${String(page.limit)}` - }, - isFirst(page: Page): boolean { - return page.page === FIRST_PAGE - }, -} - -// === Generic safe fetch functions === -async function fetchJsonSafe(url: string): Promise> { - let response: Response - try { - response = await fetch(url) - } catch (error: unknown) { - return Result.err({ tag: "networkError", error }) - } - - if (!response.ok) { - return Result.err({ - tag: "httpError", - status: response.status, - statusText: response.statusText, - }) - } else { - try { - const json: unknown = await response.json() - return Result.ok(json) - } catch (error: unknown) { - return Result.err({ tag: "jsonParseError", error }) - } - } -} - -// Assumes `f` is some sort of a zod parsing function that may throw `z.ZodError` -async function fetchJsonSafeWith( - url: string, - f: (json: unknown) => A, -): Promise> { - const result = await fetchJsonSafe(url) - switch (result.tag) { - case "error": - return Result.err(result.error) - case "ok": - try { - return Result.ok(f(result.value)) - } catch (error: unknown) { - if (error instanceof z.ZodError) { - return Result.err({ tag: "invalidResponse", error }) - } else { - throw error - } - } - } -} - -// === Generic Remote Data === -type Remote = { tag: "loading" } | Result - -const Remote = { - loading(): Remote { - return { tag: "loading" } - }, - error(error: E): Remote { - return { tag: "error", error } - }, - loaded(value: A): Remote { - return { tag: "ok", value } - }, -} - -// === api === -const picsumApiImageSchema = z.object({ - id: z.string(), - author: z.string(), - width: z.number(), - height: z.number(), - url: z.string(), - download_url: z.string(), // WARNING: the api-endpoint returns "url" and "download_url". You definitely want "download_url" for image sources. -}) -type PicsumApiImage = z.infer - -async function getPicsumImages({ - page, - limit, -}: Page): Promise> { - return fetchJsonSafeWith( - `https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`, - (json) => z.array(picsumApiImageSchema).parse(json), - ) -} - -async function getImageIds(page: Page): Promise> { - const result = await getPicsumImages(page) - return Result.map(result, (data) => data.map(({ id }) => id)) -} - -// Use this for `` -function getImageSource(id: ImageId, dimension: Dimension): ImageRef { - return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}` -} - -// === App === -type AppError = RequestError - -type State = { - imageIds: Remote - page: Page - selectedImage: ImageId | undefined - refreshSignal: boolean // This is here only because of the refresh button. It is an artificial way to trigger a reload without the current page changing. The value is meaningless. It just has to change. -} - -const State = { - init(): State { - return { - imageIds: Remote.loading(), - page: Page.init(), - selectedImage: undefined, - refreshSignal: false, - } - }, -} - -type Msg = - | { tag: "imagesReceived"; result: Result; forPage: Page } - | { tag: "retryButtonClicked" } - | { tag: "previousButtonClicked" } - | { tag: "nextButtonClicked" } - | { tag: "imageClicked"; imageId: ImageId } - | { tag: "modalCloseButtonClicked" } - -function useApp(): [State, Dispatch] { - const [state, dispatch] = useReducer(update, State.init()) - - // === Caching API calls === - // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) - const cacheRef = useRef>(new Map()) - - async function getImageIdsCached(page: Page): Promise> { - const pageKey = Page.key(page) - const maybeImages = cacheRef.current.get(pageKey) - if (maybeImages === undefined) { - // console.log("CACHE-MISS") - const result = await getImageIds(page) - if (result.tag === "ok") { - cacheRef.current.set(pageKey, result.value) - } - return result - } else { - // console.log("CACHE-HIT") - return Result.ok(maybeImages) - } - } - - // === initialization & reloading === - useEffect(() => { - void getImageIdsCached(state.page).then((result) => { - dispatch({ tag: "imagesReceived", forPage: state.page, result }) - }) - }, [state.page, state.refreshSignal]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm - - function update(state: State, msg: Msg): State { - switch (msg.tag) { - case "imagesReceived": - if (Page.eq(state.page, msg.forPage)) { - return { ...state, imageIds: msg.result } - } else { - return state - } - case "retryButtonClicked": - return { ...state, imageIds: Remote.loading(), refreshSignal: !state.refreshSignal } - case "previousButtonClicked": { - const newPage = Page.previous(state.page) - if (Page.eq(newPage, state.page)) { - return state // preserves identity - } else { - return { ...state, page: newPage, imageIds: Remote.loading() } - } - } - case "nextButtonClicked": { - return { ...state, page: Page.next(state.page), imageIds: Remote.loading() } - } - case "imageClicked": { - return { ...state, selectedImage: msg.imageId } - } - case "modalCloseButtonClicked": { - return { ...state, selectedImage: undefined } - } - default: - return msg satisfies never - } - } - - return [state, dispatch] -} - -type Dispatch = (msg: Msg) => void -const DispatchContext = createContext(null) - -function useDispatch() { - const dispatch = useContext(DispatchContext) - - if (dispatch === null) { - throw new Error("useDispatch must be used inside DispatchContext.Provider") - } - - return dispatch -} - -// === Views === -function RemoteImages({ images }: { images: Remote }) { - const dispatch = useDispatch() - switch (images.tag) { - case "loading": - return - case "error": - return ( - - - - { - dispatch({ tag: "retryButtonClicked" }) - }} - /> - - - ) - case "ok": - return - } -} - -function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) { - return ( - - Could not load images - - {RequestError.toString(error)} - - - - ) -} - -function imageGridStyle(dimension: Dimension) { - return { - display: "grid", - gridTemplateColumns: { - xs: `repeat(2, ${String(dimension.width)}px)`, - md: `repeat(5, ${String(dimension.width)}px)`, - }, - gridAutoRows: `${String(dimension.height)}px`, - gap: 2, - } -} - -// 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) -function ImagesSkeleton({ visible = true }: { visible?: boolean }) { - const images = Array.from({ length: 10 }) - return ( - - {images.map((_, index) => - visible ? ( - - ) : ( - - ), - )} - - ) -} - -function Images({ images }: { images: ImageId[] }) { - const dispatch = useDispatch() - return ( - - {images.map((imageId) => ( - { - dispatch({ tag: "imageClicked", imageId }) - }} - key={imageId} - /> - ))} - - ) -} - -function Image({ - imageId, - dimension, - onClick, -}: { - imageId: ImageId - dimension: Dimension - onClick?: () => void -}) { - const [loaded, setLoaded] = useState(false) - - return ( - - {!loaded && ( - - )} - { - setLoaded(true) - }} - onClick={onClick} - sx={{ - position: "absolute", - inset: 0, - width: dimension.width, - height: dimension.height, - objectFit: "cover", - cursor: onClick === undefined ? "default" : "pointer", - opacity: loaded ? 1 : 0, - }} - alt="" - /> - - ) -} - -function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { - const dispatch = useDispatch() - return ( - { - dispatch({ tag: "modalCloseButtonClicked" }) - }} - maxWidth={false} - > - - {selectedImage !== undefined && } - - - ) -} - +import Picturarium from "./ui/Picturarium" +import { CssBaseline } from "@mui/material" export default function App() { - const [state, dispatch] = useApp() - return ( <> - - - Picturarium - - - - - - - {state.page.page} - - - - - - - + ) } diff --git a/src/api/picsum.ts b/src/api/picsum.ts new file mode 100644 index 0000000..63f81da --- /dev/null +++ b/src/api/picsum.ts @@ -0,0 +1,29 @@ +import { z } from "zod" +import { fetchJsonSafeWith } from "../request" +import type { RequestError } from "../request" +import type { Result } from "../result" + +export const picsumApiImageSchema = z.object({ + id: z.string(), + author: z.string(), + width: z.number(), + height: z.number(), + url: z.string(), + download_url: z.string(), // WARNING: the api-endpoint returns "url" and "download_url". You definitely want "download_url" for image sources. +}) +export type PicsumApiImage = z.infer + +export type GetPicsumImagesParams = { + page: number + limit: number +} + +export async function getPicsumImages({ + page, + limit, +}: GetPicsumImagesParams): Promise> { + return fetchJsonSafeWith( + `https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`, + (json) => z.array(picsumApiImageSchema).parse(json), + ) +} diff --git a/src/remote.ts b/src/remote.ts new file mode 100644 index 0000000..1a334bb --- /dev/null +++ b/src/remote.ts @@ -0,0 +1,15 @@ +import type { Result } from "./result" + +export type Remote = { tag: "loading" } | Result + +export const Remote = { + loading(): Remote { + return { tag: "loading" } + }, + error(error: E): Remote { + return { tag: "error", error } + }, + loaded(value: A): Remote { + return { tag: "ok", value } + }, +} diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..450727b --- /dev/null +++ b/src/request.ts @@ -0,0 +1,71 @@ +import { z } from "zod" +import { Result } from "./result" + +// === Request Errors === +export type RequestError = + | { tag: "networkError"; error: unknown } + | { tag: "httpError"; status: number; statusText: string } + | { tag: "jsonParseError"; error: unknown } + | { tag: "invalidResponse"; error: z.ZodError } + +export const RequestError = { + toString(error: RequestError): string { + switch (error.tag) { + case "networkError": + return "Network error. Check your connection and try again." + case "httpError": + return `Server returned ${String(error.status)} ${error.statusText}.` + case "jsonParseError": + return "The server returned an invalid response." + case "invalidResponse": + return "The image data had an unexpected format." + } + }, +} + +// === Generic safe fetch functions === +export async function fetchJsonSafe(url: string): Promise> { + let response: Response + try { + response = await fetch(url) + } catch (error: unknown) { + return Result.err({ tag: "networkError", error }) + } + + if (!response.ok) { + return Result.err({ + tag: "httpError", + status: response.status, + statusText: response.statusText, + }) + } else { + try { + const json: unknown = await response.json() + return Result.ok(json) + } catch (error: unknown) { + return Result.err({ tag: "jsonParseError", error }) + } + } +} + +// Assumes `f` is some sort of a zod parsing function that may throw `z.ZodError` +export async function fetchJsonSafeWith( + url: string, + f: (json: unknown) => A, +): Promise> { + const result = await fetchJsonSafe(url) + switch (result.tag) { + case "error": + return Result.err(result.error) + case "ok": + try { + return Result.ok(f(result.value)) + } catch (error: unknown) { + if (error instanceof z.ZodError) { + return Result.err({ tag: "invalidResponse", error }) + } else { + throw error + } + } + } +} diff --git a/src/result.ts b/src/result.ts new file mode 100644 index 0000000..ea120c5 --- /dev/null +++ b/src/result.ts @@ -0,0 +1,18 @@ +export type Result = { tag: "ok"; value: A } | { tag: "error"; error: E } + +export const Result = { + ok(value: A): Result { + return { tag: "ok", value } + }, + err(error: E): Result { + return { tag: "error", error } + }, + map(result: Result, f: (x: A) => B): Result { + switch (result.tag) { + case "ok": + return { tag: "ok", value: f(result.value) } + case "error": + return { tag: "error", error: result.error } + } + }, +} diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx new file mode 100644 index 0000000..4c19d19 --- /dev/null +++ b/src/ui/Picturarium.tsx @@ -0,0 +1,414 @@ +import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" +import { Box, Button, Typography, Skeleton, Dialog, DialogContent } from "@mui/material" +import { getPicsumImages } from "../api/picsum" +import { Result } from "../result" +import { RequestError } from "../request" +import { Remote } from "../remote" + +// NOTE: There are multiple school of thoughts on how to structure a component. +// - What I prefer is to extract generic components or complex sub-components into their own modules (but this assignment is too simple for that). +// I also like to keep views/states/messages/initialization/effects/update of one component in one single (even if giant) file (or a structure like `Component/...` + `Component.tsx`) +// - Others school of thought are to grind all of these into fine dust and scatter the files all over the codebase (all state definitions in one giant folder, all messages/updates in another). I prefer not doing that. It violates locality too much for me, and I constantly have to jump between files "far-away". + +// === Image === +type ImageId = string +type ImageRef = string + +// === Dimension === +type Dimension = { + width: number + height: number +} +const Dimension = { + medium: { width: 180, height: 180 }, + big: { width: 720, height: 720 }, +} satisfies { + medium: Dimension + big: Dimension +} + +// === Page === +type PageNumber = number + +type Page = { + page: PageNumber + limit: number // page size +} +type PageKey = string + +const LIMIT = 10 +const FIRST_PAGE: PageNumber = 1 + +const Page = { + init(): Page { + return { page: FIRST_PAGE, limit: LIMIT } + }, + next(page: Page): Page { + return { ...page, page: page.page + 1 } + }, + previous(page: Page): Page { + // Could do + // if (page.page == FIRST_PAGE) { return page } + // this preserves identity of object, so is nicer for `useEffect`, but it's waaaay to subtle. So I'm not relying on that. + return { ...page, page: Math.max(FIRST_PAGE, page.page - 1) } + }, + eq(page0: Page, page1: Page): boolean { + return page0.page === page1.page && page0.limit === page1.limit + }, + // for hashing in maps to avoid identity problems + key(page: Page): PageKey { + return `${String(page.page)}:${String(page.limit)}` + }, + isFirst(page: Page): boolean { + return page.page === FIRST_PAGE + }, +} + +// === api === +async function getImageIds(page: Page): Promise> { + const result = await getPicsumImages(page) + return Result.map(result, (data) => data.map(({ id }) => id)) +} + +// Use this for `` +function getImageSource(id: ImageId, dimension: Dimension): ImageRef { + return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}` +} + +// === App === +type AppError = RequestError + +type State = { + imageIds: Remote + page: Page + selectedImage: ImageId | undefined + refreshSignal: boolean // This is here only because of the refresh button. It is an artificial way to trigger a reload without the current page changing. The value is meaningless. It just has to change. +} + +const State = { + init(): State { + return { + imageIds: Remote.loading(), + page: Page.init(), + selectedImage: undefined, + refreshSignal: false, + } + }, +} + +type Msg = + | { tag: "imagesReceived"; result: Result; forPage: Page } + | { tag: "retryButtonClicked" } + | { tag: "previousButtonClicked" } + | { tag: "nextButtonClicked" } + | { tag: "imageClicked"; imageId: ImageId } + | { tag: "modalCloseButtonClicked" } + +function useApp(): [State, Dispatch] { + const [state, dispatch] = useReducer(update, State.init()) + + // === Caching API calls === + // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) + const cacheRef = useRef>(new Map()) + + async function getImageIdsCached(page: Page): Promise> { + const pageKey = Page.key(page) + const maybeImages = cacheRef.current.get(pageKey) + if (maybeImages === undefined) { + // console.log("CACHE-MISS") + const result = await getImageIds(page) + if (result.tag === "ok") { + cacheRef.current.set(pageKey, result.value) + } + return result + } else { + // console.log("CACHE-HIT") + return Result.ok(maybeImages) + } + } + + // === initialization & reloading === + useEffect(() => { + void getImageIdsCached(state.page).then((result) => { + dispatch({ tag: "imagesReceived", forPage: state.page, result }) + }) + }, [state.page, state.refreshSignal]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm + + function update(state: State, msg: Msg): State { + switch (msg.tag) { + case "imagesReceived": + if (Page.eq(state.page, msg.forPage)) { + return { ...state, imageIds: msg.result } + } else { + return state + } + case "retryButtonClicked": + return { ...state, imageIds: Remote.loading(), refreshSignal: !state.refreshSignal } + case "previousButtonClicked": { + const newPage = Page.previous(state.page) + if (Page.eq(newPage, state.page)) { + return state // preserves identity + } else { + return { ...state, page: newPage, imageIds: Remote.loading() } + } + } + case "nextButtonClicked": { + return { ...state, page: Page.next(state.page), imageIds: Remote.loading() } + } + case "imageClicked": { + return { ...state, selectedImage: msg.imageId } + } + case "modalCloseButtonClicked": { + return { ...state, selectedImage: undefined } + } + default: + return msg satisfies never + } + } + + return [state, dispatch] +} + +type Dispatch = (msg: Msg) => void +const DispatchContext = createContext(null) + +function useDispatch() { + const dispatch = useContext(DispatchContext) + + if (dispatch === null) { + throw new Error("useDispatch must be used inside DispatchContext.Provider") + } + + return dispatch +} + +// === Views === +function RemoteImages({ images }: { images: Remote }) { + const dispatch = useDispatch() + switch (images.tag) { + case "loading": + return + case "error": + return ( + + + + { + dispatch({ tag: "retryButtonClicked" }) + }} + /> + + + ) + case "ok": + return + } +} + +function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) { + return ( + + Could not load images + + {RequestError.toString(error)} + + + + ) +} + +function imageGridStyle(dimension: Dimension) { + return { + display: "grid", + gridTemplateColumns: { + xs: `repeat(2, ${String(dimension.width)}px)`, + md: `repeat(5, ${String(dimension.width)}px)`, + }, + gridAutoRows: `${String(dimension.height)}px`, + gap: 2, + } +} + +// 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) +function ImagesSkeleton({ visible = true }: { visible?: boolean }) { + const images = Array.from({ length: 10 }) + return ( + + {images.map((_, index) => + visible ? ( + + ) : ( + + ), + )} + + ) +} + +function Images({ images }: { images: ImageId[] }) { + const dispatch = useDispatch() + return ( + + {images.map((imageId) => ( + { + dispatch({ tag: "imageClicked", imageId }) + }} + key={imageId} + /> + ))} + + ) +} + +function Image({ + imageId, + dimension, + onClick, +}: { + imageId: ImageId + dimension: Dimension + onClick?: () => void +}) { + const [loaded, setLoaded] = useState(false) + + return ( + + {!loaded && ( + + )} + { + setLoaded(true) + }} + onClick={onClick} + sx={{ + position: "absolute", + inset: 0, + width: dimension.width, + height: dimension.height, + objectFit: "cover", + cursor: onClick === undefined ? "default" : "pointer", + opacity: loaded ? 1 : 0, + }} + alt="" + /> + + ) +} + +function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { + const dispatch = useDispatch() + return ( + { + dispatch({ tag: "modalCloseButtonClicked" }) + }} + maxWidth={false} + > + + {selectedImage !== undefined && } + + + ) +} + +export default function Picturarium() { + const [state, dispatch] = useApp() + + return ( + + + Picturarium + + + + + + + {state.page.page} + + + + + + + + ) +} From 65fe6a9a829d9891b6734b93c87af192ef70f825 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 11:36:50 +0200 Subject: [PATCH 06/10] Introduce Picturarium config. --- src/ui/Picturarium.tsx | 82 +++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx index 4c19d19..1ff0b83 100644 --- a/src/ui/Picturarium.tsx +++ b/src/ui/Picturarium.tsx @@ -10,6 +10,57 @@ import { Remote } from "../remote" // I also like to keep views/states/messages/initialization/effects/update of one component in one single (even if giant) file (or a structure like `Component/...` + `Component.tsx`) // - Others school of thought are to grind all of these into fine dust and scatter the files all over the codebase (all state definitions in one giant folder, all messages/updates in another). I prefer not doing that. It violates locality too much for me, and I constantly have to jump between files "far-away". +// === Config === +// prefered number of cols/rows. The page size (how many images are on a page) is calculated as `columns * rows` +type Config = { + pageSize: number + + mobileColumns: number + mobileRows: number + + desktopColumns: number + desktopRows: number +} + +const CONFIG = { + pageSize: 10, + + mobileColumns: 2, + mobileRows: 5, + + desktopColumns: 5, + desktopRows: 2, +} as const satisfies Config +// const CONFIG = { +// pageSize: 12, + +// mobileColumns: 2, +// mobileRows: 6, + +// desktopColumns: 4, +// desktopRows: 3, +// } as const satisfies Config + +// Could do comptime assert: `size = mobile product`, and `size = desktop product`, but this is pretty heavy in TS. +assertValidConfig(CONFIG) + +function assertValidConfig(config: Config): void { + const mobilePageSize = config.mobileColumns * config.mobileRows + const desktopPageSize = config.desktopColumns * config.desktopRows + + if (mobilePageSize !== config.pageSize) { + throw new Error( + `Invalid config: mobile grid defines ${String(mobilePageSize)} images, but pageSize is ${String(config.pageSize)}`, + ) + } + + if (desktopPageSize !== config.pageSize) { + throw new Error( + `Invalid config: desktop grid defines ${String(desktopPageSize)} images, but pageSize is ${String(config.pageSize)}`, + ) + } +} + // === Image === type ImageId = string type ImageRef = string @@ -28,45 +79,44 @@ const Dimension = { } // === Page === -type PageNumber = number +type PageIndex = number type Page = { - page: PageNumber - limit: number // page size + index: PageIndex + size: number } type PageKey = string -const LIMIT = 10 -const FIRST_PAGE: PageNumber = 1 +const FIRST_PAGE: PageIndex = 1 const Page = { init(): Page { - return { page: FIRST_PAGE, limit: LIMIT } + return { index: FIRST_PAGE, size: CONFIG.pageSize } }, next(page: Page): Page { - return { ...page, page: page.page + 1 } + return { ...page, index: page.index + 1 } }, previous(page: Page): Page { // Could do // if (page.page == FIRST_PAGE) { return page } // this preserves identity of object, so is nicer for `useEffect`, but it's waaaay to subtle. So I'm not relying on that. - return { ...page, page: Math.max(FIRST_PAGE, page.page - 1) } + return { ...page, index: Math.max(FIRST_PAGE, page.index - 1) } }, eq(page0: Page, page1: Page): boolean { - return page0.page === page1.page && page0.limit === page1.limit + return page0.index === page1.index && page0.size === page1.size }, // for hashing in maps to avoid identity problems key(page: Page): PageKey { - return `${String(page.page)}:${String(page.limit)}` + return `${String(page.index)}:${String(page.size)}` }, isFirst(page: Page): boolean { - return page.page === FIRST_PAGE + return page.index === FIRST_PAGE }, } // === api === async function getImageIds(page: Page): Promise> { - const result = await getPicsumImages(page) + const result = await getPicsumImages({ page: page.index, limit: page.size }) return Result.map(result, (data) => data.map(({ id }) => id)) } @@ -233,8 +283,8 @@ function imageGridStyle(dimension: Dimension) { return { display: "grid", gridTemplateColumns: { - xs: `repeat(2, ${String(dimension.width)}px)`, - md: `repeat(5, ${String(dimension.width)}px)`, + xs: `repeat(${String(CONFIG.mobileColumns)}, ${String(dimension.width)}px)`, + md: `repeat(${String(CONFIG.desktopColumns)}, ${String(dimension.width)}px)`, }, gridAutoRows: `${String(dimension.height)}px`, gap: 2, @@ -243,7 +293,7 @@ function imageGridStyle(dimension: Dimension) { // 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) function ImagesSkeleton({ visible = true }: { visible?: boolean }) { - const images = Array.from({ length: 10 }) + const images = Array.from({ length: CONFIG.pageSize }) return ( {images.map((_, index) => @@ -396,7 +446,7 @@ export default function Picturarium() { color: "oklch(65% 0.02 260)", }} > - {state.page.page} + {state.page.index} - - ) -} - function imageGridStyle(dimension: Dimension) { return { display: "grid", @@ -400,6 +436,7 @@ function Image({ } function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { + // TODO: Display captions const dispatch = useDispatch() return ( - {selectedImage !== undefined && } + {selectedImage !== undefined && } ) } +function OpenImageModal({ imageId }: { imageId: ImageId }) { + type State = { + caption: Remote + } + + const [state, setState] = useState({ caption: Remote.loading() }) + + useEffect(() => { + let isAlive = true + // TODO: Do I need to set `Remote.loading()` here? I don't like that. + + void getImageCaption(imageId).then((caption) => { + if (isAlive) { + setState({ caption }) + } + }) + + return () => { + isAlive = false + } + }, [imageId]) + return ( + + + + + ) +} + +function CaptionView({ caption }: { caption: Remote }) { + switch (caption.tag) { + case "loading": + return ( + + + + ) + case "error": + return ( + + + + ) + case "ok": + return ( + + + {caption.value} + + + ) + } +} + export default function Picturarium() { const [state, dispatch] = useApp() @@ -475,3 +576,38 @@ export default function Picturarium() { ) } + + +// === Error view === +function ErrorView({ + title, + error, + onRetry, +}: { + title?: string + error: AppError + onRetry?: () => void +}) { + return ( + + {title} + + {RequestError.toString(error)} + + {onRetry !== undefined && ( + + )} + + ) +} + From 8ea8fa6446be47b925eb97f6fa3a95f6d682df2b Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 15 May 2026 15:53:15 +0200 Subject: [PATCH 10/10] Cleanup of old code. Abstract out cache of remote requests. Cache for AI captions. --- src/cache.ts | 32 +++++++ src/ui/Picturarium.tsx | 202 +++++++++++++++++++---------------------- 2 files changed, 125 insertions(+), 109 deletions(-) create mode 100644 src/cache.ts diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..e2001a6 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,32 @@ +import { useRef } from "react" +import type { RequestError } from "./request" +import { Result } from "./result" + +// KeyHash is supposed to be something where `===` compares for structural equality (so in JS: number, string). +export type KeyHash = string | number +export function useCache( + hash: (key: Key) => KeyHash, + f: (k: Key) => Promise>, +) { + // @PERSONAL_NOTE + // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) + const cacheRef = useRef>(new Map()) + + async function getCached(key: Key): Promise> { + const keyHash = hash(key) + const maybeValue = cacheRef.current.get(keyHash) + if (maybeValue === undefined) { + // console.log("CACHE-MISS") + const result = await f(key) + if (result.tag === "ok") { + cacheRef.current.set(keyHash, result.value) + } + return result + } else { + // console.log("CACHE-HIT") + return Result.ok(maybeValue) + } + } + + return getCached +} diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx index 8b685c5..9115fe8 100644 --- a/src/ui/Picturarium.tsx +++ b/src/ui/Picturarium.tsx @@ -1,4 +1,4 @@ -import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" +import { useReducer, useEffect, createContext, useContext, useState } from "react" import { Box, Button, @@ -14,6 +14,7 @@ import { Result } from "../result" import { RequestError } from "../request" import { Remote } from "../remote" import { getCaption } from "../api/caption" +import { useCache } from "../cache" // @PERSONAL_NOTE // NOTE: There are multiple school of thoughts on how to structure a component. @@ -188,39 +189,41 @@ type Msg = | { tag: "previousButtonClicked" } | { tag: "nextButtonClicked" } | { tag: "imageClicked"; imageId: ImageId } - // TODO: Move - // | { tag: "captionReceived"; forImage: ImageId; result: Result } | { tag: "modalCloseButtonClicked" } -function useApp(): [State, Dispatch] { +// === Caching Context for AI captions === +type GetImageCaptionCached = (imageId: ImageId) => Promise> + +const ImageCaptionCacheContext = createContext(null) + +function useGetImageCaptionCached(): GetImageCaptionCached { + const getImageCaptionCached = useContext(ImageCaptionCacheContext) + + if (getImageCaptionCached === null) { + throw new Error( + "useGetImageCaptionCached must be used inside ImageCaptionCacheContext.Provider", + ) + } + + return getImageCaptionCached +} + +function useApp(): [State, Dispatch, GetImageCaptionCached] { const [state, dispatch] = useReducer(update, State.init()) // === Caching API calls === - // @PERSONAL_NOTE - // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) - const cacheRef = useRef>(new Map()) - - async function getImageIdsCached(page: Page): Promise> { - const pageKey = Page.key(page) - const maybeImages = cacheRef.current.get(pageKey) - if (maybeImages === undefined) { - // console.log("CACHE-MISS") - const result = await getImageIds(page) - if (result.tag === "ok") { - cacheRef.current.set(pageKey, result.value) - } - return result - } else { - // console.log("CACHE-HIT") - return Result.ok(maybeImages) - } - } + const getImageIdsCached = useCache((page) => Page.key(page), getImageIds) + const getImageCaptionCached: GetImageCaptionCached = useCache( + (imageId) => imageId, + getImageCaption, + ) // === effects: initialization & reloading === // == Page Refresh == useEffect( () => { + // This call triggers eslint warning, but it's fine. We don't want to declare `getImageIdsCached` as a dependency. void getImageIdsCached(state.page).then((result) => { dispatch({ tag: "imagesReceived", forPage: state.page, result }) }) @@ -231,17 +234,6 @@ function useApp(): [State, Dispatch] { ) // == AI Captions == - // TODO: MOVE - // useEffect(() => { - // if (state.selectedImage !== undefined) { - // const imageId = state.selectedImage.imageId - // void getImageCaption(imageId).then((result) => { - // dispatch({ tag: "captionReceived", forImage: imageId, result }) - // }) - // // TODO: Need to fetch the image (with some url of smaller dimensions) - // } - // }, [state.selectedImage?.imageId]) - function update(state: State, msg: Msg): State { switch (msg.tag) { case "imagesReceived": @@ -266,18 +258,6 @@ function useApp(): [State, Dispatch] { case "imageClicked": { return { ...state, selectedImage: msg.imageId } } - // TODO: move - // case "captionReceived": { - // if (state.selectedImage !== undefined && state.selectedImage.imageId === msg.forImage) { - // const modalState: OpenImageModalState = { - // ...state.selectedImage, - // caption: msg.result, - // } - // return { ...state, selectedImage: modalState } - // } else { - // return state - // } - // } case "modalCloseButtonClicked": { return { ...state, selectedImage: undefined } } @@ -286,7 +266,7 @@ function useApp(): [State, Dispatch] { } } - return [state, dispatch] + return [state, dispatch, getImageCaptionCached] } type Dispatch = (msg: Msg) => void @@ -436,7 +416,6 @@ function Image({ } function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { - // TODO: Display captions const dispatch = useDispatch() return ( ({ caption: Remote.loading() }) + const getImageCaptionCached = useGetImageCaptionCached() - useEffect(() => { - let isAlive = true - // TODO: Do I need to set `Remote.loading()` here? I don't like that. + useEffect( + () => { + let isAlive = true + // TODO: Do I need to set `Remote.loading()` here? I don't like that. - void getImageCaption(imageId).then((caption) => { - if (isAlive) { - setState({ caption }) + void getImageCaptionCached(imageId).then((caption) => { + if (isAlive) { + setState({ caption }) + } + }) + + return () => { + isAlive = false } - }) - - return () => { - isAlive = false - } - }, [imageId]) + }, + // This call triggers eslint warning, but it's fine. We don't want to declare `getImageCaptionCached` as a dependency. + [imageId], + ) return ( @@ -518,66 +502,67 @@ function CaptionView({ caption }: { caption: Remote }) { } export default function Picturarium() { - const [state, dispatch] = useApp() + const [state, dispatch, getImageCaptionCached] = useApp() return ( - - - Picturarium - - - + + - - - {state.page.index} - - - - + Picturarium - - + + + + + + {state.page.index} + + + + + + + + ) } - // === Error view === function ErrorView({ title, @@ -610,4 +595,3 @@ function ErrorView({ ) } -