Cleanup of old code. Abstract out cache of remote requests. Cache for AI captions.

This commit is contained in:
Yura Dupyn 2026-05-15 15:53:15 +02:00
parent f3784e75f4
commit 8ea8fa6446
2 changed files with 125 additions and 109 deletions

32
src/cache.ts Normal file
View 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
}

View file

@ -1,4 +1,4 @@
import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react"
import { useReducer, useEffect, createContext, useContext, useState } from "react"
import {
Box,
Button,
@ -14,6 +14,7 @@ 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.
@ -188,39 +189,41 @@ type Msg =
| { tag: "previousButtonClicked" }
| { tag: "nextButtonClicked" }
| { tag: "imageClicked"; imageId: ImageId }
// TODO: Move
// | { tag: "captionReceived"; forImage: ImageId; result: Result<string, AppError> }
| { tag: "modalCloseButtonClicked" }
function useApp(): [State, Dispatch] {
// === 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 ===
// @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<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)
}
}
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 })
})
@ -231,17 +234,6 @@ function useApp(): [State, Dispatch] {
)
// == 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":
@ -266,18 +258,6 @@ 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 }
}
@ -286,7 +266,7 @@ function useApp(): [State, Dispatch] {
}
}
return [state, dispatch]
return [state, dispatch, getImageCaptionCached]
}
type Dispatch = (msg: Msg) => void
@ -436,7 +416,6 @@ function Image({
}
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
// TODO: Display captions
const dispatch = useDispatch()
return (
<Dialog
@ -459,21 +438,26 @@ function OpenImageModal({ imageId }: { imageId: ImageId }) {
}
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.
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 })
void getImageCaptionCached(imageId).then((caption) => {
if (isAlive) {
setState({ caption })
}
})
return () => {
isAlive = false
}
})
return () => {
isAlive = false
}
}, [imageId])
},
// 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} />
@ -518,66 +502,67 @@ function CaptionView({ caption }: { caption: Remote<string, AppError> }) {
}
export default function Picturarium() {
const [state, dispatch] = useApp()
const [state, dispatch, getImageCaptionCached] = 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} />
<ImageCaptionCacheContext.Provider value={getImageCaptionCached}>
<DispatchContext.Provider value={dispatch}>
<Box
component="main"
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "row",
gap: 2,
justifyContent: "center",
flexDirection: "column",
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>
<Typography variant="h2">Picturarium</Typography>
<ImageModal selectedImage={state.selectedImage} />
</DispatchContext.Provider>
<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,
@ -610,4 +595,3 @@ function ErrorView({
</Box>
)
}