Compare commits
10 commits
e9cf90180f
...
8ea8fa6446
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea8fa6446 | ||
|
|
f3784e75f4 | ||
|
|
945dcdead0 | ||
|
|
24334cb342 | ||
|
|
65fe6a9a82 | ||
|
|
c3c629943c | ||
|
|
5f9bdd5511 | ||
|
|
65fd45fc16 | ||
|
|
9138a187a9 | ||
|
|
ca1a2bf1ca |
17 changed files with 888 additions and 310 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use nix
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,3 +10,5 @@ yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
.direnv
|
||||||
|
|
|
||||||
21
AI-USE.md
Normal file
21
AI-USE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
Wrote most of the code by hand.
|
||||||
|
|
||||||
|
My own design:
|
||||||
|
|
||||||
|
- config/state/msg/update/effects, all the types
|
||||||
|
- which hooks to use
|
||||||
|
- remote/request/result
|
||||||
|
- splitting the codebase into modules/folders/files
|
||||||
|
|
||||||
|
LLM-generated:
|
||||||
|
where I mostly reviewed
|
||||||
|
|
||||||
|
- The Material UI / styling - after using a few components manually, I mostly let LLM decide which components to use, how to use them, and letting it decide/generate styling details.
|
||||||
|
|
||||||
|
LLM-assisted:
|
||||||
|
|
||||||
|
- Name suggestions
|
||||||
|
- Project setup (linters, formatters, ts compiler settings)
|
||||||
|
- Checking and Code Review
|
||||||
|
- Snippet generation
|
||||||
|
- Explanations of external API details, and confirming browser caching behaviour
|
||||||
47
README.md
Normal file
47
README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Picturarium
|
||||||
|
|
||||||
|
Picturarium is a small React image gallery using the Lorem Picsum API. It displays paginated image thumbnails, caches loaded pages during the session, and opens selected images in a larger
|
||||||
|
modal view. The app uses TypeScript, Material UI, and explicit remote/request state handling for loading and error states.
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
This assumes `node` is installed on your system.
|
||||||
|
TODO: specify `git clone` too here.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone // TODO
|
||||||
|
cd picturarium
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
# Execution
|
||||||
|
|
||||||
|
After cloning the repo in `picturarium/`:
|
||||||
|
|
||||||
|
## dev
|
||||||
|
|
||||||
|
You can start a vite dev-server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## production
|
||||||
|
|
||||||
|
To see production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
# Comments
|
||||||
|
|
||||||
|
In this codebase I sometimes use comments that start as
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// @PERSONAL_NOTE
|
||||||
|
```
|
||||||
|
|
||||||
|
These are just there for this particular assignment, and I wouldn't included them in a real codebase.
|
||||||
|
They are just notes to let you know why I made specific decisions and how I think about stuff.
|
||||||
|
|
@ -34,6 +34,7 @@ export default tseslint.config(
|
||||||
"error",
|
"error",
|
||||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/consistent-type-definitions": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
7
shell.nix
Normal file
7
shell.nix
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{ pkgs ? import <nixpkgs> {} }:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
];
|
||||||
|
}
|
||||||
274
src/App.tsx
274
src/App.tsx
|
|
@ -1,272 +1,10 @@
|
||||||
import { z } from "zod"
|
import Picturarium from "./ui/Picturarium"
|
||||||
import { useReducer, useRef, useEffect, createContext, useContext } from "react"
|
import { CssBaseline } from "@mui/material"
|
||||||
|
|
||||||
// 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<typeof picsumApiImageSchema>
|
|
||||||
|
|
||||||
async function getPicsumImages({ page, limit }: Page): Promise<PicsumApiImage[]> {
|
|
||||||
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<Image[]> {
|
|
||||||
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<ImageId[]> {
|
|
||||||
const data = await getPicsumImages(page)
|
|
||||||
return data.map(({ id }) => id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use this for `<img src=... />`
|
|
||||||
function getImageSource(id: ImageId, dimension: Dimension): ImageRef {
|
|
||||||
return `https://picsum.photos/id/${id}/${dimension.width}/${dimension.height}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Generic Remote Data ===
|
|
||||||
type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
|
|
||||||
|
|
||||||
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: "loaded", value }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// === App ===
|
|
||||||
// TODO: introduce proper error type
|
|
||||||
type AppError = string
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
imageIds: Remote<ImageId[], AppError>
|
|
||||||
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<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<ImageId[]> {
|
|
||||||
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<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> }) {
|
|
||||||
switch (images.tag) {
|
|
||||||
case "loading":
|
|
||||||
return <div>Loading...</div>
|
|
||||||
case "error":
|
|
||||||
return <div>Error: TODO</div>
|
|
||||||
case "loaded":
|
|
||||||
return <Images images={images.value} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Images({ images }: { images: ImageId[] }) {
|
|
||||||
const dispatch = useDispatch()
|
|
||||||
// TODO: Is there some basic `Col/Row` component?
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
||||||
{images.map((imageId) => (
|
|
||||||
<img
|
|
||||||
style={{ width: Dimension.medium.width, height: Dimension.medium.height }}
|
|
||||||
key={imageId}
|
|
||||||
src={getImageSource(imageId, Dimension.medium)}
|
|
||||||
onClick={() => dispatch({ tag: "imageClicked", imageId })}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [state, dispatch] = useApp()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DispatchContext.Provider value={dispatch}>
|
<>
|
||||||
<main>
|
<CssBaseline />
|
||||||
<h1>Picturarium</h1>
|
<Picturarium />
|
||||||
<RemoteImages images={state.imageIds} />
|
</>
|
||||||
<button onClick={() => dispatch({ tag: "previousButtonClicked" })}>prev</button>
|
|
||||||
<button onClick={() => dispatch({ tag: "nextButtonClicked" })}>next</button>
|
|
||||||
</main>
|
|
||||||
</DispatchContext.Provider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/api/caption.ts
Normal file
33
src/api/caption.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
import { fetchJsonSafeWith } from "../request"
|
||||||
|
import type { RequestError } from "../request"
|
||||||
|
import type { Result } from "../result"
|
||||||
|
|
||||||
|
const CAPTION_API_URL = "https://picsum-caption-worker.meatbagoverclocked.workers.dev/"
|
||||||
|
|
||||||
|
const captionResponseSchema = z.object({
|
||||||
|
description: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CaptionResponse = z.infer<typeof captionResponseSchema>
|
||||||
|
|
||||||
|
export type GetCaptionParams = {
|
||||||
|
imageUrl: string
|
||||||
|
maxWords: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCaption({
|
||||||
|
imageUrl,
|
||||||
|
maxWords,
|
||||||
|
}: GetCaptionParams): Promise<Result<CaptionResponse, RequestError>> {
|
||||||
|
return fetchJsonSafeWith(CAPTION_API_URL, (json) => captionResponseSchema.parse(json), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl,
|
||||||
|
maxWords,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/cache.ts
Normal file
32
src/cache.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useRef } from "react"
|
||||||
|
import type { RequestError } from "./request"
|
||||||
|
import { Result } from "./result"
|
||||||
|
|
||||||
|
// KeyHash is supposed to be something where `===` compares for structural equality (so in JS: number, string).
|
||||||
|
export type KeyHash = string | number
|
||||||
|
export function useCache<Key, Value>(
|
||||||
|
hash: (key: Key) => KeyHash,
|
||||||
|
f: (k: Key) => Promise<Result<Value, RequestError>>,
|
||||||
|
) {
|
||||||
|
// @PERSONAL_NOTE
|
||||||
|
// 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<KeyHash, Value>>(new Map())
|
||||||
|
|
||||||
|
async function getCached(key: Key): Promise<Result<Value, RequestError>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/* === Reset === */
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === main === */
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client"
|
import { createRoot } from "react-dom/client"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
import "./index.css"
|
import "./style.css"
|
||||||
|
|
||||||
const rootElement = document.getElementById("root")
|
const rootElement = document.getElementById("root")
|
||||||
|
|
||||||
|
|
|
||||||
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 }
|
||||||
|
},
|
||||||
|
}
|
||||||
75
src/request.ts
Normal file
75
src/request.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
import { Result } from "./result"
|
||||||
|
|
||||||
|
// === Request Errors ===
|
||||||
|
export type RequestError =
|
||||||
|
| { tag: "networkError"; error: unknown }
|
||||||
|
| { tag: "httpError"; status: number; statusText: string }
|
||||||
|
| { tag: "jsonParseError"; error: unknown }
|
||||||
|
| { tag: "invalidResponse"; error: z.ZodError }
|
||||||
|
|
||||||
|
export const RequestError = {
|
||||||
|
toString(error: RequestError): string {
|
||||||
|
switch (error.tag) {
|
||||||
|
case "networkError":
|
||||||
|
return "Network error. Check your connection and try again."
|
||||||
|
case "httpError":
|
||||||
|
return `Server returned ${String(error.status)} ${error.statusText}.`
|
||||||
|
case "jsonParseError":
|
||||||
|
return "The server returned an invalid response."
|
||||||
|
case "invalidResponse":
|
||||||
|
return "The image data had an unexpected format."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Generic safe fetch functions ===
|
||||||
|
export async function fetchJsonSafe(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Result<unknown, RequestError>> {
|
||||||
|
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<A>(
|
||||||
|
url: string,
|
||||||
|
f: (json: unknown) => A,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Result<A, RequestError>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
3
src/style.css
Normal file
3
src/style.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
:root {
|
||||||
|
--base-hue: 260;
|
||||||
|
}
|
||||||
597
src/ui/Picturarium.tsx
Normal file
597
src/ui/Picturarium.tsx
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
import { useReducer, useEffect, createContext, useContext, useState } from "react"
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Skeleton,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
CircularProgress,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material"
|
||||||
|
import { getPicsumImages } from "../api/picsum"
|
||||||
|
import { Result } from "../result"
|
||||||
|
import { RequestError } from "../request"
|
||||||
|
import { Remote } from "../remote"
|
||||||
|
import { getCaption } from "../api/caption"
|
||||||
|
import { useCache } from "../cache"
|
||||||
|
|
||||||
|
// @PERSONAL_NOTE
|
||||||
|
// NOTE: There are multiple school of thoughts on how to structure a component.
|
||||||
|
// - What I prefer is to extract generic components or complex sub-components into their own modules (but this assignment is too simple for that).
|
||||||
|
// I also like to keep views/states/messages/initialization/effects/update of one component in one single (even if giant) file (or a structure like `Component/...` + `Component.tsx`)
|
||||||
|
// - Others school of thought are to grind all of these into fine dust and scatter the files all over the codebase (all state definitions in one giant folder, all messages/updates in another). I prefer not doing that. It violates locality too much for me, and I constantly have to jump between files "far-away".
|
||||||
|
|
||||||
|
// === Config ===
|
||||||
|
// prefered number of cols/rows. The page size (how many images are on a page) is calculated as `columns * rows`
|
||||||
|
type Config = {
|
||||||
|
pageSize: number
|
||||||
|
|
||||||
|
mobileColumns: number
|
||||||
|
mobileRows: number
|
||||||
|
|
||||||
|
desktopColumns: number
|
||||||
|
desktopRows: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
pageSize: 10,
|
||||||
|
|
||||||
|
mobileColumns: 2,
|
||||||
|
mobileRows: 5,
|
||||||
|
|
||||||
|
desktopColumns: 5,
|
||||||
|
desktopRows: 2,
|
||||||
|
} as const satisfies Config
|
||||||
|
// const CONFIG = {
|
||||||
|
// pageSize: 12,
|
||||||
|
|
||||||
|
// mobileColumns: 2,
|
||||||
|
// mobileRows: 6,
|
||||||
|
|
||||||
|
// desktopColumns: 4,
|
||||||
|
// desktopRows: 3,
|
||||||
|
// } as const satisfies Config
|
||||||
|
|
||||||
|
// @PERSONAL_NOTE
|
||||||
|
// Could do comptime assert: `size = mobile product`, and `size = desktop product`, but this is pretty heavy in TS.
|
||||||
|
assertValidConfig(CONFIG)
|
||||||
|
|
||||||
|
function assertValidConfig(config: Config): void {
|
||||||
|
const mobilePageSize = config.mobileColumns * config.mobileRows
|
||||||
|
const desktopPageSize = config.desktopColumns * config.desktopRows
|
||||||
|
|
||||||
|
if (mobilePageSize !== config.pageSize) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid config: mobile grid defines ${String(mobilePageSize)} images, but pageSize is ${String(config.pageSize)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desktopPageSize !== config.pageSize) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid config: desktop grid defines ${String(desktopPageSize)} images, but pageSize is ${String(config.pageSize)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Image ===
|
||||||
|
type ImageId = string
|
||||||
|
type ImageRef = string
|
||||||
|
|
||||||
|
// === Dimension ===
|
||||||
|
type Dimension = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
const Dimension = {
|
||||||
|
medium: { width: 180, height: 180 },
|
||||||
|
big: { width: 720, height: 720 },
|
||||||
|
} satisfies {
|
||||||
|
medium: Dimension
|
||||||
|
big: Dimension
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Page ===
|
||||||
|
type PageIndex = number
|
||||||
|
|
||||||
|
// @PERSONAL_NOTE
|
||||||
|
// This is a huge overkill for this type of application - especially because page size is known at compile-time and we can't even change page size at runtime.
|
||||||
|
// Most of the identity issues down-the line would be trivial if we just used `PageIndex` instead (which is a nice immutable value where equality is structural).
|
||||||
|
// But in general there's no avoiding objects in js/ts, so during code-review this would allow me to demonstrate understanding of these subtle issues. That's why I chose to use it here.
|
||||||
|
type Page = {
|
||||||
|
index: PageIndex
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
type PageKey = string
|
||||||
|
|
||||||
|
const FIRST_PAGE: PageIndex = 1
|
||||||
|
|
||||||
|
const Page = {
|
||||||
|
init(): Page {
|
||||||
|
return { index: FIRST_PAGE, size: CONFIG.pageSize }
|
||||||
|
},
|
||||||
|
next(page: Page): Page {
|
||||||
|
return { ...page, index: page.index + 1 }
|
||||||
|
},
|
||||||
|
previous(page: Page): Page {
|
||||||
|
// @PERSONAL_NOTE
|
||||||
|
// Could do
|
||||||
|
// if (page.index == FIRST_PAGE) { return page }
|
||||||
|
// this preserves identity of object, so is nicer for `useEffect`, but it's waaaay to subtle. So I'm not relying on that.
|
||||||
|
return { ...page, index: Math.max(FIRST_PAGE, page.index - 1) }
|
||||||
|
},
|
||||||
|
eq(page0: Page, page1: Page): boolean {
|
||||||
|
return page0.index === page1.index && page0.size === page1.size
|
||||||
|
},
|
||||||
|
// for hashing in maps to avoid identity problems
|
||||||
|
key(page: Page): PageKey {
|
||||||
|
return `${String(page.index)}:${String(page.size)}`
|
||||||
|
},
|
||||||
|
isFirst(page: Page): boolean {
|
||||||
|
return page.index === FIRST_PAGE
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// === api ===
|
||||||
|
async function getImageIds(page: Page): Promise<Result<ImageId[], RequestError>> {
|
||||||
|
const result = await getPicsumImages({ page: page.index, limit: page.size })
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// = 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<Result<string, RequestError>> {
|
||||||
|
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<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" }
|
||||||
|
|
||||||
|
// === Caching Context for AI captions ===
|
||||||
|
type GetImageCaptionCached = (imageId: ImageId) => Promise<Result<string, AppError>>
|
||||||
|
|
||||||
|
const ImageCaptionCacheContext = createContext<GetImageCaptionCached | null>(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<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
|
||||||
|
title="Could not load images"
|
||||||
|
error={images.error}
|
||||||
|
onRetry={() => {
|
||||||
|
dispatch({ tag: "retryButtonClicked" })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
case "ok":
|
||||||
|
return <Images images={images.value} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 && <OpenImageModal imageId={selectedImage} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenImageModal({ imageId }: { imageId: ImageId }) {
|
||||||
|
type State = {
|
||||||
|
caption: Remote<string, AppError>
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, setState] = useState<State>({ 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 (
|
||||||
|
<Box>
|
||||||
|
<Image imageId={imageId} dimension={Dimension.big} />
|
||||||
|
<CaptionView caption={state.caption} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaptionView({ caption }: { caption: Remote<string, AppError> }) {
|
||||||
|
switch (caption.tag) {
|
||||||
|
case "loading":
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "grid", placeItems: "center", p: 2 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
case "error":
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<ErrorView title="Could not load caption" error={caption.error} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
case "ok":
|
||||||
|
return (
|
||||||
|
<Tooltip title={caption.value}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
maxWidth: Dimension.big.width,
|
||||||
|
p: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caption.value}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Picturarium() {
|
||||||
|
const [state, dispatch, getImageCaptionCached] = useApp()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImageCaptionCacheContext.Provider value={getImageCaptionCached}>
|
||||||
|
<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.index}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
dispatch({ tag: "nextButtonClicked" })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
next
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ImageModal selectedImage={state.selectedImage} />
|
||||||
|
</DispatchContext.Provider>
|
||||||
|
</ImageCaptionCacheContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Error view ===
|
||||||
|
function ErrorView({
|
||||||
|
title,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
error: AppError
|
||||||
|
onRetry?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">{title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "oklch(50% 0.02 var(--base-hue))" }}>
|
||||||
|
{RequestError.toString(error)}
|
||||||
|
</Typography>
|
||||||
|
{onRetry !== undefined && (
|
||||||
|
<Button variant="outlined" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue