Reorganization. Split remote/result/request. Split picsum specific api.

This commit is contained in:
Yura Dupyn 2026-05-15 11:12:42 +02:00
parent 5f9bdd5511
commit c3c629943c
6 changed files with 550 additions and 532 deletions

View file

@ -1,539 +1,10 @@
import { z } from "zod" import Picturarium from "./ui/Picturarium"
import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" import { CssBaseline } from "@mui/material"
import {
CssBaseline,
Box,
Button,
Typography,
Skeleton,
Dialog,
DialogContent,
} from "@mui/material"
type Result<A, E> = { tag: "ok"; value: A } | { tag: "error"; error: E }
const Result = {
ok<A>(value: A): Result<A, never> {
return { tag: "ok", value }
},
err<E>(error: E): Result<never, E> {
return { tag: "error", error }
},
map<A, B, E>(result: Result<A, E>, f: (x: A) => B): Result<B, E> {
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<Result<unknown, RequestError>> {
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<A>(
url: string,
f: (json: unknown) => A,
): Promise<Result<A, RequestError>> {
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<A, E> = { tag: "loading" } | Result<A, E>
const Remote = {
loading(): Remote<never, never> {
return { tag: "loading" }
},
error<E>(error: E): Remote<never, E> {
return { tag: "error", error }
},
loaded<A>(value: A): Remote<A, never> {
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<typeof picsumApiImageSchema>
async function getPicsumImages({
page,
limit,
}: Page): Promise<Result<PicsumApiImage[], RequestError>> {
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<Result<ImageId[], RequestError>> {
const result = await getPicsumImages(page)
return Result.map(result, (data) => data.map(({ id }) => id))
}
// Use this for `<img src=... />`
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<ImageId[], AppError>
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<ImageId[], AppError>; 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<ImageId[]>` in case two requests are made really fast one after another (not really the case in this app so whatever)
const cacheRef = useRef<Map<PageKey, ImageId[]>>(new Map())
async function getImageIdsCached(page: Page): Promise<Result<ImageId[], AppError>> {
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<Dispatch | null>(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<ImageId[], AppError> }) {
const dispatch = useDispatch()
switch (images.tag) {
case "loading":
return <ImagesSkeleton />
case "error":
return (
<Box sx={{ position: "relative" }}>
<ImagesSkeleton visible={false} />
<Box sx={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
<ErrorView
error={images.error}
onRetry={() => {
dispatch({ tag: "retryButtonClicked" })
}}
/>
</Box>
</Box>
)
case "ok":
return <Images images={images.value} />
}
}
function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
textAlign: "center",
}}
>
<Typography variant="h6">Could not load images</Typography>
<Typography variant="body2" sx={{ color: "oklch(50% 0.02 var(--base-hue))" }}>
{RequestError.toString(error)}
</Typography>
<Button variant="outlined" onClick={onRetry}>
Retry
</Button>
</Box>
)
}
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 (
<Box sx={imageGridStyle(Dimension.medium)}>
{images.map((_, index) =>
visible ? (
<Skeleton
key={index}
animation="pulse"
variant="rectangular"
width={Dimension.medium.width}
height={Dimension.medium.height}
/>
) : (
<Box
key={index}
sx={{
width: Dimension.medium.width,
height: Dimension.medium.height,
}}
/>
),
)}
</Box>
)
}
function Images({ images }: { images: ImageId[] }) {
const dispatch = useDispatch()
return (
<Box sx={imageGridStyle(Dimension.medium)}>
{images.map((imageId) => (
<Image
imageId={imageId}
dimension={Dimension.medium}
onClick={() => {
dispatch({ tag: "imageClicked", imageId })
}}
key={imageId}
/>
))}
</Box>
)
}
function Image({
imageId,
dimension,
onClick,
}: {
imageId: ImageId
dimension: Dimension
onClick?: () => void
}) {
const [loaded, setLoaded] = useState(false)
return (
<Box
sx={{
position: "relative",
width: dimension.width,
height: dimension.height,
}}
>
{!loaded && (
<Skeleton
animation="pulse"
variant="rectangular"
width={dimension.width}
height={dimension.height}
/>
)}
<Box
component="img"
src={getImageSource(imageId, dimension)}
onLoad={() => {
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=""
/>
</Box>
)
}
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
const dispatch = useDispatch()
return (
<Dialog
open={selectedImage !== undefined}
onClose={() => {
dispatch({ tag: "modalCloseButtonClicked" })
}}
maxWidth={false}
>
<DialogContent sx={{ p: 0, overflow: "hidden" }}>
{selectedImage !== undefined && <Image imageId={selectedImage} dimension={Dimension.big} />}
</DialogContent>
</Dialog>
)
}
export default function App() { export default function App() {
const [state, dispatch] = useApp()
return ( return (
<> <>
<CssBaseline /> <CssBaseline />
<DispatchContext.Provider value={dispatch}> <Picturarium />
<Box
component="main"
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography variant="h2">Picturarium</Typography>
<RemoteImages images={state.imageIds} />
<Box
sx={{
display: "flex",
flexDirection: "row",
gap: 2,
justifyContent: "center",
alignItems: "center",
}}
>
<Button
onClick={() => {
dispatch({ tag: "previousButtonClicked" })
}}
disabled={Page.isFirst(state.page)}
>
prev
</Button>
<Typography
variant="body1"
sx={{
minWidth: 60,
textAlign: "center",
color: "oklch(65% 0.02 260)",
}}
>
{state.page.page}
</Typography>
<Button
onClick={() => {
dispatch({ tag: "nextButtonClicked" })
}}
>
next
</Button>
</Box>
</Box>
<ImageModal selectedImage={state.selectedImage} />
</DispatchContext.Provider>
</> </>
) )
} }

29
src/api/picsum.ts Normal file
View file

@ -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<typeof picsumApiImageSchema>
export type GetPicsumImagesParams = {
page: number
limit: number
}
export async function getPicsumImages({
page,
limit,
}: GetPicsumImagesParams): Promise<Result<PicsumApiImage[], RequestError>> {
return fetchJsonSafeWith(
`https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`,
(json) => z.array(picsumApiImageSchema).parse(json),
)
}

15
src/remote.ts Normal file
View file

@ -0,0 +1,15 @@
import type { Result } from "./result"
export type Remote<A, E> = { tag: "loading" } | Result<A, E>
export const Remote = {
loading(): Remote<never, never> {
return { tag: "loading" }
},
error<E>(error: E): Remote<never, E> {
return { tag: "error", error }
},
loaded<A>(value: A): Remote<A, never> {
return { tag: "ok", value }
},
}

71
src/request.ts Normal file
View file

@ -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<Result<unknown, RequestError>> {
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<A>(
url: string,
f: (json: unknown) => A,
): Promise<Result<A, RequestError>> {
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
}
}
}
}

18
src/result.ts Normal file
View file

@ -0,0 +1,18 @@
export type Result<A, E> = { tag: "ok"; value: A } | { tag: "error"; error: E }
export const Result = {
ok<A>(value: A): Result<A, never> {
return { tag: "ok", value }
},
err<E>(error: E): Result<never, E> {
return { tag: "error", error }
},
map<A, B, E>(result: Result<A, E>, f: (x: A) => B): Result<B, E> {
switch (result.tag) {
case "ok":
return { tag: "ok", value: f(result.value) }
case "error":
return { tag: "error", error: result.error }
}
},
}

414
src/ui/Picturarium.tsx Normal file
View file

@ -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<Result<ImageId[], RequestError>> {
const result = await getPicsumImages(page)
return Result.map(result, (data) => data.map(({ id }) => id))
}
// Use this for `<img src=... />`
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<ImageId[], AppError>
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<ImageId[], AppError>; 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<ImageId[]>` in case two requests are made really fast one after another (not really the case in this app so whatever)
const cacheRef = useRef<Map<PageKey, ImageId[]>>(new Map())
async function getImageIdsCached(page: Page): Promise<Result<ImageId[], AppError>> {
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<Dispatch | null>(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<ImageId[], AppError> }) {
const dispatch = useDispatch()
switch (images.tag) {
case "loading":
return <ImagesSkeleton />
case "error":
return (
<Box sx={{ position: "relative" }}>
<ImagesSkeleton visible={false} />
<Box sx={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
<ErrorView
error={images.error}
onRetry={() => {
dispatch({ tag: "retryButtonClicked" })
}}
/>
</Box>
</Box>
)
case "ok":
return <Images images={images.value} />
}
}
function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
textAlign: "center",
}}
>
<Typography variant="h6">Could not load images</Typography>
<Typography variant="body2" sx={{ color: "oklch(50% 0.02 var(--base-hue))" }}>
{RequestError.toString(error)}
</Typography>
<Button variant="outlined" onClick={onRetry}>
Retry
</Button>
</Box>
)
}
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 (
<Box sx={imageGridStyle(Dimension.medium)}>
{images.map((_, index) =>
visible ? (
<Skeleton
key={index}
animation="pulse"
variant="rectangular"
width={Dimension.medium.width}
height={Dimension.medium.height}
/>
) : (
<Box
key={index}
sx={{
width: Dimension.medium.width,
height: Dimension.medium.height,
}}
/>
),
)}
</Box>
)
}
function Images({ images }: { images: ImageId[] }) {
const dispatch = useDispatch()
return (
<Box sx={imageGridStyle(Dimension.medium)}>
{images.map((imageId) => (
<Image
imageId={imageId}
dimension={Dimension.medium}
onClick={() => {
dispatch({ tag: "imageClicked", imageId })
}}
key={imageId}
/>
))}
</Box>
)
}
function Image({
imageId,
dimension,
onClick,
}: {
imageId: ImageId
dimension: Dimension
onClick?: () => void
}) {
const [loaded, setLoaded] = useState(false)
return (
<Box
sx={{
position: "relative",
width: dimension.width,
height: dimension.height,
}}
>
{!loaded && (
<Skeleton
animation="pulse"
variant="rectangular"
width={dimension.width}
height={dimension.height}
/>
)}
<Box
component="img"
src={getImageSource(imageId, dimension)}
onLoad={() => {
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=""
/>
</Box>
)
}
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
const dispatch = useDispatch()
return (
<Dialog
open={selectedImage !== undefined}
onClose={() => {
dispatch({ tag: "modalCloseButtonClicked" })
}}
maxWidth={false}
>
<DialogContent sx={{ p: 0, overflow: "hidden" }}>
{selectedImage !== undefined && <Image imageId={selectedImage} dimension={Dimension.big} />}
</DialogContent>
</Dialog>
)
}
export default function Picturarium() {
const [state, dispatch] = useApp()
return (
<DispatchContext.Provider value={dispatch}>
<Box
component="main"
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography variant="h2">Picturarium</Typography>
<RemoteImages images={state.imageIds} />
<Box
sx={{
display: "flex",
flexDirection: "row",
gap: 2,
justifyContent: "center",
alignItems: "center",
}}
>
<Button
onClick={() => {
dispatch({ tag: "previousButtonClicked" })
}}
disabled={Page.isFirst(state.page)}
>
prev
</Button>
<Typography
variant="body1"
sx={{
minWidth: 60,
textAlign: "center",
color: "oklch(65% 0.02 260)",
}}
>
{state.page.page}
</Typography>
<Button
onClick={() => {
dispatch({ tag: "nextButtonClicked" })
}}
>
next
</Button>
</Box>
</Box>
<ImageModal selectedImage={state.selectedImage} />
</DispatchContext.Provider>
)
}