/** * Theme Marketplace Browse * * Visual-first grid of theme cards with large thumbnails. * Navigates to theme detail on card click. */ import { Button } from "@cloudflare/kumo"; import { MagnifyingGlass, Palette, Warning, ArrowsClockwise, ArrowSquareOut, Eye, ShieldCheck, } from "@phosphor-icons/react"; import { useInfiniteQuery, useMutation } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import { searchThemes, generatePreviewUrl, type ThemeSummary, type ThemeSearchOpts, } from "../lib/api/theme-marketplace.js"; type SortOption = "updated" | "created" | "name"; const SORT_LABELS: Record = { updated: "Recently Updated", created: "Newest", name: "Name", }; const VALID_SORTS = new Set(["updated", "created", "name"]); function isSortOption(value: string): value is SortOption { return VALID_SORTS.has(value); } export function ThemeMarketplaceBrowse() { const [searchQuery, setSearchQuery] = React.useState(""); const [sort, setSort] = React.useState("updated"); const [debouncedQuery, setDebouncedQuery] = React.useState(""); React.useEffect(() => { const timer = setTimeout(setDebouncedQuery, 300, searchQuery); return () => clearTimeout(timer); }, [searchQuery]); const searchOpts: ThemeSearchOpts = { q: debouncedQuery || undefined, sort, limit: 12, }; const { data, isLoading, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ["themes", "search", searchOpts], queryFn: ({ pageParam }) => searchThemes({ ...searchOpts, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, }); const themes = data?.pages.flatMap((p) => p.items); return (
{/* Header */}

Themes

Browse themes and preview them with your own content.

{/* Search + Sort */}
setSearchQuery(e.target.value)} className="w-full rounded-md border bg-kumo-base px-3 py-2 pl-9 text-sm placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-ring" />
{/* Error state */} {error && (

Unable to reach marketplace

{error instanceof Error ? error.message : "An error occurred"}

)} {/* Loading state — skeleton cards with thumbnail aspect ratio */} {isLoading && (
{Array.from({ length: 6 }).map((_, i) => (
))}
)} {/* Results grid */} {themes && !isLoading && ( <> {themes.length === 0 ? (

No themes found

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

) : ( <>
{themes.map((theme) => ( ))}
{hasNextPage && (
)} )} )}
); } // --------------------------------------------------------------------------- // ThemeCard // --------------------------------------------------------------------------- function ThemeCard({ theme }: { theme: ThemeSummary }) { const thumbnailUrl = theme.thumbnailUrl ? `/_emdash/api/admin/themes/marketplace/${encodeURIComponent(theme.id)}/thumbnail` : null; const previewMutation = useMutation({ mutationFn: () => generatePreviewUrl(theme.previewUrl), onSuccess: (url) => { window.open(url, "_blank", "noopener"); }, }); return (
{/* Thumbnail */} {thumbnailUrl ? ( {`${theme.name} ) : (
)} {/* Info */}

{theme.name}

{theme.author.name} {theme.author.verified && }
{theme.description && (

{theme.description}

)} {/* Action buttons */}
{theme.demoUrl && ( )}
{previewMutation.error && (

{previewMutation.error instanceof Error ? previewMutation.error.message : "Failed to generate preview"}

)}
); } export default ThemeMarketplaceBrowse;