diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.gitignore b/.gitignore index 9a5ee26..e06f623 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ yarn-error.log* pnpm-debug.log* .DS_Store + +.direnv diff --git a/AI-USE.md b/AI-USE.md new file mode 100644 index 0000000..466c05a --- /dev/null +++ b/AI-USE.md @@ -0,0 +1,21 @@ +Wrote most of the code by hand. + +My own design: + +- config/state/msg/update/effects, all the types +- which hooks to use +- remote/request/result +- splitting the codebase into modules/folders/files + +LLM-generated: +where I mostly reviewed + +- The Material UI / styling - after using a few components manually, I mostly let LLM decide which components to use, how to use them, and letting it decide/generate styling details. + +LLM-assisted: + +- Name suggestions +- Project setup (linters, formatters, ts compiler settings) +- Checking and Code Review +- Snippet generation +- Explanations of external API details, and confirming browser caching behaviour diff --git a/README.md b/README.md new file mode 100644 index 0000000..366cb30 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Picturarium + +Picturarium is a small React image gallery using the Lorem Picsum API. It displays paginated image thumbnails, caches loaded pages during the session, and opens selected images in a larger +modal view. The app uses TypeScript, Material UI, and explicit remote/request state handling for loading and error states. + +# Installation + +This assumes `node` is installed on your system. +TODO: specify `git clone` too here. + +```bash +git clone // TODO +cd picturarium +npm install +``` + +# Execution + +After cloning the repo in `picturarium/`: + +## dev + +You can start a vite dev-server + +```bash +npm run dev +``` + +## production + +To see production build: + +```bash +npm run build +npm run preview +``` + +# Comments + +In this codebase I sometimes use comments that start as + +```typescript +// @PERSONAL_NOTE +``` + +These are just there for this particular assignment, and I wouldn't included them in a real codebase. +They are just notes to let you know why I made specific decisions and how I think about stuff. 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/shell.nix b/shell.nix new file mode 100644 index 0000000..f2a16d2 --- /dev/null +++ b/shell.nix @@ -0,0 +1,7 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + ]; +} diff --git a/src/App.tsx b/src/App.tsx index 2722b3d..8970234 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,272 +1,10 @@ -import { z } from "zod" -import { useReducer, useRef, useEffect, createContext, useContext } from "react" - -// 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 = { - width: number - height: number -} -const Dimension = { - medium: { width: 200, height: 200 } as Dimension, - big: { width: 300, height: 500 } as 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 `${page.page}:${page.limit}` - }, -} - -// === 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 { - const response = await fetch(`https://picsum.photos/v2/list?limit=${limit}&page=${page}`) - if (!response.ok) { - throw new Error(`Failed to fetch images: ${response.status}`) - } - - const json: unknown = await response.json() - const data = z.array(picsumApiImageSchema).parse(json) - - 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) -} - -// Use this for `` -function getImageSource(id: ImageId, dimension: Dimension): ImageRef { - return `https://picsum.photos/id/${id}/${dimension.width}/${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 State = { - imageIds: Remote - page: Page - selectedImage: ImageId | undefined -} - -const State = { - init(): State { - return { - imageIds: Remote.loading(), - page: Page.init(), - selectedImage: undefined, - } - }, -} - -type Msg = - | { tag: "imagesReceived"; imageIds: ImageId[]; forPage: Page } - | { 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 images = await getImageIds(page) - cacheRef.current.set(pageKey, images) - return images - } else { - console.log("CACHE-HIT") - return maybeImages - } - } - - // === initialization & reloading === - useEffect(() => { - // TODO: error-handling - getImageIdsCached(state.page).then((imageIds) => { - dispatch({ tag: "imagesReceived", forPage: state.page, imageIds }) - }) - }, [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 - - 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) } - } else { - return state - } - 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": { - console.log(msg.imageId) - return { ...state, selectedImage: msg.imageId } - } - case "modalCloseButtonClicked": { - console.log("modal closed") - return { ...state, selectedImage: undefined } - } - default: - return assertNever(msg) - } - } - - 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 }) { - switch (images.tag) { - case "loading": - return
Loading...
- case "error": - return
Error: TODO
- case "loaded": - return - } -} - -function Images({ images }: { images: ImageId[] }) { - const dispatch = useDispatch() - // TODO: Is there some basic `Col/Row` component? - return ( -
- {images.map((imageId) => ( - dispatch({ tag: "imageClicked", imageId })} - alt="" - /> - ))} -
- ) -} - +import Picturarium from "./ui/Picturarium" +import { CssBaseline } from "@mui/material" export default function App() { - const [state, dispatch] = useApp() - return ( - -
-

Picturarium

- - - -
-
+ <> + + + ) } diff --git a/src/api/caption.ts b/src/api/caption.ts new file mode 100644 index 0000000..531f5d4 --- /dev/null +++ b/src/api/caption.ts @@ -0,0 +1,33 @@ +import { z } from "zod" +import { fetchJsonSafeWith } from "../request" +import type { RequestError } from "../request" +import type { Result } from "../result" + +const CAPTION_API_URL = "https://picsum-caption-worker.meatbagoverclocked.workers.dev/" + +const captionResponseSchema = z.object({ + description: z.string(), +}) + +export type CaptionResponse = z.infer + +export type GetCaptionParams = { + imageUrl: string + maxWords: number +} + +export async function getCaption({ + imageUrl, + maxWords, +}: GetCaptionParams): Promise> { + return fetchJsonSafeWith(CAPTION_API_URL, (json) => captionResponseSchema.parse(json), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + imageUrl, + maxWords, + }), + }) +} 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/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/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/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..c58c989 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,75 @@ +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, + init?: RequestInit, +): Promise> { + let response: Response + try { + response = await fetch(url, init) + } 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, + init?: RequestInit, +): Promise> { + const result = await fetchJsonSafe(url, init) + 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/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; +} diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx new file mode 100644 index 0000000..9115fe8 --- /dev/null +++ b/src/ui/Picturarium.tsx @@ -0,0 +1,597 @@ +import { useReducer, useEffect, createContext, useContext, useState } from "react" +import { + Box, + Button, + Typography, + Skeleton, + Dialog, + DialogContent, + CircularProgress, + Tooltip, +} from "@mui/material" +import { getPicsumImages } from "../api/picsum" +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. +// - 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". + +// === 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 + +// @PERSONAL_NOTE +// 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 + +// === 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 PageIndex = number + +// @PERSONAL_NOTE +// This is a huge overkill for this type of application - especially because page size is known at compile-time and we can't even change page size at runtime. +// Most of the identity issues down-the line would be trivial if we just used `PageIndex` instead (which is a nice immutable value where equality is structural). +// But in general there's no avoiding objects in js/ts, so during code-review this would allow me to demonstrate understanding of these subtle issues. That's why I chose to use it here. +type Page = { + index: PageIndex + size: number +} +type PageKey = string + +const FIRST_PAGE: PageIndex = 1 + +const Page = { + init(): Page { + return { index: FIRST_PAGE, size: CONFIG.pageSize } + }, + next(page: Page): Page { + return { ...page, index: page.index + 1 } + }, + previous(page: Page): Page { + // @PERSONAL_NOTE + // Could do + // if (page.index == 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, index: Math.max(FIRST_PAGE, page.index - 1) } + }, + eq(page0: Page, page1: Page): boolean { + return page0.index === page1.index && page0.size === page1.size + }, + // for hashing in maps to avoid identity problems + key(page: Page): PageKey { + return `${String(page.index)}:${String(page.size)}` + }, + isFirst(page: Page): boolean { + return page.index === FIRST_PAGE + }, +} + +// === api === +async function getImageIds(page: Page): Promise> { + const result = await getPicsumImages({ page: page.index, limit: page.size }) + 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)}` +} + +// = captions = +const CAPTION_MAX_WORDS = 25 + +function getCaptionImageUrl(imageId: ImageId): ImageRef { + const dimension: Dimension = { width: 100, height: 100 } + + return `https://picsum.photos/id/${imageId}/${String(dimension.width)}/${String(dimension.height)}` +} + +async function getImageCaption(imageId: ImageId): Promise> { + const result = await getCaption({ + imageUrl: getCaptionImageUrl(imageId), + maxWords: CAPTION_MAX_WORDS, + }) + + return Result.map(result, ({ description }) => description) +} + +// === 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" } + +// === 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 === + 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 }) + }) + }, + // @PERSONAL_NOTE + // 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], + ) + + // == AI Captions == + 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, getImageCaptionCached] +} + +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 imageGridStyle(dimension: Dimension) { + return { + display: "grid", + gridTemplateColumns: { + 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, + } +} + +// EXAMPLE: 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) +function ImagesSkeleton({ visible = true }: { visible?: boolean }) { + const images = Array.from({ length: CONFIG.pageSize }) + 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 && } + + + ) +} + +function OpenImageModal({ imageId }: { imageId: ImageId }) { + type State = { + caption: Remote + } + + const [state, setState] = useState({ caption: Remote.loading() }) + const getImageCaptionCached = useGetImageCaptionCached() + + useEffect( + () => { + let isAlive = true + // TODO: Do I need to set `Remote.loading()` here? I don't like that. + + void getImageCaptionCached(imageId).then((caption) => { + if (isAlive) { + setState({ caption }) + } + }) + + return () => { + isAlive = false + } + }, + // This call triggers eslint warning, but it's fine. We don't want to declare `getImageCaptionCached` as a dependency. + [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, getImageCaptionCached] = useApp() + + return ( + + + + Picturarium + + + + + + + {state.page.index} + + + + + + + + + ) +} + +// === Error view === +function ErrorView({ + title, + error, + onRetry, +}: { + title?: string + error: AppError + onRetry?: () => void +}) { + return ( + + {title} + + {RequestError.toString(error)} + + {onRetry !== undefined && ( + + )} + + ) +}