diff --git a/.changeset/media-library-thumbnails.md b/.changeset/media-library-thumbnails.md new file mode 100644 index 0000000000..1dbadfa623 --- /dev/null +++ b/.changeset/media-library-thumbnails.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +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. diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index bda2223c1a..ebd7faf329 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -15,7 +15,14 @@ import { uploadToProvider, } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; -import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils"; +import { + providerItemToMediaItem, + getFileIcon, + formatFileSize, + getMediaThumbnailUrl, + fallbackToOriginalThumbnail, + MEDIA_THUMBNAIL_WIDTH, +} from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; @@ -576,9 +583,10 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
{isImage ? ( {item.alt fallbackToOriginalThumbnail(e.currentTarget, item.url)} /> ) : (
@@ -669,9 +677,10 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
{isImage ? ( {item.alt fallbackToOriginalThumbnail(e.currentTarget, item.url)} /> ) : (
diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index ac26797d09..559657f859 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -27,7 +27,12 @@ import { type MediaProviderItem, } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; -import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils"; +import { + providerItemToMediaItem, + getFileIcon, + getMediaThumbnailUrl, + fallbackToOriginalThumbnail, +} from "../lib/media-utils"; import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js"; import { cn } from "../lib/utils"; import { DialogError } from "./DialogError.js"; @@ -766,6 +771,12 @@ function MediaPickerItem({ const isImage = item.mimeType.startsWith("image/"); const needsDimensions = isImage && (!item.width || !item.height); + // Serve a resized thumbnail only when the original dimensions are already + // known. When they're missing we display the original so `onLoad` can read + // the true `naturalWidth`/`naturalHeight` to backfill them — a resized + // rendition would report the thumbnail's dimensions and corrupt the record. + const displayUrl = needsDimensions ? item.url : getMediaThumbnailUrl(item.url, item.mimeType); + const handleImageLoad = React.useCallback( (e: React.SyntheticEvent) => { if (needsDimensions && onDimensionsDetected) { @@ -793,10 +804,11 @@ function MediaPickerItem({ > {isImage ? ( fallbackToOriginalThumbnail(e.currentTarget, item.url)} /> ) : (
diff --git a/packages/admin/src/lib/media-utils.ts b/packages/admin/src/lib/media-utils.ts index f2d7e1c0c6..cf3d3e933d 100644 --- a/packages/admin/src/lib/media-utils.ts +++ b/packages/admin/src/lib/media-utils.ts @@ -19,6 +19,75 @@ export function providerItemToMediaItem( } as MediaItem & { provider: string; meta?: Record }; } +/** Root-absolute path prefix for locally stored media served by EmDash. */ +const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/"; + +/** + * Default rendered width (CSS px) for admin grid thumbnails, requested at ~2x + * the largest grid cell (200px) so they stay crisp on HiDPI displays. + */ +export const MEDIA_THUMBNAIL_WIDTH = 400; + +/** + * Build a display URL for a media thumbnail in the admin grid/list views. + * + * Large libraries were slow to browse and search because every grid cell loaded + * the full-size original through the media proxy (#1488). This routes + * same-origin raster images through Astro's runtime image endpoint (`/_image`) + * to request a small resized rendition instead. + * + * Where a runtime image service transforms — sharp on Node, or the Cloudflare + * Images binding on Workers (the `@astrojs/cloudflare` v13 default) — the grid + * gets a lightweight thumbnail. Where none does (a `passthrough` config, or + * behind Cloudflare Access where the endpoint's same-origin source fetch is + * blocked) `/_image` streams the original, so this never renders worse than + * before. Callers should still fall back to the original on image `error` for + * the rare case where the endpoint rejects the request (e.g. a site whose + * configured origin differs from the admin's). + * + * Returns the URL unchanged for non-raster media (an icon renders instead), + * SVGs (vector — nothing to downscale, and some services reject them), and + * anything not served from the local media route (external/provider URLs are + * already remote renditions, not same-origin originals). + */ +export function getMediaThumbnailUrl( + originalUrl: string, + mimeType: string, + width: number = MEDIA_THUMBNAIL_WIDTH, +): string { + if (!mimeType.startsWith("image/") || mimeType === "image/svg+xml") return originalUrl; + if (!originalUrl.startsWith(INTERNAL_MEDIA_PREFIX)) return originalUrl; + + // Astro authorizes the media route by absolute origin (see the + // `image.remotePatterns` entry the EmDash integration registers), so the + // transform source must be an absolute same-origin URL. The admin is served + // from the site origin, so `window.location.origin` is the right host. + const origin = typeof window === "undefined" ? "" : window.location.origin; + if (!origin) return originalUrl; + + const params = new URLSearchParams({ + href: `${origin}${originalUrl}`, + w: String(width), + f: "webp", + }); + return `/_image?${params.toString()}`; +} + +/** + * `onError` fallback for grid thumbnails: if a `/_image` rendition fails to + * load (e.g. the endpoint rejects the request on a site whose configured origin + * differs from the admin's), swap in the original URL once. Guarded with a data + * attribute so a failing original can't trigger a reload loop. + */ +export function fallbackToOriginalThumbnail( + img: { dataset: DOMStringMap; src: string }, + originalUrl: string, +): void { + if (img.dataset.thumbFallback) return; + img.dataset.thumbFallback = "1"; + img.src = originalUrl; +} + export function getFileIcon(mimeType: string): string { if (mimeType.startsWith("video/")) return "🎬"; if (mimeType.startsWith("audio/")) return "🎵"; diff --git a/packages/admin/tests/lib/media-thumbnail.test.ts b/packages/admin/tests/lib/media-thumbnail.test.ts new file mode 100644 index 0000000000..c34211f910 --- /dev/null +++ b/packages/admin/tests/lib/media-thumbnail.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; + +import { + getMediaThumbnailUrl, + fallbackToOriginalThumbnail, + MEDIA_THUMBNAIL_WIDTH, +} from "../../src/lib/media-utils"; + +const LOCAL_IMAGE = "/_emdash/api/media/file/01ABC.jpg"; + +describe("getMediaThumbnailUrl", () => { + it("routes a local raster image through Astro's /_image endpoint", () => { + const result = getMediaThumbnailUrl(LOCAL_IMAGE, "image/jpeg"); + expect(result.startsWith("/_image?")).toBe(true); + + const url = new URL(result, window.location.origin); + expect(url.pathname).toBe("/_image"); + expect(url.searchParams.get("href")).toBe(`${window.location.origin}${LOCAL_IMAGE}`); + expect(url.searchParams.get("w")).toBe(String(MEDIA_THUMBNAIL_WIDTH)); + expect(url.searchParams.get("f")).toBe("webp"); + }); + + it("honors a custom width", () => { + const result = getMediaThumbnailUrl(LOCAL_IMAGE, "image/png", 80); + const url = new URL(result, window.location.origin); + expect(url.searchParams.get("w")).toBe("80"); + }); + + it("passes SVGs through unchanged (vector, nothing to downscale)", () => { + const svg = "/_emdash/api/media/file/01ABC.svg"; + expect(getMediaThumbnailUrl(svg, "image/svg+xml")).toBe(svg); + }); + + it("passes non-image media through unchanged (an icon renders instead)", () => { + const pdf = "/_emdash/api/media/file/01ABC.pdf"; + expect(getMediaThumbnailUrl(pdf, "application/pdf")).toBe(pdf); + }); + + it("passes external/provider URLs through unchanged (already a remote rendition)", () => { + const external = "https://images.example.com/photo.jpg"; + expect(getMediaThumbnailUrl(external, "image/jpeg")).toBe(external); + }); +}); + +describe("fallbackToOriginalThumbnail", () => { + it("swaps in the original URL on first error", () => { + const img = { dataset: {} as DOMStringMap, src: "/_image?href=...&w=400&f=webp" }; + fallbackToOriginalThumbnail(img, LOCAL_IMAGE); + expect(img.src).toBe(LOCAL_IMAGE); + expect(img.dataset.thumbFallback).toBe("1"); + }); + + it("does not loop if the original also fails", () => { + const img = { dataset: { thumbFallback: "1" } as DOMStringMap, src: LOCAL_IMAGE }; + fallbackToOriginalThumbnail(img, "/some/other/url.jpg"); + // Guard short-circuits: src is left untouched. + expect(img.src).toBe(LOCAL_IMAGE); + }); +});