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} + + + + + + + + ) +}