Add AI captions.
This commit is contained in:
parent
945dcdead0
commit
f3784e75f4
3 changed files with 201 additions and 28 deletions
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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -24,10 +24,13 @@ export const RequestError = {
|
|||
}
|
||||
|
||||
// === 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
|
||||
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<Result<unknown, Reques
|
|||
export async function fetchJsonSafeWith<A>(
|
||||
url: string,
|
||||
f: (json: unknown) => A,
|
||||
init?: RequestInit,
|
||||
): Promise<Result<A, RequestError>> {
|
||||
const result = await fetchJsonSafe(url)
|
||||
const result = await fetchJsonSafe(url, init)
|
||||
switch (result.tag) {
|
||||
case "error":
|
||||
return Result.err(result.error)
|
||||
|
|
|
|||
|
|
@ -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<Result<string, RequestError>> {
|
||||
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<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.
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +188,8 @@ 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] {
|
||||
|
|
@ -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<ImageId[], AppError> }) {
|
|||
<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" })
|
||||
|
|
@ -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) {
|
||||
return {
|
||||
display: "grid",
|
||||
|
|
@ -400,6 +436,7 @@ function Image({
|
|||
}
|
||||
|
||||
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
|
||||
// TODO: Display captions
|
||||
const dispatch = useDispatch()
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -410,12 +447,76 @@ function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
|
|||
maxWidth={false}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, overflow: "hidden" }}>
|
||||
{selectedImage !== undefined && <Image imageId={selectedImage} dimension={Dimension.big} />}
|
||||
{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() })
|
||||
|
||||
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() {
|
||||
const [state, dispatch] = useApp()
|
||||
|
||||
|
|
@ -475,3 +576,38 @@ export default function Picturarium() {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue