feat(conversion): Google-first login, truthful dashboard, SSR marketplace, template seeding
- login: OAuth/email on top, phone collapsed behind 'Sign in with phone instead' (prod providers: google on, email off, sms on — a developer should never see a phone field as the front door) - dashboard: plan card wired to GET /v1/billing/status; calls card shows '—' + pointer to per-server metrics (no user-facing usage endpoint exists; previous card showed invented '0 of 100,000 / Hobby') - templates: server-rendered grid (revalidate 300) via fetchPublicTemplates, client browser hydrates with initial data; inviting empty state with labeled starter ideas instead of 'No templates yet' - servers/new: removed upgrade nag from first analyze wait - scripts/seed-templates.mjs: idempotent dry-run-by-default seeder driving the real preview->create->live->publish flow for 6 first-party templates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
This commit is contained in:
@@ -17,12 +17,17 @@ interface ServerRow {
|
||||
|
||||
export default function Overview() {
|
||||
const [servers, setServers] = useState<ServerRow[] | null>(null);
|
||||
const [plan, setPlan] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
|
||||
.then((r) => setServers(r.servers))
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
// Real plan from billing status — never render a hardcoded tier.
|
||||
apiFetch<{ plan: string }>('/v1/billing/status')
|
||||
.then((r) => setPlan(r.plan))
|
||||
.catch(() => setPlan(null));
|
||||
}, []);
|
||||
|
||||
if (err?.includes('401')) {
|
||||
@@ -46,8 +51,16 @@ export default function Overview() {
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
|
||||
<Card label="Calls this period" value="0" sub="of 100,000" />
|
||||
<Card label="Plan" value="Hobby" sub="Upgrade in Settings" />
|
||||
<Card
|
||||
label="Calls this period"
|
||||
value="—"
|
||||
sub="Per-server metrics live on each server page"
|
||||
/>
|
||||
<Card
|
||||
label="Plan"
|
||||
value={plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : '—'}
|
||||
sub="Manage in Settings → Billing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { apiFetch, apiSseStream, humanizeError } from '@/lib/api';
|
||||
import { findSecretInPrompt } from '@bmm/types';
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
@@ -558,14 +557,6 @@ function NewServerPageInner() {
|
||||
drafting the tool spec. Usually{' '}
|
||||
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
|
||||
</p>
|
||||
{userPlan === 'hobby' && (
|
||||
<p className="mt-2 text-[11px] text-[--color-fg-muted]">
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
Upgrade to Pro
|
||||
</Link>{' '}
|
||||
for ~3× faster analysis with Claude Haiku.
|
||||
</p>
|
||||
)}
|
||||
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
|
||||
{elapsedSec}s elapsed
|
||||
</p>
|
||||
|
||||
@@ -203,9 +203,11 @@ export default function LoginPage() {
|
||||
sms: false,
|
||||
email: false,
|
||||
});
|
||||
// Default to SMS — email is off by default until an SMTP/Resend provider
|
||||
// is wired. The effect below flips to 'email' if the backend says it's on.
|
||||
const [method, setMethod] = useState<'email' | 'phone'>('phone');
|
||||
// Phone sign-in is deliberately collapsed behind a text link whenever any
|
||||
// other provider (OAuth or email) is available — a developer evaluating the
|
||||
// product should never see a phone-number field as the front door. It only
|
||||
// renders expanded when SMS is the sole configured provider.
|
||||
const [phoneOpen, setPhoneOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Email magic-link
|
||||
@@ -226,9 +228,9 @@ export default function LoginPage() {
|
||||
)
|
||||
.then((p) => {
|
||||
setProviders(p);
|
||||
// Pick the most-likely method up-front: email if enabled, else SMS.
|
||||
if (p.email) setMethod('email');
|
||||
else if (p.sms) setMethod('phone');
|
||||
// SMS as the only provider → show the phone form directly (no point
|
||||
// hiding the sole sign-in method behind a toggle).
|
||||
if (p.sms && !p.email && !p.google && !p.github) setPhoneOpen(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const err = new URLSearchParams(window.location.search).get('error');
|
||||
@@ -317,7 +319,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOAuth && (
|
||||
{hasOAuth && (providers.email || providers.sms) && (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-[--color-border]" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
@@ -327,35 +329,8 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab toggle only shown when BOTH email and SMS are enabled — if just
|
||||
one is configured, that method's form renders directly without a
|
||||
useless one-tab toggle. */}
|
||||
{providers.sms && providers.email && (
|
||||
<div
|
||||
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
||||
>
|
||||
{(['email', 'phone'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethod(m);
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
|
||||
method === m
|
||||
? 'bg-[--color-bg-subtle] text-[--color-fg]'
|
||||
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
|
||||
}`}
|
||||
>
|
||||
{m === 'email' ? 'Email' : 'Phone'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
||||
{method === 'email' && providers.email && emailState !== 'sent' && (
|
||||
{providers.email && !phoneOpen && emailState !== 'sent' && (
|
||||
<form onSubmit={sendMagicLink} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
@@ -381,7 +356,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{method === 'email' && providers.email && emailState === 'sent' && (
|
||||
{providers.email && !phoneOpen && emailState === 'sent' && (
|
||||
<div className="panel p-4">
|
||||
<p className="text-[13px]">
|
||||
Magic link sent to <span className="mono">{email}</span>.
|
||||
@@ -392,15 +367,11 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === 'phone' && smsStep === 'phone' && (
|
||||
{providers.sms && phoneOpen && smsStep === 'phone' && (
|
||||
<form onSubmit={requestSmsCode} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="country">Country</Label>
|
||||
<CountryPicker
|
||||
countries={COUNTRIES}
|
||||
value={country}
|
||||
onChange={setCountry}
|
||||
/>
|
||||
<CountryPicker countries={COUNTRIES} value={country} onChange={setCountry} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="phone" hint={dialFor(country)}>
|
||||
@@ -429,7 +400,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{method === 'phone' && smsStep === 'code' && (
|
||||
{providers.sms && phoneOpen && smsStep === 'code' && (
|
||||
<form onSubmit={verifySmsCode} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
|
||||
@@ -471,6 +442,35 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
|
||||
|
||||
{/* Phone sign-in stays available but demoted: expand link when any
|
||||
other provider exists, collapse link to get back. */}
|
||||
{providers.sms && !phoneOpen && (hasOAuth || providers.email) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPhoneOpen(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
|
||||
>
|
||||
Sign in with phone number instead
|
||||
</button>
|
||||
)}
|
||||
{providers.sms && phoneOpen && (hasOAuth || providers.email) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPhoneOpen(false);
|
||||
setSmsStep('phone');
|
||||
setCode('');
|
||||
setError(null);
|
||||
}}
|
||||
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
|
||||
>
|
||||
← Use another sign-in method
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
||||
|
||||
@@ -1,351 +1,12 @@
|
||||
'use client';
|
||||
import { fetchPublicTemplates } from '@/lib/templates-server';
|
||||
import { TemplatesBrowser } from './templates-browser';
|
||||
|
||||
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';
|
||||
// Server component: fetch the default (all/trending) template list at render
|
||||
// time so the marketplace grid is present in the initial HTML for crawlers.
|
||||
// All interactivity (search, filters, scope) lives in TemplatesBrowser.
|
||||
export const revalidate = 300;
|
||||
|
||||
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>
|
||||
);
|
||||
export default async function TemplatesPage() {
|
||||
const { templates, categories } = await fetchPublicTemplates();
|
||||
return <TemplatesBrowser initialTemplates={templates} initialCategories={categories} />;
|
||||
}
|
||||
|
||||
395
apps/web/app/templates/templates-browser.tsx
Normal file
395
apps/web/app/templates/templates-browser.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/input';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { MobileActionBar } from '@/components/mobile-action-bar';
|
||||
import { UserMenu } from '@/components/user-menu';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { TemplateSummary } from '@/lib/templates-server';
|
||||
import { Activity, GitFork, Lightbulb, ShieldCheck } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Template = TemplateSummary;
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
// Honest inspiration cards for the young-marketplace empty state — clearly
|
||||
// labeled ideas, not fake templates with invented usage numbers.
|
||||
const STARTER_IDEAS: { title: string; desc: string }[] = [
|
||||
{
|
||||
title: 'Notion workspace search',
|
||||
desc: 'search_pages + get_page_content over your Notion API key. One prompt, ~60s to live.',
|
||||
},
|
||||
{
|
||||
title: 'Read-only PostgreSQL',
|
||||
desc: 'Let Claude query your tables with schema introspection — no write access.',
|
||||
},
|
||||
{
|
||||
title: 'Wrap any REST API',
|
||||
desc: 'Turn the endpoints you already have into typed MCP tools behind OAuth.',
|
||||
},
|
||||
];
|
||||
|
||||
export function TemplatesBrowser({
|
||||
initialTemplates,
|
||||
initialCategories,
|
||||
}: {
|
||||
initialTemplates: Template[];
|
||||
initialCategories: string[];
|
||||
}) {
|
||||
const [me, setMe] = useState<{ email: string } | null | undefined>(undefined);
|
||||
const [scope, setScope] = useState<Scope>('all');
|
||||
const [templates, setTemplates] = useState<Template[] | null>(initialTemplates);
|
||||
const [categories, setCategories] = useState<string[]>(initialCategories);
|
||||
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. The first run is
|
||||
// skipped: the server component already fetched the default (all/trending)
|
||||
// list and passed it as props, so the grid is in the SSR HTML for crawlers.
|
||||
const skippedInitialLoad = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!skippedInitialLoad.current) {
|
||||
skippedInitialLoad.current = true;
|
||||
return;
|
||||
}
|
||||
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 && scope === 'mine' && (
|
||||
<div className="panel p-12 text-center">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible && visible.length === 0 && scope === 'all' && (
|
||||
<div>
|
||||
<div className="panel p-8 text-center sm:p-10">
|
||||
<p className="text-[15px] font-medium text-[--color-fg]">
|
||||
The marketplace is young — be one of the first to publish.
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Every server you build can be shared as a forkable template (your credentials never
|
||||
travel with it). Not sure where to start? The{' '}
|
||||
<Link href="/guides" className="text-[--color-accent] hover:underline">
|
||||
guides
|
||||
</Link>{' '}
|
||||
walk through hosting and auth.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-8 mb-3 flex items-center gap-1.5 text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
<Lightbulb size={12} /> Starter ideas — build one from a single prompt
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{STARTER_IDEAS.map((idea) => (
|
||||
<Link
|
||||
key={idea.title}
|
||||
href="/login"
|
||||
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||
>
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">{idea.title}</h2>
|
||||
<p className="mt-1.5 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
{idea.desc}
|
||||
</p>
|
||||
<span className="mt-3 text-[11.5px] text-[--color-accent]">Build this →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,44 @@ export async function fetchTemplate(slug: string): Promise<TemplateDetail | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface TemplateSummary {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full public template list for server-rendering the marketplace grid so
|
||||
* crawlers see the templates in the initial HTML. Best-effort: returns empty
|
||||
* lists on error so the page still renders (the client component refetches
|
||||
* on filter changes anyway).
|
||||
*/
|
||||
export async function fetchPublicTemplates(): Promise<{
|
||||
templates: TemplateSummary[];
|
||||
categories: string[];
|
||||
}> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/templates?sort=trending`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return { templates: [], categories: [] };
|
||||
const data = (await res.json()) as { templates?: TemplateSummary[]; categories?: string[] };
|
||||
return { templates: data.templates ?? [], categories: data.categories ?? [] };
|
||||
} catch {
|
||||
return { templates: [], categories: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Slugs of public templates, for the sitemap. Best-effort: returns [] on error. */
|
||||
export async function fetchPublicTemplateSlugs(): Promise<string[]> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user