/** * Marketplace Browse * * Grid of plugin cards with search and sorting. * Navigates to plugin detail on card click. */ import { Badge, Button, Input, Select } from "@cloudflare/kumo"; import type { MessageDescriptor } from "@lingui/core"; import { msg, plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, PuzzlePiece, DownloadSimple, ShieldCheck, ShieldWarning, Warning, ArrowsClockwise, } from "@phosphor-icons/react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import * as React from "react"; import { CAPABILITY_LABELS, searchMarketplace, type MarketplacePluginSummary, type MarketplaceSearchOpts, } from "../lib/api/marketplace.js"; import { safeIconUrl } from "../lib/url.js"; type SortOption = "installs" | "updated" | "created" | "name"; const SORT_OPTIONS = new Set(["installs", "updated", "created", "name"]); function isSortOption(value: string): value is SortOption { return SORT_OPTIONS.has(value); } const SORT_LABELS: Record = { installs: msg`Most Popular`, updated: msg`Recently Updated`, created: msg`Newest`, name: msg`Name`, }; export interface MarketplaceBrowseProps { /** IDs of plugins already installed on this site */ installedPluginIds?: Set; } export function MarketplaceBrowse({ installedPluginIds = new Set() }: MarketplaceBrowseProps) { const { t } = useLingui(); const [searchQuery, setSearchQuery] = React.useState(""); const [sort, setSort] = React.useState("installs"); const [capability, setCapability] = React.useState(""); const [debouncedQuery, setDebouncedQuery] = React.useState(""); // Debounce search input React.useEffect(() => { const timer = setTimeout(setDebouncedQuery, 300, searchQuery); return () => clearTimeout(timer); }, [searchQuery]); const searchOpts: MarketplaceSearchOpts = { q: debouncedQuery || undefined, capability: capability || undefined, sort, limit: 20, }; const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ["marketplace", "search", searchOpts], queryFn: ({ pageParam }) => searchMarketplace({ ...searchOpts, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, }); const plugins = data?.pages.flatMap((p) => p.items); return (
{/* Header */}

{t`Marketplace`}

{t`Browse and install plugins to extend your site.`}

{/* Search + Sort */}
setSearchQuery(e.target.value)} className="ps-9" aria-label={t`Search plugins`} />
{ if (v && isSortOption(v)) setSort(v); }} items={Object.fromEntries( Object.entries(SORT_LABELS).map(([value, label]) => [value, t(label)]), )} aria-label={t`Sort plugins`} />
{/* Error state */} {error && (

{t`Unable to reach marketplace`}

{error instanceof Error ? error.message : t`An error occurred`}

)} {/* Loading state */} {isLoading && (
{Array.from({ length: 6 }).map((_, i) => (
))}
)} {/* Results grid */} {plugins && !isLoading && ( <> {plugins.length === 0 ? (

{t`No plugins found`}

{debouncedQuery ? t`No results for "${debouncedQuery}". Try a different search term.` : t`The marketplace is empty. Check back later for new plugins.`}

) : ( <>
{plugins.map((plugin) => ( ))}
{hasNextPage && (
)} )} )}
); } // --------------------------------------------------------------------------- // PluginCard // --------------------------------------------------------------------------- interface PluginCardProps { plugin: MarketplacePluginSummary; isInstalled: boolean; } function PluginCard({ plugin, isInstalled }: PluginCardProps) { const { t } = useLingui(); const navigate = useNavigate(); const auditVerdict = plugin.latestVersion?.audit?.verdict; const imageVerdict = plugin.latestVersion?.imageAudit?.verdict; const isImageFlagged = imageVerdict === "warn" || imageVerdict === "fail"; const iconSrc = plugin.iconUrl ? safeIconUrl(plugin.iconUrl, 64) : null; return (
{/* Icon */} {iconSrc ? ( ) : ( )} {/* Name + meta */}

{plugin.name}

{isInstalled && ( { e.preventDefault(); e.stopPropagation(); void navigate({ to: "/plugins-manager" }); }} > {t`Installed`} )}
{plugin.author.name} {plugin.author.verified && } {plugin.latestVersion?.version && v{plugin.latestVersion.version}}
{/* Description */} {plugin.description && (

{plugin.description}

)} {/* Footer: install count + audit + capabilities */}
{formatInstallCount(plugin.installCount)}
{auditVerdict && } {plugin.capabilities.length > 0 && ( {plural(plugin.capabilities.length, { one: "# permission", other: "# permissions", })} )}
); } // --------------------------------------------------------------------------- // Shared small components // --------------------------------------------------------------------------- function PluginAvatar({ name }: { name: string }) { const initial = name.charAt(0).toUpperCase(); return (
{initial}
); } export function AuditBadge({ verdict }: { verdict: "pass" | "warn" | "fail" }) { const { t } = useLingui(); if (verdict === "pass") { return ( {t`Pass`} ); } if (verdict === "warn") { return ( {t`Warn`} ); } return ( {t`Fail`} ); } function formatInstallCount(count: number): string { if (count >= 1000) { return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`; } return String(count); } export default MarketplaceBrowse;