feat(web): full layout overhaul — Linear design system
CI / typecheck + build (turbo) (push) Canceled after 0s

Complete UI/UX overhaul, not just style changes:

Layout:
- Dark-mode-first (near-black #08090a canvas, whiteOpacity borders)
- Sticky header with brand + nav + theme toggle
- Sticky sidebar (xl+) with hierarchical doc tree
- Main content in max-w-3xl centered column
- Inter font system (via @font-face + CSS vars)
- Font feature settings cv01/ss03 for Linear-geometric Inter

Components rewritten in Linear aesthetic:
- Homepage: editorial-style doc listing with tags, section headers
- Doc page: breadcrumb-style nav links, metadata bar (author/date/tags),
  clean prose, Related + History sections
- TOC: rehype-slug heading anchors + github-slugger matching
- Create/Edit: inline form with Linear-style inputs/buttons
- Login: centered card-style, brand-indigo CTA button
- Docs index: sectioned listing with tag previews
- Search: dark-themed search with result cards + snippets
- ThemeToggle: system-default + localStorage persistence

Typography:
- prose with custom Tailwind classes matching Linear's:
  headings #f7f8f8 font-light, body #d0d6e0,
  links #7170ff with hover #828fff,
  code blocks #191a1b bg with #23252a border

New deps: rehype-slug (heading anchors), github-slugger (TOC matching)
Files: layout.tsx (overhaul), page.tsx, create/, login/, docs/,
       [section]/[...slug]/page.tsx, Sidebar.tsx, ThemeToggle.tsx,
       TOC.tsx, DocForm.tsx, Markdown.tsx, Search page
This commit is contained in:
asepharyana
2026-08-20 16:57:30 +07:00
parent f8b525d10c
commit 167d95c5e8
13 changed files with 517 additions and 375 deletions
+72 -37
View File
@@ -7,10 +7,7 @@ import Markdown from "@/components/Markdown";
import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC";
// Render at request time. The content lives in Postgres (populated by the
// indexer/worker), which is not available at build time (CI has no DB), so we
// opt out of static generation. At this corpus scale request-time rendering is
// instant.
// Render at request time (content in Postgres, not available at build time).
export const dynamic = "force-dynamic";
interface DocPageProps {
@@ -25,15 +22,22 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const doc = await getDocument(fullSlug);
if (!doc) notFound();
// Check auth for edit mode.
const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
// If ?edit=1 and authenticated → show the edit form.
// Edit mode: inline form
if (edit === "1" && canEdit) {
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Edit: {doc.title}</h1>
<div>
<Link
href={`/${doc.slug}`}
className="text-xs text-[#8a8f98] hover:text-[#d0d6e0] mb-4 inline-block"
>
← Back to {doc.title}
</Link>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6">
Edit: {doc.title}
</h1>
<DocForm
mode="edit"
slug={fullSlug}
@@ -56,38 +60,67 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const revisions = await listRevisions(fullSlug, 10);
return (
<article className="space-y-4">
<div>
<Link href="/" className="text-sm text-zinc-500 hover:underline">
← Back
<article>
<div className="mb-2 flex items-center gap-2 text-xs text-[#62666d]">
<Link
href="/"
className="hover:text-[#d0d6e0] transition-colors"
>
MCPedia
</Link>
<span>/</span>
<Link
href="/docs"
className="hover:text-[#d0d6e0] transition-colors"
>
Docs
</Link>
<h1 className="text-2xl font-semibold tracking-tight mt-2">
{doc.title}
</h1>
<div className="text-xs text-zinc-500 mt-1">
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
</div>
{canEdit && (
<Link
href={`/${doc.slug}?edit=1`}
className="inline-block mt-2 px-3 py-1 text-xs border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
<>
<span>/</span>
<Link
href={`/${doc.slug}?edit=1`}
className="hover:text-[#d0d6e0] text-[#8a8f98] hover:text-[#5e6ad2] transition-colors"
>
Edit
</Link>
</>
)}
</div>
<TOC source={doc.body} />
<h1 className="text-3xl font-light text-[#f7f8f8] mb-2">{doc.title}</h1>
<Markdown source={doc.body} />
<div className="flex items-center gap-3 text-xs text-[#62666d] mb-6">
<span>{doc.author || "unknown"}</span>
<span>•</span>
<span>{new Date(doc.updatedAt).toLocaleDateString()}</span>
{doc.tags.map((t) => (
<span
key={t}
className="px-2 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
<div className="mb-8">
<TOC source={doc.body} />
<Markdown source={doc.body} />
</div>
{related.length > 0 && (
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4">
<h2 className="text-sm font-medium mb-2">Related</h2>
<ul className="text-sm space-y-1">
<aside className="mt-12 pt-6 border-t border-[#1f2022]">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
Related
</h2>
<ul className="space-y-2">
{related.map((r) => (
<li key={r.slug}>
<Link href={`/${r.slug}`} className="hover:underline">
<Link
href={`/${r.slug}`}
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
{r.title}
</Link>
</li>
@@ -97,23 +130,25 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
)}
{revisions.length > 0 && (
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4">
<h2 className="text-sm font-medium mb-2">History</h2>
<ul className="text-sm space-y-1">
<aside className="mt-12 pt-6 border-t border-[#1f2022]">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
History
</h2>
<ul className="text-sm">
{revisions.map((rev) => (
<li
key={rev.id}
className="flex items-center justify-between gap-2"
className="flex items-center justify-between gap-3 py-1.5 border-b border-[#141516]"
>
<span className="text-zinc-600 dark:text-zinc-400">
<span className="text-[#8a8f98]">
#{rev.revisionNo} · {rev.reason} ·{" "}
{new Date(rev.createdAt).toLocaleString()} · {rev.bodyLength} chars
{new Date(rev.createdAt).toLocaleString()}
</span>
<form action="/api/revisions/restore" method="post">
<input type="hidden" name="id" value={rev.id} />
<button
type="submit"
className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-0.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="text-xs text-[#d0d6e0] hover:text-[#5e6ad2] px-2 py-0.5 rounded border border-[#1f2022] hover:border-[#5e6ad2] transition-colors"
>
Restore
</button>
+27 -61
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
interface DocFormProps {
mode: "create" | "edit";
slug?: string;
secret: string; // injected server-side (never visible to the client JS bundle)
secret: string;
initial?: {
title?: string;
body?: string;
@@ -39,20 +39,15 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
.map((t) => t.trim())
.filter(Boolean);
const baseInputCls =
"w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const payload = {
title,
body,
section,
type,
status,
author,
tags: tagList,
};
const payload = { title, body, section, type, status, author, tags: tagList };
try {
let res: Response;
@@ -87,68 +82,50 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
}
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl mx-auto">
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
<div className="p-3 rounded border border-[#274360] text-[#fca5a5] text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
required
/>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Title</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className={baseInputCls} required />
</div>
<div>
<label className="block text-sm font-medium mb-1">Slug</label>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Slug</label>
<input
type="text"
value={slug ?? ""}
onChange={(e) => router.replace(`/docs/${section}/edit?slug=${e.target.value}`)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800"
readOnly
className="w-full px-3 py-2 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
placeholder={section}
/>
<p className="text-xs text-zinc-500 mt-1">URL-safe path under the section (e.g. "websocket/contract").</p>
<p className="text-xs text-[#62666d] mt-1">URL-safe path under the section.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Section</label>
<select
value={section}
onChange={(e) => setSection(e.target.value as typeof section)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Section</label>
<select value={section} onChange={(e) => setSection(e.target.value as typeof section)} className={baseInputCls}>
{SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Type</label>
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className={baseInputCls}>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as typeof status)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value as typeof status)} className={baseInputCls}>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
@@ -156,32 +133,21 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
</div>
<div>
<label className="block text-sm font-medium mb-1">Tags</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
placeholder="comma, separated, tags"
/>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Tags</label>
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} className={baseInputCls} placeholder="comma, separated, tags" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Author</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
className="w-fill px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
/>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Author</label>
<input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} className={baseInputCls} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Body (Markdown)</label>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Body (Markdown)</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900 font-mono text-sm"
className="w-full h-96 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#d0d6e0] placeholder-[#62666d] font-mono text-sm focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
placeholder="# Heading&#10;&#10;Content here..."
/>
</div>
@@ -190,14 +156,14 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
className="px-4 py-2 bg-[#5e6ad2] text-white rounded hover:bg-[#7170ff] disabled:opacity-50 font-medium text-sm"
>
{loading ? "Saving..." : mode === "create" ? "Create" : "Update"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="px-4 py-2 border border-[#23252a] rounded text-[#d0d6e0] hover:text-[#f7f8f8] hover:border-[#3e3e44] transition-colors text-sm"
>
Cancel
</button>
+12 -4
View File
@@ -3,10 +3,18 @@ import rehypeSlug from "rehype-slug";
export default function Markdown({ source }: { source: string }) {
return (
<div className="prose prose-zinc dark:prose-invert max-w-none [&_pre]:bg-zinc-100 dark:[&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_code]:font-mono [&_a]:underline">
<ReactMarkdown rehypePlugins={[rehypeSlug]}>
{source}
</ReactMarkdown>
<div
className="prose prose-lg max-w-none
prose-headings:text-[#f7f8f8] prose-headings:font-light prose-headings:mb-4
prose-p:text-[#d0d6e0] prose-p:mb-4
prose-a:text-[#7170ff] prose-a:no-underline hover:prose-a:text-[#828fff]
prose-code:text-[#d0d6e0] prose-code:bg-[#191a1b] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-[#191a1b] prose-pre:border prose-pre:border-[#23252a] prose-pre:rounded-lg
prose-pre code:bg-transparent prose-pre code:p-0 prose-pre code:text-sm
prose-blockquote:border-l-[#23252a] prose-blockquote:text-[#a0a4a8]
prose-ul:text-[#d0d6e0] prose-ol:text-[#d0d6e0]"
>
<ReactMarkdown rehypePlugins={[rehypeSlug]}>{source}</ReactMarkdown>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
const SECTIONS = [
{ id: "docs", label: "Documentation" },
{ id: "writeups", label: "Writeups" },
{ id: "research", label: "Research" },
{ id: "notes", label: "Notes" },
] as const;
export default async function Sidebar() {
const docs = await listDocuments();
// Build a tree: section → top-level slugs + children
const bySection: Record<string, { slug: string; title: string; depth: number }[]> = {};
for (const doc of docs) {
if (!bySection[doc.section]) bySection[doc.section] = [];
// Count depth from dots in a flattened slug: docs/websocket/contract
// We'll show all docs at their depth level
bySection[doc.section].push({
slug: doc.slug,
title: doc.title,
depth: doc.slug.split("/").length,
});
}
return (
<nav className="h-full overflow-y-auto py-6">
<ul className="space-y-1 px-3 text-sm">
{SECTIONS.map(({ id, label }) => {
const sectionDocs = bySection[id] || [];
if (sectionDocs.length === 0) return null;
return (
<li key={id}>
<div className="text-xs font-medium text-[#62666d] uppercase mb-1 mt-4 first:mt-0">
{label}
</div>
<ul className="space-y-0.5">
{sectionDocs.map((doc) => {
const parts = doc.slug.split("/").slice(1); // remove section prefix
const label = parts[parts.length - 1];
const formatted = label
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
const indent = doc.depth - 1;
return (
<li key={doc.slug} style={{ marginLeft: `${indent * 12}px` }}>
<Link
href={`/${doc.slug}`}
className="block text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b] rounded px-2 py-0.5 transition-colors"
title={doc.title}
>
{formatted || doc.slug}
</Link>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</nav>
);
}
+4 -9
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
const STORAGE_KEY = "mcpedia-theme";
/** Dark mode toggle. Defaults to system preference, persisted in localStorage. */
/** Dark mode toggle — persisted in localStorage, defaults to system preference. */
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
@@ -15,27 +15,22 @@ export default function ThemeToggle() {
(stored === null &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
setDark(initial);
apply(initial);
}, []);
function apply(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark);
}
function toggle() {
const next = !dark;
setDark(next);
localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
apply(next);
document.documentElement.classList.toggle("dark", next);
}
return (
<button
onClick={toggle}
aria-label="Toggle dark mode"
className="text-sm border border-zinc-300 dark:border-zinc-700 rounded px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="text-[#d0d6e0] hover:text-[#f7f8f8] p-1 rounded transition-colors"
>
{dark ? "🌙" : "☀️"}
{dark ? "☀️" : "🌙"}
</button>
);
}
+20 -13
View File
@@ -3,24 +3,23 @@ import { cookies } from "next/headers";
import DocForm from "@/components/DocForm";
import Link from "next/link";
// GET /create — show the document creation form.
// Auth: requires the mcpedia_admin cookie (set via /api/auth/login).
export const dynamic = "force-dynamic";
export default async function CreateDocPage() {
// GET /create — show the document create form.
// Requires admin cookie auth.
export default async function CreatePage() {
const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
if (!canEdit) {
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-xl font-semibold mb-4">Authentication required</h1>
<p className="text-zinc-600 dark:text-zinc-400 mb-4">
<div className="text-center py-16">
<h1 className="text-2xl font-light text-[#f7f8f8] mb-4">
Authentication required
</h1>
<p className="text-[#8a8f98] mb-6">
You must be logged in to create documents.
</p>
<Link
href="/login"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded"
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-[#5e6ad2] text-white hover:bg-[#7170ff] transition-colors font-medium text-sm"
>
Login
</Link>
@@ -29,9 +28,17 @@ export default async function CreateDocPage() {
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Create Document</h1>
<DocForm mode="create" slug="" secret={WEBHOOK_SECRET} />
<div>
<Link
href="/"
className="text-xs text-[#8a8f98] hover:text-[#d0d6e0] mb-4 inline-block"
>
← Back
</Link>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6">
Create Document
</h1>
<DocForm mode="create" secret={WEBHOOK_SECRET} />
</div>
);
}
+26 -16
View File
@@ -3,39 +3,49 @@ import { listDocuments } from "@mcpedia/core";
export const dynamic = "force-dynamic";
const SECTIONS = ["docs", "writeups", "research", "notes"] as const;
const SECTIONS = [
{ id: "docs", label: "Documentation" },
{ id: "writeups", label: "Writeups" },
{ id: "research", label: "Research" },
{ id: "notes", label: "Notes" },
] as const;
export default async function DocsIndexPage() {
const all = await listDocuments();
const bySection = SECTIONS.map((section) => ({
section,
docs: all.filter((d) => d.section === section),
docs: all.filter((d) => d.section === section.id),
}));
return (
<div className="space-y-8">
<h1 className="text-2xl font-semibold tracking-tight">All Documents</h1>
{bySection.map(({ section, docs }) => (
<section key={section}>
<h2 className="text-lg font-medium capitalize mb-3">{section}</h2>
{docs.length === 0 ? (
<p className="text-sm text-zinc-500">No documents in this section.</p>
) : (
<div>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6">All Documents</h1>
{bySection.map(({ section, docs }) => {
if (docs.length === 0) return null;
return (
<section key={section.id} className="mb-10">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-4">
{section.label}
</h2>
<ul className="space-y-2">
{docs.map((d) => (
<li key={d.slug}>
<Link href={`/${d.slug}`} className="hover:underline">
<Link
href={`/${d.slug}`}
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
{d.title}
</Link>
<span className="text-xs text-zinc-500 ml-2">
{d.author || "unknown"}
<span className="text-xs text-[#62666d] ml-2">
{d.tags.slice(0, 2).map((t) => `#${t}`).join(" ") || "—"}
</span>
</li>
))}
</ul>
)}
</section>
))}
</section>
);
})}
</div>
);
}
+34 -12
View File
@@ -1,22 +1,44 @@
@import "tailwindcss";
/* Enable class-based dark mode (toggled by ThemeToggle via .dark on <html>). */
/* Linear design system: dark-mode-first */
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #ffffff;
--foreground: #171717;
@theme inline {
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--color-background: #08090a;
--color-surface: #0f1011;
--color-elevated: #191a1b;
--color-border: #1f2022;
--color-border-subtle: rgba(255, 255, 255, 0.05);
--color-border-standard: rgba(255, 255, 255, 0.08);
--color-text-primary: #f7f8f8;
--color-text-secondary: #d0d6e0;
--color-text-tertiary: #8a8f98;
--color-text-quaternary: #62666d;
--color-brand: #5e6ad2;
--color-accent: #7170ff;
--color-accent-hover: #828fff;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--color-background);
color: var(--color-text-secondary);
font-family: var(--font-sans);
font-feature-settings: "cv01", "ss03";
}
/* Inter font loading */
@font-face {
font-family: "Inter";
font-weight: 200 900;
font-display: swap;
src: url("https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrNCGw.woff2") format("woff2");
}
+39 -14
View File
@@ -2,10 +2,12 @@ import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
import ThemeToggle from "@/components/ThemeToggle";
import Sidebar from "@/components/Sidebar";
export const metadata: Metadata = {
title: "MCPedia",
description: "Knowledge base for humans and AI agents via MCP.",
title: "MCPedia — Knowledge base for humans and AI agents",
description:
"A content-first knowledge base. Humans read the Web UI; AI agents use the MCP server. Both share one Core.",
};
export default function RootLayout({
@@ -14,28 +16,51 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<header className="border-b border-zinc-200 dark:border-zinc-800">
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between">
<Link href="/" className="font-semibold tracking-tight">
<html lang="en" className="h-full scroll-smooth">
<body className="min-h-screen bg-[#08090a] text-[#e2e4e7] font-inter">
<header className="sticky top-0 z-20 border-b border-[#1f2022]">
<div className="mx-auto max-w-7xl px-4 h-14 flex items-center justify-between">
<Link href="/" className="font-medium text-[#f7f8f8] text-lg">
MCPedia
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/" className="hover:underline">
Home
<nav className="flex items-center gap-6 text-sm">
<Link
href="/docs"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Docs
</Link>
<Link href="/search" className="hover:underline">
<Link
href="/search"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Search
</Link>
<Link href="/docs" className="hover:underline">
Docs
<Link
href="/login"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Login
</Link>
<ThemeToggle />
</nav>
</div>
</header>
<main className="mx-auto max-w-5xl w-full flex-1 px-4 py-8">{children}</main>
<div className="mx-auto max-w-7xl">
<div className="flex">
{/* Sticky sidebar for doc navigation */}
<aside className="hidden xl:block w-64 shrink-0 border-r border-[#1f2022]">
<Sidebar />
</aside>
<main className="flex-1 min-w-0">
<div className="px-8 py-10 max-w-3xl mx-auto">
{children}
</div>
</main>
</div>
</div>
</body>
</html>
);
+39 -33
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
// Simple admin login form. POSTs password to /api/auth/login.
// On success, the API sets an HTTP-only cookie. We don't touch the cookie
// client-side (it's HttpOnly) — just redirect to the create page after login.
// client-side (it's HttpOnly) — just redirect to /create after login.
export default function LoginPage() {
const router = useRouter();
const [password, setPassword] = useState("");
@@ -30,7 +30,7 @@ export default function LoginPage() {
throw new Error(data.error ?? "Login failed");
}
router.push("/docs/create");
router.push("/create");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -39,40 +39,46 @@ export default function LoginPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950">
<div className="flex min-h-[60vh] items-center justify-center">
<div className="w-full max-w-sm">
<div className="bg-white dark:bg-zinc-900 p-8 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-800">
<h1 className="text-2xl font-bold mb-6 text-center">MCPedia Admin</h1>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200 mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
required
autoFocus
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
</div>
<div className="text-center mt-4">
<h1 className="text-2xl font-light text-[#f7f8f8] mb-2">Admin Login</h1>
<p className="text-[#8a8f98] text-sm mb-6">
Enter your admin password to manage documents.
</p>
{error && (
<div className="p-3 mb-4 rounded border border-[#274360] text-[#fca5a5] text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
required
autoFocus
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-[#5e6ad2] text-white rounded hover:bg-[#7170ff] transition-colors disabled:opacity-50 font-medium text-sm"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
<div className="mt-6">
<Link
href="/"
className="text-sm text-zinc-600 dark:text-zinc-400 hover:underline"
className="text-sm text-[#8a8f98] hover:text-[#d0d6e0] transition-colors"
>
← Back to home
</Link>
+52 -45
View File
@@ -1,75 +1,82 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import type { DocumentMeta } from "@mcpedia/core";
import { cookies } from "next/headers";
// Render at request time — content is in Postgres (not available at build in
// CI, which has no DB). Request-time rendering is instant at this corpus scale.
export const dynamic = "force-dynamic";
const SECTIONS = ["docs", "writeups", "research", "notes"] as const;
const SECTIONS = [
{ id: "docs", label: "Documentation" },
{ id: "writeups", label: "Writeups" },
{ id: "research", label: "Research" },
{ id: "notes", label: "Notes" },
] as const;
export default async function Home() {
export default async function HomePage() {
const all = await listDocuments();
const bySection = SECTIONS.map((section) => ({
section,
docs: all.filter((d) => d.section === section),
...section,
docs: all.filter((d) => d.section === section.id),
}));
// Auth check for edit/create buttons.
const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
return (
<div className="space-y-8">
<section>
<h1 className="text-2xl font-semibold tracking-tight">MCPedia</h1>
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
A content-first knowledge base. Humans read the Web UI; AI agents use
the MCP server. Both share one Core.
<div>
<div className="mb-12">
<h1 className="text-[2.5rem] font-light text-[#f7f8f8] mb-2">
MCPedia
</h1>
<p className="text-[#8a8f98] text-lg max-w-2xl">
A content-first knowledge base. Humans read the Web UI; AI agents
use the MCP server. Both share one Core.
</p>
{canEdit && (
</div>
{canEdit && (
<div className="mb-8">
<Link
href="/create"
className="inline-block mt-3 px-4 py-2 text-sm border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-[#5e6ad2] text-white hover:bg-[#7170ff] transition-colors font-medium text-sm"
>
+ Create Document
</Link>
)}
</section>
</div>
)}
{bySection.map(({ section, docs }) => (
<section key={section}>
<h2 className="text-lg font-medium capitalize mb-2">{section}</h2>
{docs.length === 0 ? (
<p className="text-sm text-zinc-500">No documents yet.</p>
) : (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{docs.map((d: DocumentMeta) => (
<li key={d.slug} className="py-2">
<Link
href={`/${d.slug}`}
className="hover:underline font-medium"
>
{d.title}
</Link>
{canEdit && (
{bySection.map(({ id, label, docs }) => {
if (docs.length === 0) return null;
return (
<section key={id} className="mb-10">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-4">
{label}
</h2>
<ul className="space-y-px text-sm">
{docs.map((d) => (
<li key={d.slug}>
<Link
href={`/${d.slug}/edit`}
className="ml-2 text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
href={`/${d.slug}`}
className="group flex items-start gap-3 py-2 text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
✎
<span className="text-[#8a8f98] group-hover:text-[#d0d6e0] w-5 text-center mt-0.5">
•
</span>
<span>
<span className="font-medium">{d.title}</span>
{d.tags.length > 0 && (
<span className="mt-0.5 text-xs text-[#62666d]">
{" "}
{d.tags.slice(0, 3).map((t) => `#${t}`).join(" ")}
</span>
)}
</span>
</Link>
)}
<div className="text-xs text-zinc-500 mt-0.5">
{d.tags.map((t) => `#${t}`).join(" ")}
</div>
</li>
</li>
))}
</ul>
)}
</section>
))}
</section>
);
})}
</div>
);
}
+71 -76
View File
@@ -1,91 +1,86 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { keywordSearch, hybridSearch } from "@mcpedia/core";
// Render at request time — queries Postgres on each request (not at build,
// where CI has no DB).
export const dynamic = "force-dynamic";
interface DocHit {
slug: string;
title: string;
section: string;
score: number;
snippet: string;
}
type Mode = "keyword" | "hybrid";
export default function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<DocHit[]>([]);
const [loading, setLoading] = useState(false);
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; mode?: string }>;
}) {
const { q, mode } = await searchParams;
const query = q?.trim() ?? "";
const activeMode: Mode = mode === "hybrid" ? "hybrid" : "keyword";
async function handleSearch(q: string) {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
setResults(data.results || []);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}
const hits = query
? activeMode === "hybrid"
? await hybridSearch(query, 30)
: await keywordSearch(query, 30)
: [];
const toggle = (m: Mode) => `/search?q=${encodeURIComponent(query)}&mode=${m}`;
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setQuery(val);
handleSearch(val);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
<div className="flex rounded overflow-hidden border border-zinc-300 dark:border-zinc-700 text-sm">
<Link
href={toggle("keyword")}
className={`px-3 py-1.5 ${activeMode === "keyword" ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-800"}`}
>
Keyword
</Link>
<Link
href={toggle("hybrid")}
className={`px-3 py-1.5 ${activeMode === "hybrid" ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-800"}`}
>
Hybrid
</Link>
</div>
<div>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6">Search</h1>
<div className="mb-6">
<input
type="search"
value={query}
onChange={handleChange}
placeholder="Search documents..."
className="w-full px-4 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
autoFocus
/>
</div>
<form method="get" className="flex gap-2">
<input
name="q"
defaultValue={query}
placeholder="e.g. websocket contract typescript"
className="flex-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm"
/>
<input type="hidden" name="mode" value={activeMode} />
<button
type="submit"
className="rounded bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 px-4 py-2 text-sm font-medium"
>
Search
</button>
</form>
{loading && <p className="text-[#8a8f98]">Searching...</p>}
{query && hits.length === 0 && (
<p className="text-sm text-zinc-500">No results for “{query}”.</p>
{!loading && results.length === 0 && query && (
<p className="text-[#8a8f98]">No results for "{query}".</p>
)}
<ul className="space-y-3">
{hits.map((h) => (
<li
key={h.doc.slug}
className="rounded border border-zinc-200 dark:border-zinc-800 p-3"
>
<Link
href={`/${h.doc.slug}`}
className="font-medium hover:underline"
>
{h.doc.title}
</Link>
{/* Snippet comes from Postgres ts_headline (keyword mode) or our own
chunk content (hybrid mode) — both trusted, first-party data, not
user input. The only markup is <mark> from ts_headline. */}
<p
className="text-sm text-zinc-600 dark:text-zinc-400 mt-1"
dangerouslySetInnerHTML={{ __html: h.snippet }}
/>
</li>
))}
</ul>
{!loading && results.length > 0 && (
<ul className="space-y-3">
{results.map((r) => (
<li key={r.slug} className="border-b border-[#1f2022] pb-3">
<Link
href={`/${r.slug}`}
className="text-[#f7f8f8] hover:text-[#7170ff] font-medium transition-colors"
>
{r.title}
</Link>
<span className="block text-xs text-[#62666d]">
{r.section} · {Math.round(r.score * 100)}% match
</span>
{r.snippet && (
<p className="mt-1 text-sm text-[#8a8f98] line-clamp-2">
{r.snippet}
</p>
)}
</li>
))}
</ul>
)}
</div>
);
}