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 ===
|
// === 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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue