diff --git a/.changeset/media-usage-used-in-ui.md b/.changeset/media-usage-used-in-ui.md new file mode 100644 index 0000000000..f41c12257c --- /dev/null +++ b/.changeset/media-usage-used-in-ui.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": patch +--- + +Adds a coverage-aware Used in section to local media details. diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 5013c199f6..553b1f9411 100644 --- a/packages/admin/src/components/MediaDetailPanel.tsx +++ b/packages/admin/src/components/MediaDetailPanel.tsx @@ -30,6 +30,7 @@ import { Info, } from "@phosphor-icons/react"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import * as React from "react"; import { @@ -44,6 +45,7 @@ import { type MediaFolder, type MediaItem, type MediaUpdateInput, + type MediaUsageEntryDetail, } from "../lib/api"; import { useDebouncedValue, useStableCallback } from "../lib/hooks"; import { @@ -56,6 +58,7 @@ import { import { ConfirmDialog } from "./ConfirmDialog"; import { DialogError, getMutationError } from "./DialogError.js"; import { FocalPointEditor, FocalPointPreviews } from "./FocalPointEditor.js"; +import { MediaUsedIn } from "./MediaUsedIn.js"; const CLOSE_FALLBACK_MS = 500; type MediaDetailTab = "details" | "edit-image"; @@ -97,6 +100,7 @@ export function MediaDetailPanel({ }: MediaDetailPanelProps) { const { t } = useLingui(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const restoreFocusAfterDeleteRef = React.useRef(false); const savePendingRef = React.useRef(false); const closeFallbackTimerRef = React.useRef(null); @@ -126,6 +130,9 @@ export function MediaDetailPanel({ const [activeTab, setActiveTab] = React.useState("details"); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [showDiscardConfirm, setShowDiscardConfirm] = React.useState(false); + const [pendingUsageEntry, setPendingUsageEntry] = React.useState( + null, + ); const focalPointDescriptionId = React.useId(); React.useEffect(() => { @@ -148,6 +155,7 @@ export function MediaDetailPanel({ setActiveTab("details"); setShowDeleteConfirm(false); setShowDiscardConfirm(false); + setPendingUsageEntry(null); }, [item.id, localItem?.folderId, open]); React.useEffect(() => { @@ -368,6 +376,7 @@ export function MediaDetailPanel({ const requestClose = React.useCallback(() => { if (isBusy) return; if (isConfirmOpen) return; + setPendingUsageEntry(null); if (hasChanges) { setShowDiscardConfirm(true); return; @@ -397,8 +406,33 @@ export function MediaDetailPanel({ }; const handleDiscardConfirm = () => { + const usageEntry = pendingUsageEntry; setShowDiscardConfirm(false); + setPendingUsageEntry(null); closeDialog(); + if (usageEntry) { + void navigate({ + to: "/content/$collection/$id", + params: { collection: usageEntry.collection, id: usageEntry.contentId }, + search: { locale: usageEntry.locale ?? undefined }, + }); + } + }; + + const handleUsageEntryClick = ( + event: React.MouseEvent, + entry: MediaUsageEntryDetail, + ) => { + if (isBusy) { + event.preventDefault(); + return; + } + if (!hasChanges || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + event.preventDefault(); + setPendingUsageEntry(entry); + setShowDiscardConfirm(true); }; const stableHandleSave = useStableCallback(handleSave); @@ -629,7 +663,7 @@ export function MediaDetailPanel({ >
- {t`Filename`} + {t`Filename`}
- {t`Alt Text`} + {t`Alt Text`} )} - {updateErrorMessage && (
)} + + {!isProviderAsset && ( +
+ +
+ )}
@@ -897,7 +941,10 @@ export function MediaDetailPanel({ setShowDiscardConfirm(false)} + onClose={() => { + setShowDiscardConfirm(false); + setPendingUsageEntry(null); + }} title={t`Discard changes?`} description={t`Your unsaved media changes will be lost.`} confirmLabel={t`Discard`} diff --git a/packages/admin/src/components/MediaUsedIn.tsx b/packages/admin/src/components/MediaUsedIn.tsx new file mode 100644 index 0000000000..1aeb77c9f9 --- /dev/null +++ b/packages/admin/src/components/MediaUsedIn.tsx @@ -0,0 +1,333 @@ +import { Badge, Banner, Button, SkeletonLine, Tooltip } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { + ArrowCounterClockwise, + CircleNotch, + Clock, + Question, + Warning, + XCircle, +} 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 coverageIcon: React.ReactNode = null; + let coverageMessage = ""; + let coverageTone = ""; + switch (coverageStatus) { + case "running": + coverageMessage = t`Usage is updating. Some content may not appear here yet.`; + coverageTone = "text-kumo-info hover:text-kumo-info"; + coverageIcon = ( +