feat(web): Next.js 15 shell — design tokens, landing, auth pages

This commit is contained in:
Marco Sadjadi
2026-05-19 00:30:20 +02:00
parent efa2c3f30d
commit f2238f2e6b
18 changed files with 938 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
'use client';
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { cn } from '@/lib/cn';
export interface CodeBlockProps {
code: string;
language?: string;
label?: string;
className?: string;
}
export function CodeBlock({ code, language, label, className }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
async function onCopy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {}
}
return (
<div className={cn('panel-subtle relative overflow-hidden', className)}>
{(label || language) && (
<div className="flex items-center justify-between border-b border-[--color-border] px-3 py-2">
<span className="mono text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
{label ?? language}
</span>
<button
type="button"
onClick={onCopy}
aria-label="Copy"
className="inline-flex items-center gap-1.5 rounded-sm text-[11px] text-[--color-fg-subtle] transition-colors duration-200 ease-out hover:text-[--color-fg]"
>
{copied ? (
<>
<Check size={12} /> copied
</>
) : (
<>
<Copy size={12} /> copy
</>
)}
</button>
</div>
)}
{!label && !language && (
<button
type="button"
onClick={onCopy}
aria-label="Copy"
className="absolute right-2 top-2 inline-flex items-center gap-1.5 rounded-sm border border-[--color-border] bg-[--color-bg-elevated] px-2 py-1 text-[11px] text-[--color-fg-subtle] transition-colors duration-200 ease-out hover:text-[--color-fg]"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
)}
<pre className="mono overflow-x-auto px-3 py-3 text-[12.5px] leading-relaxed text-[--color-fg]">
<code>{code}</code>
</pre>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import { cn } from '@/lib/cn';
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={cn(
'h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[13px] text-[--color-fg] placeholder:text-[--color-fg-subtle] transition-colors duration-200 ease-out focus:border-[--color-accent] focus:outline-none focus:ring-1 focus:ring-[--color-accent]',
className,
)}
{...props}
/>
);
},
);
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
function Textarea({ className, ...props }, ref) {
return (
<textarea
ref={ref}
className={cn(
'mono w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-3 text-[13px] leading-relaxed text-[--color-fg] placeholder:text-[--color-fg-subtle] transition-colors duration-200 ease-out focus:border-[--color-accent] focus:outline-none focus:ring-1 focus:ring-[--color-accent]',
className,
)}
{...props}
/>
);
},
);
export function Label({
children,
hint,
className,
htmlFor,
}: {
children: React.ReactNode;
hint?: string;
className?: string;
htmlFor?: string;
}) {
return (
<div className={cn('flex items-baseline justify-between', className)}>
<label htmlFor={htmlFor} className="text-[12px] font-medium text-[--color-fg]">
{children}
</label>
{hint && <span className="mono text-[11px] text-[--color-fg-subtle]">{hint}</span>}
</div>
);
}

View File

@@ -0,0 +1,14 @@
import Link from 'next/link';
import { cn } from '@/lib/cn';
export function Logo({ className }: { className?: string }) {
return (
<Link href="/" className={cn('inline-flex items-center gap-2 text-[--color-fg]', className)}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.25" />
<path d="M5 12V6L9 9.5L13 6V12" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-[13px] font-semibold tracking-tight">BuildMyMCPServer</span>
</Link>
);
}

View File

@@ -0,0 +1,38 @@
import type { ServerStatus, BuildStatus } from '@bmm/types';
import { cn } from '@/lib/cn';
type AnyStatus = ServerStatus | BuildStatus;
const map: Record<string, { label: string; dot: string; fg: string; bg: string; pulse?: boolean }> = {
draft: { label: 'Draft', dot: 'bg-zinc-500', fg: 'text-zinc-400', bg: 'bg-zinc-500/10' },
queued: { label: 'Queued', dot: 'bg-zinc-400', fg: 'text-zinc-300', bg: 'bg-zinc-400/10' },
generating: { label: 'Generating', dot: 'bg-amber-400', fg: 'text-amber-300', bg: 'bg-amber-400/10', pulse: true },
building: { label: 'Building', dot: 'bg-amber-400', fg: 'text-amber-300', bg: 'bg-amber-400/10', pulse: true },
deploying: { label: 'Deploying', dot: 'bg-indigo-400', fg: 'text-indigo-300', bg: 'bg-indigo-400/10', pulse: true },
live: { label: 'Live', dot: 'bg-emerald-400', fg: 'text-emerald-300', bg: 'bg-emerald-400/10', pulse: true },
success: { label: 'Success', dot: 'bg-emerald-400', fg: 'text-emerald-300', bg: 'bg-emerald-400/10' },
failed: { label: 'Failed', dot: 'bg-red-400', fg: 'text-red-300', bg: 'bg-red-400/10' },
cancelled: { label: 'Cancelled', dot: 'bg-zinc-500', fg: 'text-zinc-400', bg: 'bg-zinc-500/10' },
paused: { label: 'Paused', dot: 'bg-zinc-500', fg: 'text-zinc-400', bg: 'bg-zinc-500/10' },
};
export function StatusPill({ status, className }: { status: AnyStatus; className?: string }) {
const s = map[status] ?? map.draft;
if (!s) return null;
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-[--color-border] px-2 py-0.5 text-[11px] font-medium tracking-tight',
s.bg,
s.fg,
className,
)}
>
<span
className={cn('size-1.5 rounded-full', s.dot)}
style={s.pulse ? { animation: 'pulse-dot 1.6s ease-in-out infinite' } : undefined}
/>
{s.label}
</span>
);
}

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'lg';
const variants: Record<Variant, string> = {
primary:
'bg-[--color-accent] text-[--color-accent-fg] hover:bg-[#5557e8] active:bg-[#4f51d8] border border-transparent',
secondary:
'bg-[--color-bg-elevated] text-[--color-fg] hover:bg-[--color-bg-subtle] border border-[--color-border]',
ghost:
'bg-transparent text-[--color-fg-muted] hover:text-[--color-fg] hover:bg-[--color-bg-subtle] border border-transparent',
danger:
'bg-transparent text-[--color-danger] hover:bg-[rgba(239,68,68,0.08)] border border-[--color-border]',
};
const sizes: Record<Size, string> = {
sm: 'h-7 px-2.5 text-xs gap-1.5',
md: 'h-8 px-3 text-[13px] gap-2',
lg: 'h-10 px-4 text-sm gap-2',
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant = 'secondary', size = 'md', ...props },
ref,
) {
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md font-medium tracking-tight transition-colors duration-200 ease-out disabled:opacity-50 disabled:pointer-events-none select-none',
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
});