buildmymcpserver/apps/web/app/templates/page.tsx
Marco Sadjadi 00c6692c7a
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
feat(web): mobile-responsive /templates + drop pre-launch SiteBanner
Two related polish items:

1. Remove the global blue Preview banner from app/layout.tsx and delete
   the SiteBanner component. The component's own comment said "Remove
   once the service is open for production use" — Stripe live billing,
   OAuth, and per-runner TLS are all wired now, so the pre-launch notice
   is misleading.

2. Mobile-responsive treatment for the standalone /templates page (it
   lives outside (dashboard) layout, so it didn't inherit the new
   mobile chrome from the dashboard pass):
   - Top header tightened: "/templates" breadcrumb + Dashboard link +
     "+ New server" pill all hidden on mobile (the avatar UserMenu +
     bottom MobileActionBar cover those paths).
   - Logged-in users now get the same MobileActionBar tab-bar at the
     bottom (Market tab active), giving consistent app-shell across
     dashboard pages.
   - Filter row stacks vertically on mobile with search on top (thumb
     reach), then a horizontally-scrollable chip row for scope / sort /
     category so segmented controls don't squeeze below their min-width.
   - h1 scales 32px → 24px on mobile; padding tightened to px-4 py-8.
   - main gets pb-24 when logged in so cards clear the tab bar.

Logged-out marketplace browsing keeps the simpler marketing chrome
(Logo + "Start building" CTA) — no tab-bar, since visitors don't have
a dashboard to navigate into yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:43:56 +02:00

346 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' | 'newest';
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', 'newest'] 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)}
className="h-7 shrink-0 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none"
>
<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&apos;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&apos;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>
);
}