diff --git a/src/api/caption.ts b/src/api/caption.ts new file mode 100644 index 0000000..531f5d4 --- /dev/null +++ b/src/api/caption.ts @@ -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 + +export type GetCaptionParams = { + imageUrl: string + maxWords: number +} + +export async function getCaption({ + imageUrl, + maxWords, +}: GetCaptionParams): Promise> { + return fetchJsonSafeWith(CAPTION_API_URL, (json) => captionResponseSchema.parse(json), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + imageUrl, + maxWords, + }), + }) +} diff --git a/src/request.ts b/src/request.ts index 450727b..c58c989 100644 --- a/src/request.ts +++ b/src/request.ts @@ -24,10 +24,13 @@ export const RequestError = { } // === Generic safe fetch functions === -export async function fetchJsonSafe(url: string): Promise> { +export async function fetchJsonSafe( + url: string, + init?: RequestInit, +): Promise> { let response: Response try { - response = await fetch(url) + response = await fetch(url, init) } catch (error: unknown) { return Result.err({ tag: "networkError", error }) } @@ -52,8 +55,9 @@ export async function fetchJsonSafe(url: string): Promise( url: string, f: (json: unknown) => A, + init?: RequestInit, ): Promise> { - const result = await fetchJsonSafe(url) + const result = await fetchJsonSafe(url, init) switch (result.tag) { case "error": return Result.err(result.error) diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx index e9c812f..8b685c5 100644 --- a/src/ui/Picturarium.tsx +++ b/src/ui/Picturarium.tsx @@ -1,9 +1,19 @@ import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" -import { Box, Button, Typography, Skeleton, Dialog, DialogContent } from "@mui/material" +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" // @PERSONAL_NOTE // NOTE: There are multiple school of thoughts on how to structure a component. @@ -132,6 +142,24 @@ function getImageSource(id: ImageId, dimension: Dimension): ImageRef { return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}` } +// = captions = +const CAPTION_MAX_WORDS = 25 + +function getCaptionImageUrl(imageId: ImageId): ImageRef { + const dimension: Dimension = { width: 100, height: 100 } + + return `https://picsum.photos/id/${imageId}/${String(dimension.width)}/${String(dimension.height)}` +} + +async function getImageCaption(imageId: ImageId): Promise> { + const result = await getCaption({ + imageUrl: getCaptionImageUrl(imageId), + maxWords: CAPTION_MAX_WORDS, + }) + + return Result.map(result, ({ description }) => description) +} + // === App === type AppError = RequestError @@ -139,6 +167,7 @@ type State = { imageIds: Remote page: Page selectedImage: ImageId | undefined + refreshSignal: boolean // This is here only because of the refresh button. It is an artificial way to trigger a reload without the current page changing. The value is meaningless. It just has to change. } @@ -159,6 +188,8 @@ type Msg = | { tag: "previousButtonClicked" } | { tag: "nextButtonClicked" } | { tag: "imageClicked"; imageId: ImageId } + // TODO: Move + // | { tag: "captionReceived"; forImage: ImageId; result: Result } | { tag: "modalCloseButtonClicked" } function useApp(): [State, Dispatch] { @@ -185,7 +216,9 @@ function useApp(): [State, Dispatch] { } } - // === initialization & reloading === + // === effects: initialization & reloading === + + // == Page Refresh == useEffect( () => { void getImageIdsCached(state.page).then((result) => { @@ -197,6 +230,18 @@ function useApp(): [State, Dispatch] { [state.page, state.refreshSignal], ) + // == AI Captions == + // TODO: MOVE + // useEffect(() => { + // if (state.selectedImage !== undefined) { + // const imageId = state.selectedImage.imageId + // void getImageCaption(imageId).then((result) => { + // dispatch({ tag: "captionReceived", forImage: imageId, result }) + // }) + // // TODO: Need to fetch the image (with some url of smaller dimensions) + // } + // }, [state.selectedImage?.imageId]) + function update(state: State, msg: Msg): State { switch (msg.tag) { case "imagesReceived": @@ -221,6 +266,18 @@ function useApp(): [State, Dispatch] { case "imageClicked": { return { ...state, selectedImage: msg.imageId } } + // TODO: move + // case "captionReceived": { + // if (state.selectedImage !== undefined && state.selectedImage.imageId === msg.forImage) { + // const modalState: OpenImageModalState = { + // ...state.selectedImage, + // caption: msg.result, + // } + // return { ...state, selectedImage: modalState } + // } else { + // return state + // } + // } case "modalCloseButtonClicked": { return { ...state, selectedImage: undefined } } @@ -257,6 +314,7 @@ function RemoteImages({ images }: { images: Remote }) { { dispatch({ tag: "retryButtonClicked" }) @@ -270,28 +328,6 @@ function RemoteImages({ images }: { images: Remote }) { } } -function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) { - return ( - - Could not load images - - {RequestError.toString(error)} - - - - ) -} - function imageGridStyle(dimension: Dimension) { return { display: "grid", @@ -400,6 +436,7 @@ function Image({ } function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { + // TODO: Display captions const dispatch = useDispatch() return ( - {selectedImage !== undefined && } + {selectedImage !== undefined && } ) } +function OpenImageModal({ imageId }: { imageId: ImageId }) { + type State = { + caption: Remote + } + + const [state, setState] = useState({ caption: Remote.loading() }) + + useEffect(() => { + let isAlive = true + // TODO: Do I need to set `Remote.loading()` here? I don't like that. + + void getImageCaption(imageId).then((caption) => { + if (isAlive) { + setState({ caption }) + } + }) + + return () => { + isAlive = false + } + }, [imageId]) + return ( + + + + + ) +} + +function CaptionView({ caption }: { caption: Remote }) { + switch (caption.tag) { + case "loading": + return ( + + + + ) + case "error": + return ( + + + + ) + case "ok": + return ( + + + {caption.value} + + + ) + } +} + export default function Picturarium() { const [state, dispatch] = useApp() @@ -475,3 +576,38 @@ export default function Picturarium() { ) } + + +// === Error view === +function ErrorView({ + title, + error, + onRetry, +}: { + title?: string + error: AppError + onRetry?: () => void +}) { + return ( + + {title} + + {RequestError.toString(error)} + + {onRetry !== undefined && ( + + )} + + ) +} +