Skip to content

Commit 6c1fe5c

Browse files
authored
fix(admin): load resized thumbnails in media library and picker (#1488) (#1507)
The media library, library list, and picker modal loaded every grid item's full-size original through the media proxy, so opening the library and searching for older items waited on full-resolution downloads (1 min+ on large libraries). Route same-origin raster images through Astro's runtime image endpoint (/_image) to request a small resized rendition instead. Where no runtime image service transforms (passthrough config, or the endpoint rejects the request) the original is served as before, so nothing renders worse. Items with unknown dimensions still load the original so onLoad can backfill true dimensions rather than the thumbnail's.
1 parent 5de7559 commit 6c1fe5c

5 files changed

Lines changed: 159 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
Speeds up browsing and searching large media libraries. The media library and the media picker now load small resized thumbnails through Astro's image endpoint instead of fetching every grid item's full-size original, so opening the library and searching for older items no longer waits on full-resolution downloads ([#1488](https://github.com/emdash-cms/emdash/issues/1488)). Where no runtime image service is available the original is served as before, so nothing renders worse than it did.

packages/admin/src/components/MediaLibrary.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ import {
1515
uploadToProvider,
1616
} from "../lib/api";
1717
import { useDebouncedValue } from "../lib/hooks.js";
18-
import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils";
18+
import {
19+
providerItemToMediaItem,
20+
getFileIcon,
21+
formatFileSize,
22+
getMediaThumbnailUrl,
23+
fallbackToOriginalThumbnail,
24+
MEDIA_THUMBNAIL_WIDTH,
25+
} from "../lib/media-utils";
1926
import { cn } from "../lib/utils";
2027
import { MediaDetailPanel } from "./MediaDetailPanel";
2128

@@ -576,9 +583,10 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
576583
<div className="aspect-square">
577584
{isImage ? (
578585
<img
579-
src={item.url}
586+
src={getMediaThumbnailUrl(item.url, item.mimeType, MEDIA_THUMBNAIL_WIDTH)}
580587
alt={item.alt || item.filename}
581588
className="h-full w-full object-cover"
589+
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
582590
/>
583591
) : (
584592
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
@@ -669,9 +677,10 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
669677
<div className="h-10 w-10 overflow-hidden rounded">
670678
{isImage ? (
671679
<img
672-
src={item.url}
680+
src={getMediaThumbnailUrl(item.url, item.mimeType, 80)}
673681
alt={item.alt || item.filename}
674682
className="h-full w-full object-cover"
683+
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
675684
/>
676685
) : (
677686
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">

packages/admin/src/components/MediaPickerModal.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import {
2727
type MediaProviderItem,
2828
} from "../lib/api";
2929
import { useDebouncedValue } from "../lib/hooks.js";
30-
import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils";
30+
import {
31+
providerItemToMediaItem,
32+
getFileIcon,
33+
getMediaThumbnailUrl,
34+
fallbackToOriginalThumbnail,
35+
} from "../lib/media-utils";
3136
import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js";
3237
import { cn } from "../lib/utils";
3338
import { DialogError } from "./DialogError.js";
@@ -766,6 +771,12 @@ function MediaPickerItem({
766771
const isImage = item.mimeType.startsWith("image/");
767772
const needsDimensions = isImage && (!item.width || !item.height);
768773

774+
// Serve a resized thumbnail only when the original dimensions are already
775+
// known. When they're missing we display the original so `onLoad` can read
776+
// the true `naturalWidth`/`naturalHeight` to backfill them — a resized
777+
// rendition would report the thumbnail's dimensions and corrupt the record.
778+
const displayUrl = needsDimensions ? item.url : getMediaThumbnailUrl(item.url, item.mimeType);
779+
769780
const handleImageLoad = React.useCallback(
770781
(e: React.SyntheticEvent<HTMLImageElement>) => {
771782
if (needsDimensions && onDimensionsDetected) {
@@ -793,10 +804,11 @@ function MediaPickerItem({
793804
>
794805
{isImage ? (
795806
<img
796-
src={item.url}
807+
src={displayUrl}
797808
alt=""
798809
className="h-full w-full object-cover"
799810
onLoad={handleImageLoad}
811+
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
800812
/>
801813
) : (
802814
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">

packages/admin/src/lib/media-utils.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,75 @@ export function providerItemToMediaItem(
1919
} as MediaItem & { provider: string; meta?: Record<string, unknown> };
2020
}
2121

22+
/** Root-absolute path prefix for locally stored media served by EmDash. */
23+
const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
24+
25+
/**
26+
* Default rendered width (CSS px) for admin grid thumbnails, requested at ~2x
27+
* the largest grid cell (200px) so they stay crisp on HiDPI displays.
28+
*/
29+
export const MEDIA_THUMBNAIL_WIDTH = 400;
30+
31+
/**
32+
* Build a display URL for a media thumbnail in the admin grid/list views.
33+
*
34+
* Large libraries were slow to browse and search because every grid cell loaded
35+
* the full-size original through the media proxy (#1488). This routes
36+
* same-origin raster images through Astro's runtime image endpoint (`/_image`)
37+
* to request a small resized rendition instead.
38+
*
39+
* Where a runtime image service transforms — sharp on Node, or the Cloudflare
40+
* Images binding on Workers (the `@astrojs/cloudflare` v13 default) — the grid
41+
* gets a lightweight thumbnail. Where none does (a `passthrough` config, or
42+
* behind Cloudflare Access where the endpoint's same-origin source fetch is
43+
* blocked) `/_image` streams the original, so this never renders worse than
44+
* before. Callers should still fall back to the original on image `error` for
45+
* the rare case where the endpoint rejects the request (e.g. a site whose
46+
* configured origin differs from the admin's).
47+
*
48+
* Returns the URL unchanged for non-raster media (an icon renders instead),
49+
* SVGs (vector — nothing to downscale, and some services reject them), and
50+
* anything not served from the local media route (external/provider URLs are
51+
* already remote renditions, not same-origin originals).
52+
*/
53+
export function getMediaThumbnailUrl(
54+
originalUrl: string,
55+
mimeType: string,
56+
width: number = MEDIA_THUMBNAIL_WIDTH,
57+
): string {
58+
if (!mimeType.startsWith("image/") || mimeType === "image/svg+xml") return originalUrl;
59+
if (!originalUrl.startsWith(INTERNAL_MEDIA_PREFIX)) return originalUrl;
60+
61+
// Astro authorizes the media route by absolute origin (see the
62+
// `image.remotePatterns` entry the EmDash integration registers), so the
63+
// transform source must be an absolute same-origin URL. The admin is served
64+
// from the site origin, so `window.location.origin` is the right host.
65+
const origin = typeof window === "undefined" ? "" : window.location.origin;
66+
if (!origin) return originalUrl;
67+
68+
const params = new URLSearchParams({
69+
href: `${origin}${originalUrl}`,
70+
w: String(width),
71+
f: "webp",
72+
});
73+
return `/_image?${params.toString()}`;
74+
}
75+
76+
/**
77+
* `onError` fallback for grid thumbnails: if a `/_image` rendition fails to
78+
* load (e.g. the endpoint rejects the request on a site whose configured origin
79+
* differs from the admin's), swap in the original URL once. Guarded with a data
80+
* attribute so a failing original can't trigger a reload loop.
81+
*/
82+
export function fallbackToOriginalThumbnail(
83+
img: { dataset: DOMStringMap; src: string },
84+
originalUrl: string,
85+
): void {
86+
if (img.dataset.thumbFallback) return;
87+
img.dataset.thumbFallback = "1";
88+
img.src = originalUrl;
89+
}
90+
2291
export function getFileIcon(mimeType: string): string {
2392
if (mimeType.startsWith("video/")) return "🎬";
2493
if (mimeType.startsWith("audio/")) return "🎵";
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import {
4+
getMediaThumbnailUrl,
5+
fallbackToOriginalThumbnail,
6+
MEDIA_THUMBNAIL_WIDTH,
7+
} from "../../src/lib/media-utils";
8+
9+
const LOCAL_IMAGE = "/_emdash/api/media/file/01ABC.jpg";
10+
11+
describe("getMediaThumbnailUrl", () => {
12+
it("routes a local raster image through Astro's /_image endpoint", () => {
13+
const result = getMediaThumbnailUrl(LOCAL_IMAGE, "image/jpeg");
14+
expect(result.startsWith("/_image?")).toBe(true);
15+
16+
const url = new URL(result, window.location.origin);
17+
expect(url.pathname).toBe("/_image");
18+
expect(url.searchParams.get("href")).toBe(`${window.location.origin}${LOCAL_IMAGE}`);
19+
expect(url.searchParams.get("w")).toBe(String(MEDIA_THUMBNAIL_WIDTH));
20+
expect(url.searchParams.get("f")).toBe("webp");
21+
});
22+
23+
it("honors a custom width", () => {
24+
const result = getMediaThumbnailUrl(LOCAL_IMAGE, "image/png", 80);
25+
const url = new URL(result, window.location.origin);
26+
expect(url.searchParams.get("w")).toBe("80");
27+
});
28+
29+
it("passes SVGs through unchanged (vector, nothing to downscale)", () => {
30+
const svg = "/_emdash/api/media/file/01ABC.svg";
31+
expect(getMediaThumbnailUrl(svg, "image/svg+xml")).toBe(svg);
32+
});
33+
34+
it("passes non-image media through unchanged (an icon renders instead)", () => {
35+
const pdf = "/_emdash/api/media/file/01ABC.pdf";
36+
expect(getMediaThumbnailUrl(pdf, "application/pdf")).toBe(pdf);
37+
});
38+
39+
it("passes external/provider URLs through unchanged (already a remote rendition)", () => {
40+
const external = "https://images.example.com/photo.jpg";
41+
expect(getMediaThumbnailUrl(external, "image/jpeg")).toBe(external);
42+
});
43+
});
44+
45+
describe("fallbackToOriginalThumbnail", () => {
46+
it("swaps in the original URL on first error", () => {
47+
const img = { dataset: {} as DOMStringMap, src: "/_image?href=...&w=400&f=webp" };
48+
fallbackToOriginalThumbnail(img, LOCAL_IMAGE);
49+
expect(img.src).toBe(LOCAL_IMAGE);
50+
expect(img.dataset.thumbFallback).toBe("1");
51+
});
52+
53+
it("does not loop if the original also fails", () => {
54+
const img = { dataset: { thumbFallback: "1" } as DOMStringMap, src: LOCAL_IMAGE };
55+
fallbackToOriginalThumbnail(img, "/some/other/url.jpg");
56+
// Guard short-circuits: src is left untouched.
57+
expect(img.src).toBe(LOCAL_IMAGE);
58+
});
59+
});

0 commit comments

Comments
 (0)