/** * Marketplace Plugin Detail * * Full detail view for a marketplace plugin: * - README rendered as markdown * - Screenshot gallery * - Capability list * - Audit summary * - Version history * - Install button (with capability consent) */ import { Badge, Button } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { DownloadSimple, GithubLogo, Globe, ShieldCheck, Warning, X } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import { fetchMarketplacePlugin, installMarketplacePlugin, uninstallMarketplacePlugin, describeCapability, } from "../lib/api/marketplace.js"; import { renderMarkdown } from "../lib/markdown.js"; import { isSafeUrl, safeIconUrl } from "../lib/url.js"; import { ArrowPrev, CaretNext, CaretPrev } from "./ArrowIcons.js"; import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js"; import { getMutationError } from "./DialogError.js"; import { AuditBadge } from "./MarketplaceBrowse.js"; import { UninstallConfirmDialog } from "./PluginManager.js"; export interface MarketplacePluginDetailProps { pluginId: string; /** IDs of plugins already installed on this site */ installedPluginIds?: Set; } export function MarketplacePluginDetail({ pluginId, installedPluginIds = new Set(), }: MarketplacePluginDetailProps) { const { t } = useLingui(); const queryClient = useQueryClient(); const [showConsent, setShowConsent] = React.useState(false); const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false); const [lightboxIndex, setLightboxIndex] = React.useState(null); const { data: plugin, isLoading, error, } = useQuery({ queryKey: ["marketplace", "plugin", pluginId], queryFn: () => fetchMarketplacePlugin(pluginId), }); const installMutation = useMutation({ mutationFn: () => installMarketplacePlugin(pluginId, { version: plugin?.latestVersion?.version, }), onSuccess: () => { setShowConsent(false); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); void queryClient.invalidateQueries({ queryKey: ["marketplace"] }); }, }); const uninstallMutation = useMutation({ mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(pluginId, { deleteData }), onSuccess: () => { setShowUninstallConfirm(false); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); void queryClient.invalidateQueries({ queryKey: ["marketplace"] }); }, }); const isInstalled = installedPluginIds.has(pluginId); if (isLoading) { return (
); } if (error || !plugin) { return (

{t`Failed to load plugin`}

{error instanceof Error ? error.message : t`Plugin not found`}

{t`Back to marketplace`}
); } const latest = plugin.latestVersion; const imageVerdict = latest?.imageAudit?.verdict; const isImageFlagged = imageVerdict === "warn" || imageVerdict === "fail"; const isAuditFailed = latest?.audit?.verdict === "fail"; const screenshots = (latest?.screenshotUrls ?? []).filter(isSafeUrl); const iconSrc = plugin.iconUrl ? safeIconUrl(plugin.iconUrl, 128) : null; return (
{/* Header */}
{/* Icon */} {iconSrc ? ( ) : (
{plugin.name.charAt(0).toUpperCase()}
)}

{plugin.name}

{plugin.author.name} {plugin.author.verified && } {latest && ( <> v{latest.version} )}
{plugin.description && (

{plugin.description}

)}
{/* Action button */}
{isInstalled ? ( <> {t`Installed`} ) : isAuditFailed ? (
{t`Failed security audit`}
) : ( )}
{/* Stats bar */}
{t`${plugin.installCount.toLocaleString()} installs`}
{latest?.audit && } {plugin.license && {plugin.license}} {plugin.repositoryUrl && isSafeUrl(plugin.repositoryUrl) && ( {t`Source`} )} {plugin.homepageUrl && isSafeUrl(plugin.homepageUrl) && ( {t`Website`} )}
{/* Screenshots */} {screenshots.length > 0 && (

{t`Screenshots`}

{screenshots.map((url, i) => ( ))}
)} {/* Two-column layout: README + sidebar */}
{/* README */}
{latest?.readme ? (
) : (
{t`No detailed description available.`}
)}
{/* Sidebar */}
{/* Capabilities */}

{t`Permissions`}

{plugin.capabilities.length === 0 ? (

{t`This plugin requires no special permissions.`}

) : (
    {plugin.capabilities.map((cap) => (
  • {describeCapability(cap)}
  • ))}
)}
{/* Keywords */} {plugin.keywords && plugin.keywords.length > 0 && (

{t`Keywords`}

{plugin.keywords.map((kw) => ( {kw} ))}
)} {/* Audit summary */} {latest?.audit && (

{t`Security Audit`}

{t`Risk score: ${latest.audit.riskScore}/100`}
)} {/* Version info */} {latest && (

{t`Version`}

v{latest.version}
{latest.minEmDashVersion && (
{t`Requires EmDash ${latest.minEmDashVersion}`}
)}
{t`Published ${new Date(latest.publishedAt).toLocaleDateString()}`}
{latest.bundleSize > 0 &&
{formatBytes(latest.bundleSize)}
}
)}
{/* Capability consent dialog */} {showConsent && ( installMutation.mutate()} onCancel={() => { setShowConsent(false); installMutation.reset(); }} /> )} {/* Uninstall confirmation */} {showUninstallConfirm && ( uninstallMutation.mutate(deleteData)} onCancel={() => { setShowUninstallConfirm(false); uninstallMutation.reset(); }} /> )} {/* Screenshot lightbox */} {lightboxIndex !== null && lightboxIndex < screenshots.length && ( setLightboxIndex(null)} onNavigate={setLightboxIndex} /> )}
); } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function BackLink() { const { t } = useLingui(); return ( {t`Back to marketplace`} ); } interface ScreenshotLightboxProps { screenshots: string[]; index: number; isBlurred?: boolean; onClose: () => void; onNavigate: (index: number) => void; } function ScreenshotLightbox({ screenshots, index, isBlurred = false, onClose, onNavigate, }: ScreenshotLightboxProps) { const { t } = useLingui(); const handleKeyDown = React.useCallback( (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "ArrowLeft" && index > 0) onNavigate(index - 1); if (e.key === "ArrowRight" && index < screenshots.length - 1) onNavigate(index + 1); }, [index, screenshots.length, onClose, onNavigate], ); React.useEffect(() => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); return (
{index > 0 && ( )} {t`Screenshot {index < screenshots.length - 1 && ( )} {/* Counter */}
{index + 1} / {screenshots.length}
); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export default MarketplacePluginDetail;