Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-library-thumbnails.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 12 additions & 3 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -576,9 +583,10 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
<div className="aspect-square">
{isImage ? (
<img
src={item.url}
src={getMediaThumbnailUrl(item.url, item.mimeType, MEDIA_THUMBNAIL_WIDTH)}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
Expand Down Expand Up @@ -669,9 +677,10 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
<div className="h-10 w-10 overflow-hidden rounded">
{isImage ? (
<img
src={item.url}
src={getMediaThumbnailUrl(item.url, item.mimeType, 80)}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
Expand Down
16 changes: 14 additions & 2 deletions packages/admin/src/components/MediaPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLImageElement>) => {
if (needsDimensions && onDimensionsDetected) {
Expand Down Expand Up @@ -793,10 +804,11 @@ function MediaPickerItem({
>
{isImage ? (
<img
src={item.url}
src={displayUrl}
alt=""
className="h-full w-full object-cover"
onLoad={handleImageLoad}
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
Expand Down
69 changes: 69 additions & 0 deletions packages/admin/src/lib/media-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,75 @@ export function providerItemToMediaItem(
} as MediaItem & { provider: string; meta?: Record<string, unknown> };
}

/** 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 "🎵";
Expand Down
59 changes: 59 additions & 0 deletions packages/admin/tests/lib/media-thumbnail.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading