diff --git a/.envrc b/.envrc deleted file mode 100644 index 1d953f4..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use nix diff --git a/.gitignore b/.gitignore index e06f623..9a5ee26 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,3 @@ yarn-error.log* pnpm-debug.log* .DS_Store - -.direnv diff --git a/AI-USE.md b/AI-USE.md deleted file mode 100644 index 466c05a..0000000 --- a/AI-USE.md +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 366cb30..0000000 --- a/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# 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 bb094c8..e50a55f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,7 +34,6 @@ 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 deleted file mode 100644 index f2a16d2..0000000 --- a/shell.nix +++ /dev/null @@ -1,7 +0,0 @@ -{ pkgs ? import {} }: - -pkgs.mkShell { - packages = [ - pkgs.nodejs_22 - ]; -} diff --git a/src/App.tsx b/src/App.tsx index 8970234..2722b3d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,272 @@ -import Picturarium from "./ui/Picturarium" -import { CssBaseline } from "@mui/material" -export default function App() { +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="" + /> + ))} +
+ ) +} + +export default function App() { + const [state, dispatch] = useApp() + + return ( + +
+

Picturarium

+ + + +
+
) } diff --git a/src/api/caption.ts b/src/api/caption.ts deleted file mode 100644 index 531f5d4..0000000 --- a/src/api/caption.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 63f81da..0000000 --- a/src/api/picsum.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index e2001a6..0000000 --- a/src/cache.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 new file mode 100644 index 0000000..bd1da64 --- /dev/null +++ b/src/index.css @@ -0,0 +1,41 @@ +/* === 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 5a7820c..57d77e4 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 "./style.css" +import "./index.css" const rootElement = document.getElementById("root") diff --git a/src/remote.ts b/src/remote.ts deleted file mode 100644 index 1a334bb..0000000 --- a/src/remote.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index c58c989..0000000 --- a/src/request.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index ea120c5..0000000 --- a/src/result.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 5467feb..0000000 --- a/src/style.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --base-hue: 260; -} diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx deleted file mode 100644 index 9115fe8..0000000 --- a/src/ui/Picturarium.tsx +++ /dev/null @@ -1,597 +0,0 @@ -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 && ( - - )} - - ) -}