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:
parent
17056d0b30
commit
089074d104
@ -17,12 +17,17 @@ interface ServerRow {
|
|||||||
|
|
||||||
export default function Overview() {
|
export default function Overview() {
|
||||||
const [servers, setServers] = useState<ServerRow[] | null>(null);
|
const [servers, setServers] = useState<ServerRow[] | null>(null);
|
||||||
|
const [plan, setPlan] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
|
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
|
||||||
.then((r) => setServers(r.servers))
|
.then((r) => setServers(r.servers))
|
||||||
.catch((e) => setErr((e as Error).message));
|
.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')) {
|
if (err?.includes('401')) {
|
||||||
@ -46,8 +51,16 @@ export default function Overview() {
|
|||||||
|
|
||||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||||
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
|
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
|
||||||
<Card label="Calls this period" value="0" sub="of 100,000" />
|
<Card
|
||||||
<Card label="Plan" value="Hobby" sub="Upgrade in Settings" />
|
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>
|
||||||
|
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { apiFetch, apiSseStream, humanizeError } from '@/lib/api';
|
import { apiFetch, apiSseStream, humanizeError } from '@/lib/api';
|
||||||
import { findSecretInPrompt } from '@bmm/types';
|
import { findSecretInPrompt } from '@bmm/types';
|
||||||
import { Loader2, RotateCcw, X } from 'lucide-react';
|
import { Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
@ -558,14 +557,6 @@ function NewServerPageInner() {
|
|||||||
drafting the tool spec. Usually{' '}
|
drafting the tool spec. Usually{' '}
|
||||||
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
|
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
|
||||||
</p>
|
</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]">
|
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
|
||||||
{elapsedSec}s elapsed
|
{elapsedSec}s elapsed
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -203,9 +203,11 @@ export default function LoginPage() {
|
|||||||
sms: false,
|
sms: false,
|
||||||
email: false,
|
email: false,
|
||||||
});
|
});
|
||||||
// Default to SMS — email is off by default until an SMTP/Resend provider
|
// Phone sign-in is deliberately collapsed behind a text link whenever any
|
||||||
// is wired. The effect below flips to 'email' if the backend says it's on.
|
// other provider (OAuth or email) is available — a developer evaluating the
|
||||||
const [method, setMethod] = useState<'email' | 'phone'>('phone');
|
// 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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Email magic-link
|
// Email magic-link
|
||||||
@ -226,9 +228,9 @@ export default function LoginPage() {
|
|||||||
)
|
)
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
setProviders(p);
|
setProviders(p);
|
||||||
// Pick the most-likely method up-front: email if enabled, else SMS.
|
// SMS as the only provider → show the phone form directly (no point
|
||||||
if (p.email) setMethod('email');
|
// hiding the sole sign-in method behind a toggle).
|
||||||
else if (p.sms) setMethod('phone');
|
if (p.sms && !p.email && !p.google && !p.github) setPhoneOpen(true);
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
const err = new URLSearchParams(window.location.search).get('error');
|
const err = new URLSearchParams(window.location.search).get('error');
|
||||||
@ -317,7 +319,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasOAuth && (
|
{hasOAuth && (providers.email || providers.sms) && (
|
||||||
<div className="my-5 flex items-center gap-3">
|
<div className="my-5 flex items-center gap-3">
|
||||||
<span className="h-px flex-1 bg-[--color-border]" />
|
<span className="h-px flex-1 bg-[--color-border]" />
|
||||||
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||||
@ -327,35 +329,8 @@ export default function LoginPage() {
|
|||||||
</div>
|
</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'}>
|
<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">
|
<form onSubmit={sendMagicLink} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
@ -381,7 +356,7 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method === 'email' && providers.email && emailState === 'sent' && (
|
{providers.email && !phoneOpen && emailState === 'sent' && (
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<p className="text-[13px]">
|
<p className="text-[13px]">
|
||||||
Magic link sent to <span className="mono">{email}</span>.
|
Magic link sent to <span className="mono">{email}</span>.
|
||||||
@ -392,15 +367,11 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method === 'phone' && smsStep === 'phone' && (
|
{providers.sms && phoneOpen && smsStep === 'phone' && (
|
||||||
<form onSubmit={requestSmsCode} className="space-y-3">
|
<form onSubmit={requestSmsCode} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="country">Country</Label>
|
<Label htmlFor="country">Country</Label>
|
||||||
<CountryPicker
|
<CountryPicker countries={COUNTRIES} value={country} onChange={setCountry} />
|
||||||
countries={COUNTRIES}
|
|
||||||
value={country}
|
|
||||||
onChange={setCountry}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="phone" hint={dialFor(country)}>
|
<Label htmlFor="phone" hint={dialFor(country)}>
|
||||||
@ -429,7 +400,7 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method === 'phone' && smsStep === 'code' && (
|
{providers.sms && phoneOpen && smsStep === 'code' && (
|
||||||
<form onSubmit={verifySmsCode} className="space-y-3">
|
<form onSubmit={verifySmsCode} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
|
<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>}
|
{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>
|
||||||
|
|
||||||
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
<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';
|
// Server component: fetch the default (all/trending) template list at render
|
||||||
import Link from 'next/link';
|
// time so the marketplace grid is present in the initial HTML for crawlers.
|
||||||
import { ShieldCheck, GitFork, Activity } from 'lucide-react';
|
// All interactivity (search, filters, scope) lives in TemplatesBrowser.
|
||||||
import { apiFetch } from '@/lib/api';
|
export const revalidate = 300;
|
||||||
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 {
|
export default async function TemplatesPage() {
|
||||||
id: string;
|
const { templates, categories } = await fetchPublicTemplates();
|
||||||
slug: string;
|
return <TemplatesBrowser initialTemplates={templates} initialCategories={categories} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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. */
|
/** Slugs of public templates, for the sitemap. Best-effort: returns [] on error. */
|
||||||
export async function fetchPublicTemplateSlugs(): Promise<string[]> {
|
export async function fetchPublicTemplateSlugs(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
275
scripts/seed-templates.mjs
Normal file
275
scripts/seed-templates.mjs
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Seed first-party marketplace templates through the real product flow:
|
||||||
|
// preview → create server → wait for live → publish as template.
|
||||||
|
//
|
||||||
|
// The API only publishes templates from LIVE servers with a successful build,
|
||||||
|
// so seeding is the honest path — every seeded template carries real generated
|
||||||
|
// code, exactly what a user's build would produce.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// BMM_SESSION=<bmm_session cookie value> node scripts/seed-templates.mjs # dry-run (default)
|
||||||
|
// BMM_SESSION=... node scripts/seed-templates.mjs --apply # execute
|
||||||
|
// BMM_API_URL=https://api.buildmymcpserver.com BMM_SESSION=... node ... --apply # against prod (deliberate!)
|
||||||
|
//
|
||||||
|
// Secrets: templates whose server needs credentials read them from env
|
||||||
|
// (SEED_<KEY>). Missing secret → that seed is skipped with a warning, never
|
||||||
|
// created with a fake value. echo-demo needs none and always works.
|
||||||
|
//
|
||||||
|
// Idempotent: seeds whose derived slug already exists in GET /v1/templates
|
||||||
|
// are skipped. Rate limits apply (previews/builds per day per plan) — run
|
||||||
|
// under an account with headroom or spread over days.
|
||||||
|
|
||||||
|
const API = process.env.BMM_API_URL ?? 'http://localhost:4000';
|
||||||
|
const SESSION = process.env.BMM_SESSION;
|
||||||
|
const APPLY = process.argv.includes('--apply');
|
||||||
|
const BUILD_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
/** @type {Array<{slug: string, name: string, serverSlug: string, category: string, prompt: string, shortDescription: string, longDescription: string, secretEnv: Record<string, string>, secretHints: Array<{key: string, description: string, howToGetUrl?: string}>}>} */
|
||||||
|
const SEEDS = [
|
||||||
|
{
|
||||||
|
slug: 'echo-demo',
|
||||||
|
name: 'Echo Demo',
|
||||||
|
serverSlug: 'seed-echo-demo',
|
||||||
|
category: 'demo',
|
||||||
|
prompt:
|
||||||
|
'Create a minimal demo MCP server with two tools: echo (returns the input text unchanged) and now (returns the current UTC timestamp in ISO 8601). No external APIs, no secrets.',
|
||||||
|
shortDescription:
|
||||||
|
'The smallest possible MCP server — echo and a UTC clock. Fork it to see the full flow in under a minute.',
|
||||||
|
longDescription:
|
||||||
|
'A dependency-free demo server with two tools: `echo` returns whatever text you send it, `now` returns the current UTC timestamp. Useful as a first fork to watch the build pipeline, test your client connection and inspect the OAuth flow before wiring a real API.',
|
||||||
|
secretEnv: {},
|
||||||
|
secretHints: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'notion-search',
|
||||||
|
name: 'Notion Search',
|
||||||
|
serverSlug: 'seed-notion-search',
|
||||||
|
category: 'productivity',
|
||||||
|
prompt:
|
||||||
|
'Create an MCP server that searches a Notion workspace. Tools: search_pages (query string, returns matching page titles and ids via the Notion search API) and get_page_content (page_id, returns the page blocks as plain text). Auth: NOTION_API_KEY used as a Bearer token against api.notion.com with Notion-Version header.',
|
||||||
|
shortDescription:
|
||||||
|
'Search pages and read content from your Notion workspace. Bring your own integration token.',
|
||||||
|
longDescription:
|
||||||
|
'Two tools against the official Notion API: `search_pages` for workspace-wide search and `get_page_content` to read a page as plain text. Fork it, paste your own internal-integration token, and your AI client can look things up in Notion. Read-only.',
|
||||||
|
secretEnv: { NOTION_API_KEY: 'SEED_NOTION_API_KEY' },
|
||||||
|
secretHints: [
|
||||||
|
{
|
||||||
|
key: 'NOTION_API_KEY',
|
||||||
|
description:
|
||||||
|
'Internal integration secret from notion.so/my-integrations (read scope is enough).',
|
||||||
|
howToGetUrl: 'https://www.notion.so/my-integrations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'github-issues',
|
||||||
|
name: 'GitHub Issues',
|
||||||
|
serverSlug: 'seed-github-issues',
|
||||||
|
category: 'developer-tools',
|
||||||
|
prompt:
|
||||||
|
'Create an MCP server for GitHub issues scoped to a single repository. Tools: list_issues (state filter open/closed/all), get_issue (issue number, returns title/body/labels/comments count) and search_issues (query string). Auth: GITHUB_TOKEN as Bearer token, GITHUB_REPO in owner/repo format used in the API paths.',
|
||||||
|
shortDescription:
|
||||||
|
'List, read and search issues in one GitHub repo. Scoped by a fine-grained token you control.',
|
||||||
|
longDescription:
|
||||||
|
'Three read-only tools against the GitHub REST API, scoped to a single repository via GITHUB_REPO: `list_issues`, `get_issue` and `search_issues`. Use a fine-grained personal access token with Issues read permission and nothing else.',
|
||||||
|
secretEnv: { GITHUB_TOKEN: 'SEED_GITHUB_TOKEN', GITHUB_REPO: 'SEED_GITHUB_REPO' },
|
||||||
|
secretHints: [
|
||||||
|
{
|
||||||
|
key: 'GITHUB_TOKEN',
|
||||||
|
description: 'Fine-grained PAT with read-only Issues permission on the target repo.',
|
||||||
|
howToGetUrl: 'https://github.com/settings/personal-access-tokens',
|
||||||
|
},
|
||||||
|
{ key: 'GITHUB_REPO', description: 'Repository in owner/repo format, e.g. vercel/next.js.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'stripe-readonly',
|
||||||
|
name: 'Stripe Read-Only',
|
||||||
|
serverSlug: 'seed-stripe-readonly',
|
||||||
|
category: 'finance',
|
||||||
|
prompt:
|
||||||
|
'Create a read-only MCP server for Stripe. Tools: list_charges (limit param, returns recent charges with amount/currency/status), get_customer (customer id), list_refunds (limit param). Auth: STRIPE_API_KEY as Bearer token against api.stripe.com. Read-only — never create or mutate anything.',
|
||||||
|
shortDescription:
|
||||||
|
'Look up charges, customers and refunds from Stripe. Use a restricted read-only key.',
|
||||||
|
longDescription:
|
||||||
|
'Read-only Stripe lookups: `list_charges`, `get_customer`, `list_refunds`. Designed for a restricted API key with read-only permissions — the server never writes. Ask your AI “what were yesterday’s failed charges?” instead of opening the dashboard.',
|
||||||
|
secretEnv: { STRIPE_API_KEY: 'SEED_STRIPE_API_KEY' },
|
||||||
|
secretHints: [
|
||||||
|
{
|
||||||
|
key: 'STRIPE_API_KEY',
|
||||||
|
description: 'Restricted key with read-only scopes (Charges, Customers, Refunds).',
|
||||||
|
howToGetUrl: 'https://dashboard.stripe.com/apikeys',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'postgres-readonly',
|
||||||
|
name: 'PostgreSQL Read-Only',
|
||||||
|
serverSlug: 'seed-postgres-readonly',
|
||||||
|
category: 'data',
|
||||||
|
prompt:
|
||||||
|
'Create a read-only PostgreSQL MCP server. Tools: list_tables (returns schema-qualified table names), describe_table (table name, returns columns and types from information_schema) and run_query (SELECT-only — reject any statement that is not a single SELECT). Auth: DATABASE_URL connection string.',
|
||||||
|
shortDescription:
|
||||||
|
'Schema introspection and SELECT-only queries against your Postgres. Nothing gets written.',
|
||||||
|
longDescription:
|
||||||
|
'`list_tables`, `describe_table` and a guarded `run_query` that accepts single SELECT statements only. Point it at a read-only database role for defense in depth — the code additionally rejects non-SELECT statements before execution.',
|
||||||
|
secretEnv: { DATABASE_URL: 'SEED_DATABASE_URL' },
|
||||||
|
secretHints: [
|
||||||
|
{
|
||||||
|
key: 'DATABASE_URL',
|
||||||
|
description: 'postgres:// connection string — use a read-only role.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'rest-wrapper',
|
||||||
|
name: 'REST API Wrapper',
|
||||||
|
serverSlug: 'seed-rest-wrapper',
|
||||||
|
category: 'developer-tools',
|
||||||
|
prompt:
|
||||||
|
'Create an MCP server that wraps a generic REST API. Tools: get_resource (path param appended to a base URL, returns the JSON response) and search_resources (path plus query-string params object). Auth: API_BASE_URL for the base URL and API_TOKEN sent as a Bearer token. Only allow requests to the configured base URL.',
|
||||||
|
shortDescription: 'Point it at any JSON REST API — base URL + token in, typed MCP tools out.',
|
||||||
|
longDescription:
|
||||||
|
'The template for “I just want my existing API in Claude”: configure API_BASE_URL and API_TOKEN, get `get_resource` and `search_resources` tools that only ever call your configured host. Fork it and iterate the prompt to add endpoint-specific tools.',
|
||||||
|
secretEnv: { API_BASE_URL: 'SEED_API_BASE_URL', API_TOKEN: 'SEED_API_TOKEN' },
|
||||||
|
secretHints: [
|
||||||
|
{
|
||||||
|
key: 'API_BASE_URL',
|
||||||
|
description: 'Base URL of your API, e.g. https://api.example.com/v1.',
|
||||||
|
},
|
||||||
|
{ key: 'API_TOKEN', description: 'Bearer token the wrapper sends with every request.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!SESSION) {
|
||||||
|
console.error(
|
||||||
|
'BMM_SESSION is required (value of the bmm_session cookie of the seeding account).',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} path @param {RequestInit} [init] */
|
||||||
|
async function api(path, init = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: `bmm_session=${SESSION}`,
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = body?.detail ?? body?.error ?? res.status;
|
||||||
|
throw new Error(`${init.method ?? 'GET'} ${path} → ${res.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Derive the slug the publish endpoint will generate from a title. */
|
||||||
|
function titleSlug(title) {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Target API: ${API} ${APPLY ? '(APPLY)' : '(dry-run — pass --apply to execute)'}`);
|
||||||
|
|
||||||
|
const existing = await api('/v1/templates?sort=newest');
|
||||||
|
const existingSlugs = new Set((existing.templates ?? []).map((t) => t.slug));
|
||||||
|
|
||||||
|
for (const seed of SEEDS) {
|
||||||
|
const expectedSlug = titleSlug(seed.name);
|
||||||
|
if (existingSlugs.has(expectedSlug) || existingSlugs.has(seed.slug)) {
|
||||||
|
console.log(`✓ ${seed.slug} — already published, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve secrets from env; skip seeds we can't provision honestly.
|
||||||
|
const secrets = {};
|
||||||
|
let missing = null;
|
||||||
|
for (const [key, envName] of Object.entries(seed.secretEnv)) {
|
||||||
|
const val = process.env[envName];
|
||||||
|
if (!val) {
|
||||||
|
missing = envName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
secrets[key] = val;
|
||||||
|
}
|
||||||
|
if (missing) {
|
||||||
|
console.warn(
|
||||||
|
`⚠ ${seed.slug} — skipped: env ${missing} not set (refusing to seed with fake credentials)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log(
|
||||||
|
`→ ${seed.slug} — would run preview → create (${seed.serverSlug}) → wait live → publish`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`→ ${seed.slug} — previewing spec…`);
|
||||||
|
const preview = await api('/v1/servers/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ prompt: seed.prompt }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` spec ok (${preview.spec.tools.length} tools, source=${preview.source}) — creating server…`,
|
||||||
|
);
|
||||||
|
const created = await api('/v1/servers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: seed.name,
|
||||||
|
slug: seed.serverSlug,
|
||||||
|
prompt: seed.prompt,
|
||||||
|
secrets,
|
||||||
|
previewId: preview.previewId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const serverId = created.server.id;
|
||||||
|
|
||||||
|
process.stdout.write(' building');
|
||||||
|
const deadline = Date.now() + BUILD_TIMEOUT_MS;
|
||||||
|
let status = created.server.status;
|
||||||
|
while (status !== 'live') {
|
||||||
|
if (Date.now() > deadline)
|
||||||
|
throw new Error(`${seed.slug}: build timed out (status=${status})`);
|
||||||
|
if (status === 'failed' || status === 'error') throw new Error(`${seed.slug}: build failed`);
|
||||||
|
await sleep(POLL_INTERVAL_MS);
|
||||||
|
const s = await api(`/v1/servers/${serverId}`);
|
||||||
|
status = s.server.status;
|
||||||
|
process.stdout.write('.');
|
||||||
|
}
|
||||||
|
console.log(' live');
|
||||||
|
|
||||||
|
const published = await api('/v1/templates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId,
|
||||||
|
title: seed.name,
|
||||||
|
shortDescription: seed.shortDescription,
|
||||||
|
longDescription: seed.longDescription,
|
||||||
|
category: seed.category,
|
||||||
|
secretHints: seed.secretHints,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`✓ ${seed.slug} — published as /templates/${published.template.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\nSeed failed: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user