Reorganization. Split remote/result/request. Split picsum specific api.
This commit is contained in:
parent
5f9bdd5511
commit
c3c629943c
6 changed files with 550 additions and 532 deletions
535
src/App.tsx
535
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<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>
|
||||
)
|
||||
}
|
||||
|
||||
import Picturarium from "./ui/Picturarium"
|
||||
import { CssBaseline } from "@mui/material"
|
||||
export default function App() {
|
||||
const [state, dispatch] = useApp()
|
||||
|
||||
return (
|
||||
<>
|
||||
<CssBaseline />
|
||||
<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>
|
||||
<Picturarium />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
29
src/api/picsum.ts
Normal file
29
src/api/picsum.ts
Normal 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
15
src/remote.ts
Normal 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
71
src/request.ts
Normal 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
18
src/result.ts
Normal 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
414
src/ui/Picturarium.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue