'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { ShieldCheck, GitFork, Activity } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/cn'; import { Logo } from '@/components/logo'; import { Input } from '@/components/input'; import { MobileActionBar } from '@/components/mobile-action-bar'; import { UserMenu } from '@/components/user-menu'; interface Template { id: string; slug: string; title: string; shortDescription: string; category: string; status: 'draft' | 'public' | 'hidden' | 'takedown'; verified: boolean; forkCount: number; activeDeployments: number; ownerName: string | null; ownerOrgName: string | null; sourceServerId: string | null; createdAt: string; } type Sort = 'trending' | 'top'; type Scope = 'all' | 'mine'; const STATUS_STYLE: Record = { public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300', hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300', takedown: 'border-red-400/40 bg-red-400/10 text-red-300', draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300', }; export default function TemplatesMarketplace() { const [me, setMe] = useState<{ email: string } | null | undefined>(undefined); const [scope, setScope] = useState('all'); const [templates, setTemplates] = useState(null); const [categories, setCategories] = useState([]); const [sort, setSort] = useState('trending'); const [category, setCategory] = useState(''); const [search, setSearch] = useState(''); // Detect login state once useEffect(() => { apiFetch<{ user: { email: string } }>('/v1/auth/me') .then((r) => setMe({ email: r.user.email })) .catch(() => setMe(null)); }, []); // Load templates whenever scope/sort/category changes useEffect(() => { setTemplates(null); if (scope === 'mine') { apiFetch<{ templates: Template[]; categories: string[] }>('/v1/templates/mine') .then((r) => { setTemplates(r.templates); setCategories(r.categories); }) .catch(() => setTemplates([])); } else { const params = new URLSearchParams({ sort }); if (category) params.set('category', category); apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`) .then((r) => { setTemplates(r.templates); setCategories(r.categories); }) .catch(() => setTemplates([])); } }, [scope, sort, category]); const visible = templates?.filter((t) => { if (search) { const q = search.toLowerCase(); if (!t.title.toLowerCase().includes(q) && !t.shortDescription.toLowerCase().includes(q)) { return false; } } // category filter is server-side for 'all', client-side for 'mine' if (scope === 'mine' && category && t.category !== category) return false; return true; }); const loggedIn = me != null; return (
{/* "/ templates" subtitle is redundant on mobile — h1 below already names the page. Keep on desktop as breadcrumb. */} / templates
Marketplace

MCP server templates

Pre-built MCP servers from the community. Fork in one click — your own container, your own credentials, fully isolated. The template author never sees your data.

{/* Filter row: stacks vertically on mobile (search on top for thumb reach), inline on desktop. The order utility on the Input flips it to the right side at sm: while filters wrap normally. */}
setSearch(e.target.value)} placeholder="Search…" className="order-first w-full sm:order-last sm:ml-auto sm:w-60" /> {/* Chips row — horizontally scrollable on narrow mobile so the segmented controls never get squeezed below their min-width. */}
{loggedIn && ( <>
{(['all', 'mine'] as Scope[]).map((s) => ( ))}
)} {scope === 'all' && ( <>
{(['trending', 'top'] as Sort[]).map((s) => ( ))}
)} truncates the visible label with ellipsis when // the box is narrower than the text; the dropdown panel // itself still shows full names when opened. className="h-7 w-[140px] shrink-0 truncate rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none sm:w-[160px]" > {categories.map((c) => ( ))}
{!visible &&

Loading…

} {visible && visible.length === 0 && (
{scope === 'mine' ? ( <>

You haven't published any templates.

Build a server, then tick Share as template on the done screen — or use the Publish tab on any live server.

) : ( <>

No templates yet.

Build a server you're proud of and share it to the marketplace.

)}
)} {visible && visible.length > 0 && (
{visible.map((t) => ( ))}
)}
Every template is isolated: forking creates your own container with your own secrets.
{/* Mobile tab-bar — only when signed in. Logged-out marketplace browsing keeps the simple marketing chrome (login CTA in header). */} {loggedIn && }
); } function TemplateCard({ t, showStatus }: { t: Template; showStatus: boolean }) { // takedown templates can't be opened (the detail route 410s); link to the // server's Publish tab so the owner can see why. Everything else opens detail. const href = t.status === 'takedown' && t.sourceServerId ? `/servers/${t.sourceServerId}` : `/templates/${t.slug}`; return (

{t.title}

{t.verified && ( verified )}
{showStatus ? ( {t.status} ) : ( {t.category} )}

{t.shortDescription}

{t.forkCount} {t.activeDeployments}
{showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
); }