From 3f45c40dcca41181f4a5b44bf9a68c27c8e54cd9 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:09:24 +0100 Subject: [PATCH 1/8] feat(admin): add media usage detail client --- packages/admin/src/lib/api/index.ts | 8 ++ packages/admin/src/lib/api/media.ts | 73 ++++++++++++++++ packages/admin/tests/lib/media-api.test.ts | 97 ++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 packages/admin/tests/lib/media-api.test.ts diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index 8c3cb4b7a4..ead599f421 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -58,12 +58,20 @@ export { // Media export { type MediaItem, + type MediaUsageCoverageStatus, + type MediaUsageCoverage, + type MediaUsageOccurrenceDetail, + type MediaUsageSourceDetail, + type MediaUsageEntryDetail, + type MediaUsageDetailsResponse, type MediaProviderCapabilities, type MediaProviderInfo, type MediaProviderItem, MEDIA_SEARCH_MAX_LENGTH, fetchMediaList, fetchMediaItem, + fetchMediaUsageDetails, + MediaUsageAccessDeniedError, uploadMedia, deleteMedia, updateMedia, diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 1e70562536..5570bc1533 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -20,6 +20,57 @@ export function normalizeMediaSearch(value: string | undefined | null): string { return (value ?? "").trim().slice(0, MEDIA_SEARCH_MAX_LENGTH); } +export type MediaUsageCoverageStatus = + | "complete" + | "never" + | "running" + | "partial" + | "failed" + | "stale" + | "unknown"; + +export interface MediaUsageCoverage { + scope: "all_content_collections"; + status: MediaUsageCoverageStatus; +} + +export interface MediaUsageOccurrenceDetail { + fieldSlug: string; + fieldPath: string; + occurrenceIndex: number; + referenceType: "image_field" | "file_field" | "portable_text_image" | "unknown"; +} + +export interface MediaUsageSourceDetail { + variant: "columns" | "draft_overlay"; + occurrences: MediaUsageOccurrenceDetail[]; +} + +export interface MediaUsageEntryDetail { + collection: string; + contentId: string; + title: string | null; + slug: string | null; + locale: string | null; + status: string | null; + scheduledAt: string | null; + deletedAt: string | null; + sources: MediaUsageSourceDetail[]; +} + +export interface MediaUsageDetailsResponse { + items: MediaUsageEntryDetail[]; + nextCursor?: string; + coverage: MediaUsageCoverage; +} + +export class MediaUsageAccessDeniedError extends Error { + constructor() { + super("Media usage details are unavailable"); + this.name = "MediaUsageAccessDeniedError"; + } +} + export interface MediaItem { id: string; filename: string; @@ -87,6 +138,28 @@ export async function fetchMediaItem(id: string): Promise { return data.item; } +export async function fetchMediaUsageDetails( + mediaId: string, + options?: { cursor?: string; limit?: number; signal?: AbortSignal }, +): Promise { + const params = new URLSearchParams(); + if (options?.cursor !== undefined) params.set("cursor", options.cursor); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + + const query = params.toString(); + const response = await apiFetch( + `${API_BASE}/media/${encodeURIComponent(mediaId)}/usage${query ? `?${query}` : ""}`, + { signal: options?.signal }, + ); + if (response.status === 401 || response.status === 403) { + throw new MediaUsageAccessDeniedError(); + } + return parseApiResponse( + response, + i18n._(msg`Failed to fetch media usage details`), + ); +} + /** * Upload URL response from the API */ diff --git a/packages/admin/tests/lib/media-api.test.ts b/packages/admin/tests/lib/media-api.test.ts new file mode 100644 index 0000000000..7dd2ef48d3 --- /dev/null +++ b/packages/admin/tests/lib/media-api.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MediaUsageAccessDeniedError, fetchMediaUsageDetails } from "../../src/lib/api/media.js"; + +describe("media usage API", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("encodes pagination and forwards cancellation for a bounded usage read", async () => { + const controller = new AbortController(); + const data = { + items: [ + { + collection: "posts", + contentId: "post-1", + title: "Launch notes", + slug: "launch-notes", + locale: "en", + status: "published", + scheduledAt: null, + deletedAt: null, + sources: [], + }, + ], + nextCursor: "next / group", + coverage: { scope: "all_content_collections", status: "stale" }, + }; + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ success: true, data })); + + const result = await fetchMediaUsageDetails("media/one", { + cursor: "after / group", + limit: 50, + signal: controller.signal, + }); + + const [input, init] = fetch.mock.calls[0]!; + const url = new URL(String(input), window.location.origin); + expect(url.pathname).toBe("/_emdash/api/media/media%2Fone/usage"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + cursor: "after / group", + limit: "50", + }); + expect(init?.signal).toBe(controller.signal); + expect(result).toEqual(data); + expect(result).not.toHaveProperty("count"); + }); + + it("omits undefined pagination values", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ + success: true, + data: { + items: [], + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }), + ); + + await fetchMediaUsageDetails("media-1", { cursor: undefined, limit: undefined }); + + expect(String(fetch.mock.calls[0]![0])).toBe("/_emdash/api/media/media-1/usage"); + }); + + it.each([401, 403])("maps %s to a private access-denied error", async (status) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json( + { + success: false, + error: { code: "FORBIDDEN", message: "private server detail" }, + }, + { status }, + ), + ); + + const error = await fetchMediaUsageDetails("media-1").catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MediaUsageAccessDeniedError); + expect((error as Error).message).not.toContain("private server detail"); + }); + + it("preserves ordinary API error handling", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json( + { + success: false, + error: { code: "MEDIA_NOT_FOUND", message: "Media item not found" }, + }, + { status: 404 }, + ), + ); + + await expect(fetchMediaUsageDetails("missing")).rejects.toThrow("Media item not found"); + }); +}); From 8cd33c0317d01275a9c172564d970a5ccd08381a Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:21:20 +0100 Subject: [PATCH 2/8] feat(admin): show coverage-aware media usage --- packages/admin/src/components/MediaUsedIn.tsx | 281 +++++++++++++++ .../tests/components/MediaUsedIn.test.tsx | 340 ++++++++++++++++++ 2 files changed, 621 insertions(+) create mode 100644 packages/admin/src/components/MediaUsedIn.tsx create mode 100644 packages/admin/tests/components/MediaUsedIn.test.tsx diff --git a/packages/admin/src/components/MediaUsedIn.tsx b/packages/admin/src/components/MediaUsedIn.tsx new file mode 100644 index 0000000000..8f21140a92 --- /dev/null +++ b/packages/admin/src/components/MediaUsedIn.tsx @@ -0,0 +1,281 @@ +import { Badge, Banner, Button, SkeletonLine } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { Warning } from "@phosphor-icons/react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import * as React from "react"; + +import { + MediaUsageAccessDeniedError, + fetchManifest, + fetchMediaUsageDetails, + type AdminManifest, + type MediaUsageCoverageStatus, + type MediaUsageEntryDetail, +} from "../lib/api/index.js"; +import { getCollectionNavIcon } from "./admin-navigation-icons.js"; + +const USAGE_PAGE_SIZE = 50; + +export interface MediaUsedInProps { + mediaId: string; + open: boolean; + navigationBlocked?: boolean; + onEntryClick?: (event: React.MouseEvent, entry: MediaUsageEntryDetail) => void; +} + +export function MediaUsedIn({ mediaId, open, navigationBlocked, onEntryClick }: MediaUsedInProps) { + const { t } = useLingui(); + const headingId = React.useId(); + const { data: manifest } = useQuery({ + queryKey: ["manifest"], + queryFn: fetchManifest, + enabled: open, + }); + const usageQuery = useInfiniteQuery({ + queryKey: ["media-usage", mediaId], + queryFn: ({ pageParam, signal }) => + fetchMediaUsageDetails(mediaId, { + cursor: pageParam, + limit: USAGE_PAGE_SIZE, + signal, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: open, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: "always", + gcTime: 0, + }); + + const accessDenied = usageQuery.error instanceof MediaUsageAccessDeniedError; + const pages = usageQuery.data?.pages ?? []; + const entries = pages.flatMap((page) => page.items); + const coverageStatus = aggregateCoverageStatus(pages.map((page) => page.coverage.status)); + const coverageComplete = coverageStatus === "complete"; + const refreshError = usageQuery.isError && pages.length > 0 && !usageQuery.isFetchNextPageError; + const canRenderEmpty = usageQuery.isSuccess && !usageQuery.isFetching; + + let statusMessage = ""; + if (accessDenied) statusMessage = t`Usage details unavailable`; + else if (usageQuery.isFetching && pages.length === 0) statusMessage = t`Loading usage`; + else if (usageQuery.isFetching) statusMessage = t`Updating usage`; + else if (pages.length > 0 && !coverageComplete) + statusMessage = t`Usage loaded. Coverage may be incomplete.`; + else if (pages.length > 0) statusMessage = t`Usage loaded`; + + return ( +
+
+

+ {t`Used in`} +

+
+ + {statusMessage} + + + {accessDenied ? ( +

+ {t`Usage details aren’t available for your account.`} +

+ ) : usageQuery.isLoading ? ( + + ) : usageQuery.isError && pages.length === 0 ? ( + void usageQuery.refetch()} /> + ) : pages.length > 0 ? ( +
+ {refreshError && void usageQuery.refetch()} />} + {!coverageComplete && } + + {entries.length > 0 ? ( +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ ) : canRenderEmpty ? ( +

+ {coverageComplete + ? t`No usage found in EmDash-managed content fields.` + : t`No indexed references are currently available.`} +

+ ) : null} + + {usageQuery.hasNextPage && ( +
+ {usageQuery.isFetchNextPageError && ( +

{t`Couldn’t load more usage.`}

+ )} + +
+ )} +
+ ) : null} +
+ ); +} + +function UsageSkeleton() { + return ( + + ); +} + +function UsageError({ onRetry }: { onRetry: () => void }) { + const { t } = useLingui(); + return ( + + {t`Try again`} + + } + /> + ); +} + +function IncompleteCoverage() { + const { t } = useLingui(); + return ( +