All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Two narrow fixes for mobile chip-row width: - Removed the 'newest' sort button. Trending and Top cover the use cases; newest was largely redundant with Top sorted on createdAt. - Capped the categories <select> at 140px (160px on sm+). Long category names were stretching the box and pushing the horizontally-scrollable chip row beyond a sane width on phones. Native <select> truncates the visible label with ellipsis; the dropdown panel still shows full names when opened. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
'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<Template['status'], string> = {
|
|
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<Scope>('all');
|
|
const [templates, setTemplates] = useState<Template[] | null>(null);
|
|
const [categories, setCategories] = useState<string[]>([]);
|
|
const [sort, setSort] = useState<Sort>('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 (
|
|
<div className="flex min-h-screen flex-col">
|
|
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
|
|
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-4 sm:px-6">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Logo />
|
|
{/* "/ templates" subtitle is redundant on mobile — h1 below
|
|
already names the page. Keep on desktop as breadcrumb. */}
|
|
<span className="hidden text-[12.5px] text-[--color-fg-subtle] sm:inline">
|
|
/ templates
|
|
</span>
|
|
</div>
|
|
<nav className="flex items-center gap-1.5 sm:gap-2">
|
|
{loggedIn ? (
|
|
<>
|
|
{/* Dashboard link + "+ New server" pill hidden on mobile —
|
|
the UserMenu (avatar) + the MobileActionBar below cover
|
|
both navigation paths there. */}
|
|
<Link
|
|
href="/dashboard"
|
|
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
|
|
>
|
|
Dashboard
|
|
</Link>
|
|
<Link
|
|
href="/servers/new"
|
|
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
|
|
>
|
|
+ New server
|
|
</Link>
|
|
<UserMenu />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link
|
|
href="/"
|
|
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
|
|
>
|
|
Home
|
|
</Link>
|
|
<Link
|
|
href="/login"
|
|
className="rounded-md bg-[--color-accent] px-2.5 py-1 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:px-3 sm:py-1.5 sm:text-[12.5px]"
|
|
>
|
|
Start building
|
|
</Link>
|
|
</>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main
|
|
className={cn(
|
|
'mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6 sm:py-12',
|
|
// Bottom-bar clearance on mobile for logged-in users only — the
|
|
// MobileActionBar is fixed bottom and would overlap the last row
|
|
// of template cards otherwise.
|
|
loggedIn && 'pb-24 sm:pb-12',
|
|
)}
|
|
>
|
|
<header className="mb-6 max-w-2xl sm:mb-8">
|
|
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
|
Marketplace
|
|
</div>
|
|
<h1 className="mt-2 text-[24px] font-semibold tracking-tight sm:text-[32px]">
|
|
MCP server templates
|
|
</h1>
|
|
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted] sm:mt-3 sm:text-[14px]">
|
|
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.
|
|
</p>
|
|
</header>
|
|
|
|
{/* 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. */}
|
|
<div className="mb-6 flex flex-col gap-3 border-b border-[--color-border] pb-4 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => 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. */}
|
|
<div className="-mx-1 flex items-center gap-2 overflow-x-auto px-1 sm:m-0 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:p-0">
|
|
{loggedIn && (
|
|
<>
|
|
<div className="flex shrink-0 gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
|
|
{(['all', 'mine'] as Scope[]).map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setScope(s)}
|
|
className={cn(
|
|
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
|
|
s === scope
|
|
? 'bg-[--color-bg-elevated] text-[--color-fg]'
|
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
|
|
)}
|
|
>
|
|
{s === 'all' ? 'All' : 'Mine'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
|
|
</>
|
|
)}
|
|
|
|
{scope === 'all' && (
|
|
<>
|
|
<div className="flex shrink-0 gap-1">
|
|
{(['trending', 'top'] as Sort[]).map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setSort(s)}
|
|
className={cn(
|
|
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
|
|
s === sort
|
|
? 'bg-[--color-bg-elevated] text-[--color-fg]'
|
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
|
|
)}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
|
|
</>
|
|
)}
|
|
|
|
<select
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
// Hard width-cap — without it a long category name in the
|
|
// selected slot would blow the chip row out on mobile and
|
|
// push the scrollable region beyond the viewport. Native
|
|
// <select> 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]"
|
|
>
|
|
<option value="">All categories</option>
|
|
{categories.map((c) => (
|
|
<option key={c} value={c}>
|
|
{c}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading…</p>}
|
|
|
|
{visible && visible.length === 0 && (
|
|
<div className="panel p-12 text-center">
|
|
{scope === 'mine' ? (
|
|
<>
|
|
<p className="text-[14px] text-[--color-fg]">You haven't published any templates.</p>
|
|
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
|
|
Build a server, then tick <span className="mono">Share as template</span> on the
|
|
done screen — or use the Publish tab on any live server.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-[14px] text-[--color-fg]">No templates yet.</p>
|
|
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
|
|
Build a server you're proud of and share it to the marketplace.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{visible && visible.length > 0 && (
|
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
{visible.map((t) => (
|
|
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<footer className="border-t border-[--color-border] py-8">
|
|
<div className="mx-auto max-w-6xl px-4 text-[12px] text-[--color-fg-subtle] sm:px-6">
|
|
Every template is isolated: forking creates your own container with your own secrets.
|
|
</div>
|
|
</footer>
|
|
|
|
{/* Mobile tab-bar — only when signed in. Logged-out marketplace
|
|
browsing keeps the simple marketing chrome (login CTA in header). */}
|
|
{loggedIn && <MobileActionBar />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Link
|
|
href={href}
|
|
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<h2 className="text-[14.5px] font-semibold tracking-tight">{t.title}</h2>
|
|
{t.verified && (
|
|
<span
|
|
className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-1.5 py-0.5 text-[9.5px] font-medium text-[--color-accent]"
|
|
title="Verified by BuildMyMCPServer"
|
|
>
|
|
<ShieldCheck size={9} /> verified
|
|
</span>
|
|
)}
|
|
</div>
|
|
{showStatus ? (
|
|
<span
|
|
className={cn(
|
|
'mono rounded-full border px-1.5 py-0.5 text-[9.5px]',
|
|
STATUS_STYLE[t.status],
|
|
)}
|
|
>
|
|
{t.status}
|
|
</span>
|
|
) : (
|
|
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[10px] text-[--color-fg-subtle]">
|
|
{t.category}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
|
{t.shortDescription}
|
|
</p>
|
|
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
|
|
<div className="flex items-center gap-3">
|
|
<span className="inline-flex items-center gap-1 mono" title="Total forks">
|
|
<GitFork size={11} />
|
|
{t.forkCount}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 mono" title="Active deployments">
|
|
<Activity size={11} />
|
|
{t.activeDeployments}
|
|
</span>
|
|
</div>
|
|
<span className="truncate">
|
|
{showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|