/** * Registry Browse * * Grid of plugin cards backed by the experimental decentralized plugin * registry's aggregator. Search box debounces directly into the * aggregator's `searchPackages` XRPC -- the aggregator is a public, * read-only service, so no server proxy is involved. * * Cards navigate to `/plugins/marketplace/$pluginId` (the same path the * marketplace browse uses); the router branches to the registry detail * component when `manifest.registry` is configured. */ import { Badge, Input } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, PuzzlePiece, ShieldCheck } from "@phosphor-icons/react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import { searchRegistryPackages, type RegistryClientConfig, type RegistryPackageView, } from "../lib/api/registry.js"; import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.js"; export interface RegistryBrowseProps { /** Resolved manifest.registry block. Required -- caller checks. */ config: RegistryClientConfig; /** * Plugin IDs already installed on this site (derived hashes for * registry installs, see `makeRegistryPluginId`). The UI uses this * only to show an "Installed" badge on browse cards; install gating * happens server-side. */ installedRegistryUris?: Set; } export function RegistryBrowse({ config, installedRegistryUris = new Set() }: RegistryBrowseProps) { const { t } = useLingui(); const [searchQuery, setSearchQuery] = React.useState(""); const [debouncedQuery, setDebouncedQuery] = React.useState(""); // Debounce search input React.useEffect(() => { const timer = setTimeout(setDebouncedQuery, 300, searchQuery); return () => clearTimeout(timer); }, [searchQuery]); const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ["registry", "search", config.aggregatorUrl, debouncedQuery], queryFn: ({ pageParam }) => searchRegistryPackages(config, { q: debouncedQuery || undefined, cursor: pageParam, limit: 20, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.cursor, }); const packages = data?.pages.flatMap((p) => p.packages); return (
{/* Header */}

{t`Plugin Registry`}

{t`Browse and install plugins published to the decentralized registry.`}

{/* Search */}
setSearchQuery(e.target.value)} className="ps-9" aria-label={t`Search plugins`} />
{/* Error */} {error ? (
{t`Failed to load plugins. The registry aggregator may be unreachable.`}
) : null} {/* Loading skeleton */} {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : null} {/* Empty */} {packages && packages.length === 0 ? (
{debouncedQuery ? t`No plugins match "${debouncedQuery}".` : t`No plugins have been published to this registry yet.`}
) : null} {/* Grid */} {packages && packages.length > 0 ? (
{packages.map((pkg) => ( ))}
) : null} {/* Load more */} {hasNextPage ? (
) : null}
); } interface RegistryPackageCardProps { pkg: RegistryPackageView; installed: boolean; } function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) { const { t } = useLingui(); const handleResult = usePublisherHandle(pkg.did, pkg.handle); // Always link by handle when we have one (cleaner URL), DID // otherwise. The detail page accepts either. const linkSegment = handleResult.handle ?? pkg.did; // `profile` is lexicon-validated at the DiscoveryClient boundary, so the // shape is trustworthy (or `null`). These are plain text content // (React-escaped) — no URL/href, so no scheme allow-list is needed here. const name = pkg.profile?.name; const description = pkg.profile?.description; const license = pkg.profile?.license; const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified"); return (

{name ?? pkg.slug}

{verified ? ( ) : null}
{description ? (

{description}

) : null} {license ?

{license}

: null} {installed ? (
{t`Installed`}
) : null}
); }