buildmymcpserver/apps/web/app/templates/[slug]/page.tsx
Marco Sadjadi 414903f16d 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)
2026-05-20 17:18:58 +02:00

288 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ShieldCheck, GitFork, Activity, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { CodeBlock } from '@/components/code-block';
interface Tool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
interface SecretHint {
key: string;
description: string;
howToGetUrl?: string;
}
interface TemplateDetail {
id: string;
slug: string;
title: string;
shortDescription: string;
longDescription: string | null;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
toolsSchema: Tool[];
generatedCode: string;
requiredSecrets: SecretHint[];
scopes: string[];
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
export default function TemplateDetail() {
const params = useParams<{ slug: string }>();
const router = useRouter();
const [template, setTemplate] = useState<TemplateDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [showCode, setShowCode] = useState(false);
useEffect(() => {
apiFetch<{ template: TemplateDetail }>(`/v1/templates/${params.slug}`)
.then((r) => setTemplate(r.template))
.catch((e) => {
const detail = (e as { detail?: { error?: string } }).detail;
setError(detail?.error ?? (e as Error).message);
});
}, [params.slug]);
function useTemplate() {
if (!template) return;
router.push(`/servers/new?template=${template.slug}`);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="text-center">
<p className="text-[14px]">Template not found.</p>
<Link href="/templates" className="mt-3 inline-block text-[12px] text-[--color-accent] underline">
Back to marketplace
</Link>
</div>
</div>
);
}
if (!template) {
return (
<div className="px-8 py-20 text-center mono text-[12px] text-[--color-fg-muted]">Loading</div>
);
}
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-5xl items-center justify-between px-6">
<div className="flex items-center gap-3">
<Logo />
<span className="text-[12.5px] text-[--color-fg-subtle]">
/ <Link href="/templates" className="hover:text-[--color-fg]">templates</Link> / {template.slug}
</span>
</div>
<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>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
<div className="grid gap-10 lg:grid-cols-[1fr_300px]">
<div>
<div className="flex items-center gap-2">
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px] text-[--color-fg-muted]">
{template.category}
</span>
{template.verified && (
<span className="inline-flex items-center gap-1 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-2 py-0.5 text-[11px] font-medium text-[--color-accent]">
<ShieldCheck size={10} /> verified
</span>
)}
</div>
<h1 className="mt-3 text-[28px] font-semibold tracking-tight">{template.title}</h1>
<p className="mt-2 text-[14px] leading-relaxed text-[--color-fg-muted]">
{template.shortDescription}
</p>
{template.longDescription && (
<p className="mt-4 whitespace-pre-wrap text-[13.5px] leading-relaxed text-[--color-fg]">
{template.longDescription}
</p>
)}
<section className="mt-10">
<h2 className="text-[16px] font-semibold tracking-tight">
Tools ({template.toolsSchema.length})
</h2>
<div className="mt-3 space-y-3">
{template.toolsSchema.map((tool) => (
<div key={tool.name} className="panel p-3">
<div className="flex items-baseline gap-2">
<span className="mono text-[13px] font-semibold">{tool.name}</span>
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
{Object.keys(tool.inputSchema ?? {}).length} param
{Object.keys(tool.inputSchema ?? {}).length === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
{Object.keys(tool.inputSchema ?? {}).length > 0 && (
<div className="mt-2">
<CodeBlock
label="input schema"
code={JSON.stringify(tool.inputSchema, null, 2)}
/>
</div>
)}
</div>
))}
</div>
</section>
{template.requiredSecrets.length > 0 && (
<section className="mt-10">
<h2 className="text-[16px] font-semibold tracking-tight">
Credentials you&apos;ll need
</h2>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
When you fork, the wizard asks you for these. Your values stay in your container
the template author never sees them.
</p>
<div className="mt-3 space-y-2">
{template.requiredSecrets.map((s) => (
<div key={s.key} className="panel p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="mono text-[13px] font-semibold">{s.key}</span>
{s.howToGetUrl && (
<a
href={s.howToGetUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[11.5px] text-[--color-accent] hover:underline"
>
How to get one <ExternalLink size={10} />
</a>
)}
</div>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">
{s.description}
</p>
</div>
))}
</div>
</section>
)}
<section className="mt-10">
<button
type="button"
onClick={() => setShowCode((s) => !s)}
className="inline-flex items-center gap-1 text-[14px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-fg-muted]"
>
{showCode ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Generated code ({template.generatedCode.length} chars)
</button>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
Audit before you fork. We re-scan every published template for banned patterns
(eval, child_process, prompt-injection markers).
</p>
{showCode && (
<div className="mt-3">
<CodeBlock label="src/server.ts" code={template.generatedCode} />
</div>
)}
</section>
</div>
<aside className="space-y-3">
<div className="panel p-4">
{template.status === 'public' ? (
<>
<Button variant="primary" size="lg" className="w-full" onClick={useTemplate}>
Fork this template
</Button>
<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 servers 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 className="panel p-3 text-[12px]">
<Row label="Forks" value={template.forkCount} icon={<GitFork size={11} />} />
<Row
label="Live deployments"
value={template.activeDeployments}
icon={<Activity size={11} />}
/>
<Row label="Category" value={template.category} mono />
<Row
label="Published"
value={new Date(template.createdAt).toLocaleDateString()}
/>
<Row label="Author" value={template.ownerName ?? template.ownerOrgName ?? 'anonymous'} />
</div>
<div className="panel p-3 text-[11.5px] leading-relaxed text-[--color-fg-muted]">
<strong className="text-[--color-fg]">Forking is safe.</strong> Your fork gets its own
Docker container, its own port, its own AES-256-encrypted secrets. The template
author has no visibility into your traffic or data.
</div>
</aside>
</div>
</main>
</div>
);
}
function Row({
label,
value,
mono,
icon,
}: {
label: string;
value: string | number;
mono?: boolean;
icon?: React.ReactNode;
}) {
return (
<div className="flex items-baseline justify-between gap-3 py-1">
<span className="text-[--color-fg-subtle]">{label}</span>
<span className={`inline-flex items-center gap-1.5 ${mono ? 'mono' : ''} text-[--color-fg]`}>
{icon}
{value}
</span>
</div>
);
}