feat(marketplace): dashboard nav link + My-templates filter
The logged-in user can now reach the marketplace and filter to their own templates. Dashboard nav: - Added 'Marketplace' item (Overview · Servers · Marketplace · Audit · Settings). /templates page — login-aware: - Detects session via /v1/auth/me. Logged-in users get a 'Dashboard' + '+ New server' header instead of 'Home' + 'Start building'. - New [All templates | My templates] scope toggle, shown only when logged in. - 'My templates' loads GET /v1/templates/mine and shows EVERY status the user owns (public / hidden / draft / takedown) with a colored status badge on each card — so a template you unshared doesn't appear to have vanished. - Sort tabs (trending/top/newest) hide in 'mine' scope — meaningless for a handful of own templates. Category filter + search still apply (client-side). - Takedown cards link to the source server's Publish tab instead of the detail route (which 410s); everything else opens the detail page. Backend: - GET /v1/templates/mine (requireAuth) — all own templates, any status, registered before /:slug so the static route always wins the match. - GET /v1/templates/:slug — now does an optional session check: the OWNER can view their own hidden/draft template (so a 'My templates' card click never dead-ends in a 404). takedown stays 410 for everyone, owner included — that's an admin decision, not the owner's to reverse. Detail page: - Fork CTA is gated on status === 'public'. For a non-public template the owner sees an amber 'not forkable — re-share from the Publish tab' notice plus a 'Manage in server' link, instead of a Fork button that would fail silently. Verified: - GET /v1/templates/mine → marco's 1 template; 401 without auth - Owner GET of a hidden template → 200 status:hidden; anon → 404 - Dashboard nav shows Marketplace (screenshot) - /templates 'My templates' toggle → only own template, public badge, sort tabs hidden (screenshot)
This commit is contained in:
parent
a189111782
commit
414903f16d
@ -16,6 +16,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from '@bmm/db';
|
} from '@bmm/db';
|
||||||
import { GeneratorSpec } from '@bmm/types';
|
import { GeneratorSpec } from '@bmm/types';
|
||||||
|
import { getSession } from '@bmm/auth';
|
||||||
import { requireAuth, requireAdmin } from '../plugins/session.js';
|
import { requireAuth, requireAdmin } from '../plugins/session.js';
|
||||||
import { audit } from '../lib/audit.js';
|
import { audit } from '../lib/audit.js';
|
||||||
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
|
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
|
||||||
@ -345,6 +346,33 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.send({ templates: ranked, categories: CATEGORIES });
|
return reply.send({ templates: ranked, categories: CATEGORIES });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- My templates (authed — all statuses, for the marketplace "Mine" filter) ----
|
||||||
|
// Registered before /:slug so the static segment always wins the router match.
|
||||||
|
app.get('/v1/templates/mine', { preHandler: requireAuth }, async (req, reply) => {
|
||||||
|
const user = req.user!;
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(templates)
|
||||||
|
.where(eq(templates.ownerUserId, user.userId))
|
||||||
|
.orderBy(desc(templates.createdAt));
|
||||||
|
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
rows.map(async (t) => {
|
||||||
|
const [active] = await db
|
||||||
|
.select({ c: count() })
|
||||||
|
.from(mcpServers)
|
||||||
|
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
ownerName: user.email.split('@')[0],
|
||||||
|
ownerOrgName: null,
|
||||||
|
activeDeployments: Number(active?.c ?? 0),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return reply.send({ templates: enriched, categories: CATEGORIES });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Detail ----
|
// ---- Detail ----
|
||||||
app.get('/v1/templates/:slug', async (req, reply) => {
|
app.get('/v1/templates/:slug', async (req, reply) => {
|
||||||
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
|
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
|
||||||
@ -365,16 +393,19 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' });
|
if (!row) return reply.code(404).send({ error: 'not_found' });
|
||||||
if (row.template.status === 'takedown') {
|
if (row.template.status === 'takedown') {
|
||||||
|
// Takedown is an admin decision — sealed for everyone, including the owner.
|
||||||
return reply.code(410).send({
|
return reply.code(410).send({
|
||||||
error: 'taken_down',
|
error: 'taken_down',
|
||||||
reason: row.template.takedownReason,
|
reason: row.template.takedownReason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (row.template.status !== 'public') {
|
if (row.template.status !== 'public') {
|
||||||
// hidden / draft — only owner can view
|
// hidden / draft — visible only to the owner (optional auth check).
|
||||||
// Note: viewing endpoint is public, so we 404 to non-owners.
|
const session = await getSession(req.cookies['bmm_session']);
|
||||||
// (Owner UI would use a separate auth'd endpoint; out of scope for v1.)
|
const isOwner = session != null && session.userId === row.template.ownerUserId;
|
||||||
return reply.code(404).send({ error: 'not_found' });
|
if (!isOwner) {
|
||||||
|
return reply.code(404).send({ error: 'not_found' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [active] = await db
|
const [active] = await db
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
import { LayoutGrid, Server, Settings, FileClock } from 'lucide-react';
|
import { LayoutGrid, Server, Settings, FileClock, Package } from 'lucide-react';
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@ -16,6 +16,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
<NavLink href="/servers" icon={<Server size={13} />}>
|
<NavLink href="/servers" icon={<Server size={13} />}>
|
||||||
Servers
|
Servers
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink href="/templates" icon={<Package size={13} />}>
|
||||||
|
Marketplace
|
||||||
|
</NavLink>
|
||||||
<NavLink href="/audit" icon={<FileClock size={13} />}>
|
<NavLink href="/audit" icon={<FileClock size={13} />}>
|
||||||
Audit
|
Audit
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ interface TemplateDetail {
|
|||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
longDescription: string | null;
|
longDescription: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
|
status: 'draft' | 'public' | 'hidden' | 'takedown';
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
forkCount: number;
|
forkCount: number;
|
||||||
activeDeployments: number;
|
activeDeployments: number;
|
||||||
@ -37,6 +38,7 @@ interface TemplateDetail {
|
|||||||
scopes: string[];
|
scopes: string[];
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
ownerOrgName: string | null;
|
ownerOrgName: string | null;
|
||||||
|
sourceServerId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,12 +210,31 @@ export default function TemplateDetail() {
|
|||||||
|
|
||||||
<aside className="space-y-3">
|
<aside className="space-y-3">
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<Button variant="primary" size="lg" className="w-full" onClick={useTemplate}>
|
{template.status === 'public' ? (
|
||||||
Fork this template →
|
<>
|
||||||
</Button>
|
<Button variant="primary" size="lg" className="w-full" onClick={useTemplate}>
|
||||||
<p className="mt-2 text-[11.5px] text-[--color-fg-muted]">
|
Fork this template →
|
||||||
One click → your own isolated container.
|
</Button>
|
||||||
</p>
|
<p className="mt-2 text-[11.5px] text-[--color-fg-muted]">
|
||||||
|
One click → your own isolated container.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border border-amber-400/30 bg-amber-400/5 p-2.5 text-[12px] text-amber-200/90">
|
||||||
|
This template is <span className="mono">{template.status}</span> — not
|
||||||
|
forkable. {template.sourceServerId ? 'Re-share it from the server’s Publish tab to allow forks.' : ''}
|
||||||
|
</div>
|
||||||
|
{template.sourceServerId && (
|
||||||
|
<a
|
||||||
|
href={`/servers/${template.sourceServerId}`}
|
||||||
|
className="mt-2 inline-flex h-8 w-full items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[12.5px] text-[--color-fg] transition-colors hover:bg-[--color-bg-subtle]"
|
||||||
|
>
|
||||||
|
Manage in server →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel p-3 text-[12px]">
|
<div className="panel p-3 text-[12px]">
|
||||||
|
|||||||
@ -14,38 +14,77 @@ interface Template {
|
|||||||
title: string;
|
title: string;
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
status: 'draft' | 'public' | 'hidden' | 'takedown';
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
forkCount: number;
|
forkCount: number;
|
||||||
activeDeployments: number;
|
activeDeployments: number;
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
ownerOrgName: string | null;
|
ownerOrgName: string | null;
|
||||||
|
sourceServerId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sort = 'trending' | 'top' | 'newest';
|
type Sort = 'trending' | 'top' | 'newest';
|
||||||
|
type Scope = 'all' | 'mine';
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<Template['status'], string> = {
|
||||||
|
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
|
||||||
|
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
|
||||||
|
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
|
||||||
|
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
|
||||||
|
};
|
||||||
|
|
||||||
export default function TemplatesMarketplace() {
|
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 [templates, setTemplates] = useState<Template[] | null>(null);
|
||||||
const [categories, setCategories] = useState<string[]>([]);
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
const [sort, setSort] = useState<Sort>('trending');
|
const [sort, setSort] = useState<Sort>('trending');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Detect login state once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams({ sort });
|
apiFetch<{ user: { email: string } }>('/v1/auth/me')
|
||||||
if (category) params.set('category', category);
|
.then((r) => setMe({ email: r.user.email }))
|
||||||
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`).then((r) => {
|
.catch(() => setMe(null));
|
||||||
setTemplates(r.templates);
|
}, []);
|
||||||
setCategories(r.categories);
|
|
||||||
});
|
|
||||||
}, [sort, category]);
|
|
||||||
|
|
||||||
const visible = templates?.filter((t) =>
|
// Load templates whenever scope/sort/category changes
|
||||||
search
|
useEffect(() => {
|
||||||
? t.title.toLowerCase().includes(search.toLowerCase()) ||
|
setTemplates(null);
|
||||||
t.shortDescription.toLowerCase().includes(search.toLowerCase())
|
if (scope === 'mine') {
|
||||||
: true,
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
@ -56,18 +95,37 @@ export default function TemplatesMarketplace() {
|
|||||||
<span className="text-[12.5px] text-[--color-fg-subtle]">/ templates</span>
|
<span className="text-[12.5px] text-[--color-fg-subtle]">/ templates</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="flex items-center gap-2">
|
||||||
<Link
|
{loggedIn ? (
|
||||||
href="/"
|
<>
|
||||||
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
<Link
|
||||||
>
|
href="/dashboard"
|
||||||
Home
|
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||||
</Link>
|
>
|
||||||
<Link
|
Dashboard
|
||||||
href="/login"
|
</Link>
|
||||||
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
<Link
|
||||||
>
|
href="/servers/new"
|
||||||
Start building
|
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||||
</Link>
|
>
|
||||||
|
+ New server
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||||
|
>
|
||||||
|
Start building
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -87,24 +145,52 @@ export default function TemplatesMarketplace() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-3 border-b border-[--color-border] pb-4">
|
<div className="mb-6 flex flex-wrap items-center gap-3 border-b border-[--color-border] pb-4">
|
||||||
<div className="flex gap-1">
|
{loggedIn && (
|
||||||
{(['trending', 'top', 'newest'] as Sort[]).map((s) => (
|
<>
|
||||||
<button
|
<div className="flex gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
|
||||||
key={s}
|
{(['all', 'mine'] as Scope[]).map((s) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSort(s)}
|
key={s}
|
||||||
className={cn(
|
type="button"
|
||||||
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
|
onClick={() => setScope(s)}
|
||||||
s === sort
|
className={cn(
|
||||||
? 'bg-[--color-bg-elevated] text-[--color-fg]'
|
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
|
||||||
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
|
s === scope
|
||||||
)}
|
? 'bg-[--color-bg-elevated] text-[--color-fg]'
|
||||||
>
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
|
||||||
{s}
|
)}
|
||||||
</button>
|
>
|
||||||
))}
|
{s === 'all' ? 'All templates' : 'My templates'}
|
||||||
</div>
|
</button>
|
||||||
<div className="mx-2 h-4 w-px bg-[--color-border]" />
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-px bg-[--color-border]" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scope === 'all' && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['trending', 'top', 'newest'] as Sort[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSort(s)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
|
||||||
|
s === sort
|
||||||
|
? 'bg-[--color-bg-elevated] text-[--color-fg]'
|
||||||
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-px bg-[--color-border]" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
@ -126,62 +212,33 @@ export default function TemplatesMarketplace() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!visible && (
|
{!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading…</p>}
|
||||||
<p className="mono text-[12px] text-[--color-fg-muted]">Loading…</p>
|
|
||||||
)}
|
|
||||||
{visible && visible.length === 0 && (
|
{visible && visible.length === 0 && (
|
||||||
<div className="panel p-12 text-center">
|
<div className="panel p-12 text-center">
|
||||||
<p className="text-[14px] text-[--color-fg]">No templates yet.</p>
|
{scope === 'mine' ? (
|
||||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
|
<>
|
||||||
Build a server you're proud of and click <span className="mono">Publish as template</span>{' '}
|
<p className="text-[14px] text-[--color-fg]">You haven't published any templates.</p>
|
||||||
on its detail page.
|
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
|
||||||
</p>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visible && visible.length > 0 && (
|
{visible && visible.length > 0 && (
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{visible.map((t) => (
|
{visible.map((t) => (
|
||||||
<Link
|
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
|
||||||
key={t.id}
|
|
||||||
href={`/templates/${t.slug}`}
|
|
||||||
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>
|
|
||||||
<span className="mono text-[10px] rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 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">
|
|
||||||
by {t.ownerName ?? t.ownerOrgName ?? 'anonymous'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -195,3 +252,65 @@ export default function TemplatesMarketplace() {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user