/** * Theme Marketplace Detail * * Full detail view for a marketplace theme: * - Screenshot gallery * - Description, author, license * - "Try with my data" button * - Demo + repository links */ import { Badge, Button, LinkButton } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { ArrowSquareOut, Eye, GithubLogo, Globe, Palette, ShieldCheck, X, } from "@phosphor-icons/react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import { fetchTheme, generatePreviewUrl } from "../lib/api/theme-marketplace.js"; import { ArrowPrev, CaretNext, CaretPrev } from "./ArrowIcons.js"; /** Only allow safe URL protocols for external links */ function isSafeUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.protocol === "https:" || parsed.protocol === "http:"; } catch { return false; } } export interface ThemeMarketplaceDetailProps { themeId: string; } export function ThemeMarketplaceDetail({ themeId }: ThemeMarketplaceDetailProps) { const { t } = useLingui(); const [lightboxIndex, setLightboxIndex] = React.useState(null); const { data: theme, isLoading, error, } = useQuery({ queryKey: ["themes", "detail", themeId], queryFn: () => fetchTheme(themeId), }); const previewMutation = useMutation({ mutationFn: () => generatePreviewUrl(theme!.previewUrl), onSuccess: (url) => { window.open(url, "_blank", "noopener"); }, }); // Loading if (isLoading) { return (
); } // Error if (error || !theme) { return (
{t`Back to Themes`}

{t`Failed to load theme`}

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

); } const thumbnailUrl = theme.hasThumbnail ? `/_emdash/api/admin/themes/marketplace/${encodeURIComponent(theme.id)}/thumbnail` : null; return (
{/* Back link */} {t`Back to Themes`} {/* Header */}
{thumbnailUrl ? ( ) : (
)}

{theme.name}

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

{theme.description}

)}
{/* Actions */}
{theme.demoUrl && isSafeUrl(theme.demoUrl) && ( }> {t`Demo`} )}
{previewMutation.error && (
{previewMutation.error instanceof Error ? previewMutation.error.message : t`Failed to generate preview URL`}
)} {/* Screenshot gallery */} {theme.screenshotCount > 0 && (

{t`Screenshots`}

{theme.screenshotUrls.map((url, i) => ( ))}
)} {/* Details */}
{/* Keywords */} {theme.keywords.length > 0 && (

{t`Keywords`}

{theme.keywords.map((kw) => ( {kw} ))}
)} {/* License */} {theme.license && (

{t`License`}

{theme.license}

)} {/* Links */}

{t`Links`}

{theme.repositoryUrl && isSafeUrl(theme.repositoryUrl) && ( {t`Repository`} )} {theme.homepageUrl && isSafeUrl(theme.homepageUrl) && ( {t`Homepage`} )}
{/* Lightbox */} {lightboxIndex !== null && ( setLightboxIndex(null)} onPrev={() => setLightboxIndex((i) => (i !== null && i > 0 ? i - 1 : theme.screenshotUrls.length - 1)) } onNext={() => setLightboxIndex((i) => (i !== null && i < theme.screenshotUrls.length - 1 ? i + 1 : 0)) } /> )}
); } // --------------------------------------------------------------------------- // Lightbox // --------------------------------------------------------------------------- function Lightbox({ urls, index, onClose, onPrev, onNext, }: { urls: string[]; index: number; onClose: () => void; onPrev: () => void; onNext: () => void; }) { const { t } = useLingui(); React.useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") onClose(); if (e.key === "ArrowLeft") onPrev(); if (e.key === "ArrowRight") onNext(); } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [onClose, onPrev, onNext]); const url = urls[index]; if (!url) return null; return (
e.stopPropagation()}> {t`Screenshot {urls.length > 1 && ( <> )}
{index + 1} / {urls.length}
); } export default ThemeMarketplaceDetail;