Add AI captions.

This commit is contained in:
Yura Dupyn 2026-05-15 14:45:06 +02:00
parent 945dcdead0
commit f3784e75f4
3 changed files with 201 additions and 28 deletions

33
src/api/caption.ts Normal file
View 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,
}),
})
}

View file

@ -24,10 +24,13 @@ export const RequestError = {
} }
// === Generic safe fetch functions === // === Generic safe fetch functions ===
export async function fetchJsonSafe(url: string): Promise<Result<unknown, RequestError>> { export async function fetchJsonSafe(
url: string,
init?: RequestInit,
): Promise<Result<unknown, RequestError>> {
let response: Response let response: Response
try { try {
response = await fetch(url) response = await fetch(url, init)
} catch (error: unknown) { } catch (error: unknown) {
return Result.err({ tag: "networkError", error }) return Result.err({ tag: "networkError", error })
} }
@ -52,8 +55,9 @@ export async function fetchJsonSafe(url: string): Promise<Result<unknown, Reques
export async function fetchJsonSafeWith<A>( export async function fetchJsonSafeWith<A>(
url: string, url: string,
f: (json: unknown) => A, f: (json: unknown) => A,
init?: RequestInit,
): Promise<Result<A, RequestError>> { ): Promise<Result<A, RequestError>> {
const result = await fetchJsonSafe(url) const result = await fetchJsonSafe(url, init)
switch (result.tag) { switch (result.tag) {
case "error": case "error":
return Result.err(result.error) return Result.err(result.error)

View file

@ -1,9 +1,19 @@
import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" 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 { getPicsumImages } from "../api/picsum"
import { Result } from "../result" import { Result } from "../result"
import { RequestError } from "../request" import { RequestError } from "../request"
import { Remote } from "../remote" import { Remote } from "../remote"
import { getCaption } from "../api/caption"
// @PERSONAL_NOTE // @PERSONAL_NOTE
// NOTE: There are multiple school of thoughts on how to structure a component. // 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)}` 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 === // === App ===
type AppError = RequestError type AppError = RequestError
@ -139,6 +167,7 @@ type State = {
imageIds: Remote<ImageId[], AppError> imageIds: Remote<ImageId[], AppError>
page: Page page: Page
selectedImage: ImageId | undefined 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. 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: "previousButtonClicked" }
| { tag: "nextButtonClicked" } | { tag: "nextButtonClicked" }
| { tag: "imageClicked"; imageId: ImageId } | { tag: "imageClicked"; imageId: ImageId }
// TODO: Move
// | { tag: "captionReceived"; forImage: ImageId; result: Result<string, AppError> }
| { tag: "modalCloseButtonClicked" } | { tag: "modalCloseButtonClicked" }
function useApp(): [State, Dispatch] { function useApp(): [State, Dispatch] {
@ -185,7 +216,9 @@ function useApp(): [State, Dispatch] {
} }
} }
// === initialization & reloading === // === effects: initialization & reloading ===
// == Page Refresh ==
useEffect( useEffect(
() => { () => {
void getImageIdsCached(state.page).then((result) => { void getImageIdsCached(state.page).then((result) => {
@ -197,6 +230,18 @@ function useApp(): [State, Dispatch] {
[state.page, state.refreshSignal], [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 { function update(state: State, msg: Msg): State {
switch (msg.tag) { switch (msg.tag) {
case "imagesReceived": case "imagesReceived":
@ -221,6 +266,18 @@ function useApp(): [State, Dispatch] {
case "imageClicked": { case "imageClicked": {
return { ...state, selectedImage: msg.imageId } 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": { case "modalCloseButtonClicked": {
return { ...state, selectedImage: undefined } return { ...state, selectedImage: undefined }
} }
@ -257,6 +314,7 @@ function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
<ImagesSkeleton visible={false} /> <ImagesSkeleton visible={false} />
<Box sx={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}> <Box sx={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
<ErrorView <ErrorView
title="Could not load images"
error={images.error} error={images.error}
onRetry={() => { onRetry={() => {
dispatch({ tag: "retryButtonClicked" }) dispatch({ tag: "retryButtonClicked" })
@ -270,28 +328,6 @@ function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
} }
} }
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) { function imageGridStyle(dimension: Dimension) {
return { return {
display: "grid", display: "grid",
@ -400,6 +436,7 @@ function Image({
} }
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
// TODO: Display captions
const dispatch = useDispatch() const dispatch = useDispatch()
return ( return (
<Dialog <Dialog
@ -410,12 +447,76 @@ function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
maxWidth={false} maxWidth={false}
> >
<DialogContent sx={{ p: 0, overflow: "hidden" }}> <DialogContent sx={{ p: 0, overflow: "hidden" }}>
{selectedImage !== undefined && <Image imageId={selectedImage} dimension={Dimension.big} />} {selectedImage !== undefined && <OpenImageModal imageId={selectedImage} />}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }
function OpenImageModal({ imageId }: { imageId: ImageId }) {
type State = {
caption: Remote<string, AppError>
}
const [state, setState] = useState<State>({ 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 (
<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() { export default function Picturarium() {
const [state, dispatch] = useApp() const [state, dispatch] = useApp()
@ -475,3 +576,38 @@ export default function Picturarium() {
</DispatchContext.Provider> </DispatchContext.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>
)
}