-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAppImage.tsx
More file actions
330 lines (302 loc) · 9.45 KB
/
Copy pathAppImage.tsx
File metadata and controls
330 lines (302 loc) · 9.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
/**
* AppImage — image renderer for R2/CDN-hosted assets.
*
* Renders a standard <img> pointing at the stored CDN URL. When an eagerly
* generated crop exists (`croppedUrl`, materialized by the backend
* generate_cropped_image task), it is preferred; until the task lands the
* raw original renders instead. No request-time transforms exist — the URL
* is served as-is.
*
* Context-specific chrome:
* thumbnail — 64×64 box, used in image lists and history rows
* gallery — fills the local container, used in the Piece photo gallery grid
* lightbox — fit-content box, for the full-screen viewer
* detail — fills the local container, for the PieceDetail hero image
* preview — 64×64 box, used for the upload preview before saving
*/
import { Box, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState, Suspense } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import type { ImageCrop } from "../util/types";
const THUMBNAIL_SIZE = 64;
export type AppImageContext =
| "thumbnail"
| "gallery"
| "lightbox"
| "detail"
| "preview";
/** Prefer the materialized crop; fall back to the original until it exists. */
function resolveImageUrl(url: string, croppedUrl?: string | null): string {
return croppedUrl?.trim() || url;
}
const imageLoadQueryOptions = (url: string) => ({
queryKey: ["image-load", url],
queryFn: () =>
new Promise<string>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
img.src = url;
}),
staleTime: Infinity,
});
function useSuspendedImageLoad(url: string) {
return useSuspenseQuery(imageLoadQueryOptions(url));
}
export type AppImageProps = {
/** Original delivery URL — always required as fallback. */
url: string;
/** CDN URL of the eagerly generated crop; null until the task completes. */
croppedUrl?: string | null;
alt?: string;
/** Rendering context determines the wrapper chrome. */
context: AppImageContext;
/** Crop coordinates — used only for skeleton aspect estimation. */
crop?: ImageCrop | null;
/**
* True when the image is stored in R2 and eligible for crop materialization.
* When falsy, a pending crop (crop set, croppedUrl null) renders the original
* instead of an indefinite skeleton — non-R2 images can never get a cropped_url.
*/
r2Key?: string | null;
/**
* True when the backend generate_cropped_image task failed. The skeleton is
* replaced by the original image so the UI doesn't spin forever.
*/
cropTaskFailed?: boolean;
style?: React.CSSProperties;
className?: string;
onLoad?: React.ReactEventHandler<HTMLImageElement>;
/** data-testid forwarded to the underlying <img>. */
"data-testid"?: string;
};
/**
* Outer component: handles the crop-pending guard without any hooks, so the
* Rules of Hooks are satisfied — the early return comes before any hook calls.
*
* Shows a skeleton only when the crop is genuinely pending (R2-backed, task not
* failed). Non-R2 images and failed tasks fall through to AppImageRenderer so
* the original image renders instead of an indefinite spinner.
*/
export default function AppImage(props: AppImageProps) {
const cropPending =
!!props.crop &&
!props.croppedUrl?.trim() &&
!!props.r2Key &&
!props.cropTaskFailed;
if (cropPending) {
return <ImageSkeleton context={props.context} crop={props.crop} />;
}
return <AppImageRenderer {...props} />;
}
function AppImageRenderer({
url,
croppedUrl,
alt = "",
context,
style,
className,
onLoad,
"data-testid": testId,
}: AppImageProps) {
const [isLoading, setIsLoading] = useState(true);
const imageRef = useRef<HTMLImageElement | null>(null);
const resolvedUrl = resolveImageUrl(url, croppedUrl);
// Reset loading state when the image source changes. Storing the previous key
// in state (not a ref) is the React-documented pattern for deriving state from
// props during render — refs must not be read during render.
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const currentKey = `${resolvedUrl}__${context}`;
const [prevKey, setPrevKey] = useState(currentKey);
if (prevKey !== currentKey) {
setPrevKey(currentKey);
setIsLoading(true);
}
useEffect(() => {
function syncLoadedStateFromDom() {
const image = imageRef.current;
if (image?.complete && image.naturalWidth > 0) {
setIsLoading(false);
}
}
syncLoadedStateFromDom();
window.addEventListener("pageshow", syncLoadedStateFromDom);
document.addEventListener("visibilitychange", syncLoadedStateFromDom);
return () => {
window.removeEventListener("pageshow", syncLoadedStateFromDom);
document.removeEventListener("visibilitychange", syncLoadedStateFromDom);
};
}, [resolvedUrl]);
function handleLoad(event: React.SyntheticEvent<HTMLImageElement>) {
setIsLoading(false);
onLoad?.(event);
}
function handleError() {
setIsLoading(false);
}
const wrapperStyle: React.CSSProperties =
context === "lightbox"
? {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "fit-content",
height: "fit-content",
}
: context === "detail"
? {
position: "relative",
display: "block",
width: "100%",
height: "100%",
}
: context === "gallery"
? {
position: "relative",
display: "block",
width: "100%",
height: "100%",
}
: {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
flexShrink: 0,
};
const spinnerStyle: React.CSSProperties = {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
};
const contextImageStyle: React.CSSProperties =
context === "lightbox"
? { maxWidth: "90vw", maxHeight: "80vh", objectFit: "contain" }
: context === "gallery" || context === "detail"
? { width: "100%", height: "100%", objectFit: "cover" }
: { width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, objectFit: "cover" };
const imageStyle: React.CSSProperties = {
...contextImageStyle,
...style,
opacity: isLoading ? 0 : 1,
};
return (
<Box style={wrapperStyle}>
{isLoading && (
<Box style={spinnerStyle}>
<CircularProgress size={24} />
</Box>
)}
<img
ref={imageRef}
src={resolvedUrl}
alt={alt}
style={imageStyle}
className={className}
onLoad={handleLoad}
onError={handleError}
data-testid={testId}
role="img"
/>
</Box>
);
}
export function ImageSkeleton({
context,
crop,
aspectRatio,
}: {
context: AppImageContext;
crop?: ImageCrop | null;
aspectRatio?: number | null;
}) {
const aspect = aspectRatio ?? (crop ? crop.width / crop.height : 4 / 3);
if (context === "lightbox") {
return (
<Box
sx={{
aspectRatio: aspect,
maxWidth: "90vw",
maxHeight: "80vh",
width: "90vw",
borderRadius: "4px",
bgcolor: "rgba(255,255,255,0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress sx={{ color: "white" }} />
</Box>
);
}
if (context === "detail") {
return (
<Box
sx={{
width: "100%",
height: "100%",
minHeight: { xs: 200, sm: 260 },
aspectRatio: { md: "4 / 3" },
bgcolor: "rgba(255,255,255,0.06)",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={24} />
</Box>
);
}
// Fallback for gallery/thumbnail/preview
return (
<Box
sx={{
width: "100%",
height: "100%",
aspectRatio: aspect,
bgcolor: "rgba(255,255,255,0.06)",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={16} />
</Box>
);
}
function InnerSuspenseAppImage(props: AppImageProps) {
useSuspendedImageLoad(resolveImageUrl(props.url, props.croppedUrl));
return <AppImage {...props} />;
}
export type SuspenseAppImageProps = AppImageProps & {
fallback?: React.ReactNode;
};
export function SuspenseAppImage({ fallback, ...props }: SuspenseAppImageProps) {
// When a crop is genuinely pending (R2-backed, task not failed), AppImage
// renders the skeleton itself — no URL to preload yet, so skip Suspense.
const cropPending =
!!props.crop &&
!props.croppedUrl?.trim() &&
!!props.r2Key &&
!props.cropTaskFailed;
if (cropPending) {
return fallback ?? <ImageSkeleton context={props.context} crop={props.crop} />;
}
const defaultFallback = fallback ?? (
<ImageSkeleton context={props.context} crop={props.crop} />
);
return (
<Suspense fallback={defaultFallback}>
<InnerSuspenseAppImage {...props} />
</Suspense>
);
}