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
+55 -55
View File
@@ -1,67 +1,67 @@
# MCPedia Phase 11 — CRUD + Auth + UI/UX Improvement # MCPedia Phase 11 — CRUD + Auth + Web UI
## STATUS: ✅ ALL DONE (committed f8b525d, CI+Deploy success)
## Plan (spec BEFORE implementation, per user rule) ## Plan (spec BEFORE implementation, per user rule)
### Scope: 4 areas ### Scope: 4 areas
1. **CRUD**: Create/Read/Update/Delete documents from web UI + MCP 1. **CRUD**: Create/Read/Update/Delete documents from web UI + MCP
2. **Auth**: Session-based auth for web edits; API key auth for MCP writes 2. **Authentication**: MCP/API writes (x-webhook-secret), Web CRUD (cookie-based ADMIN_PASSWORD)
3. **UI/UX**: Polish doc page, edit forms, search UX, dark mode 3. **Web + Agent access**: Web forms + MCP write tools
4. **Agent integration**: MCP tools for CRUD with auth 4. **UI/UX**: Edit forms, TOC, dark mode
## Requirements: ### Requirements:
1. **Source of truth is the filesystem** — each doc is a markdown file under `content/{section}/{slug}.md` with YAML frontmatter (parsed/striped by gray-matter). 1. Source of truth = filesystem (markdown files in content/{section}/{slug}.md)
2. **DB mirrors disk** — `documents` table has slug/title/section/body/status/tags/etc; `document_chunks` stores embeddings; `document_revisions` snapshots body+meta. 2. DB mirrors disk (documents, document_chunks, document_revisions tables)
3. **Single indexing path** — `indexContentFile` in `@mcpedia/core` writes DB + revisions + chunks; CRUD ops call it (or its pieces) after writing the file. 3. Single indexing path: indexContentFile in @mcpedia/core
4. **Auth** — MCP/API writes require `x-webhook-secret` matching `WEBHOOK_SECRET` (Phase 4 pattern). Web CRUD uses cookie-based auth (Phase 11 addition: `ADMIN_PASSWORD`). 4. Auth: MCP/API writes use WEBHOOK_SECRET; Web uses ADMIN_PASSWORD cookie
5. **Slug rules** — `[a-z0-9][a-z0-9/_-]*`, no `//`, no path traversal (`..`). The slug in the URL is `<section>/<slug-parts>`. 5. Slug rules: [a-z0-9][a-z0-9/_-]*, no //, no .. traversal
6. **No breaking existing features** — read tools/routes unchanged; existing 32 tests still green. 6. No breaking existing features (32 original tests still green)
7. **UI/UX** — edit button on doc pages (auth-gated), `?edit=1` inline form, `/create` page, login page, table of contents auto-generated from h2, dark mode toggle. 7. UI/UX: edit button on doc pages, ?edit=1 inline form, /create page, login, TOC, dark mode
### Schema changes: ### Implementation
- Optional: `users` table if auth is user-based
- OR: simple `WEB_AUTH_TOKEN` env for admin access (simpler, fits single-author KB)
### Backend surface: #### Backend
- **Web auth**: Cookie-based session OR single admin token in localStorage - `packages/parser`: added `stringifyFile()` (writes markdown with frontmatter)
- Simpler approach: `POST /api/auth/login` (checks `ADMIN_PASSWORD` env) → sets cookie - `packages/core`: `createDocument`, `updateDocument`, `deleteDocument` (file I/O + DB + revision + chunks)
- Edit UI gated behind cookie check in server components - `apps/api`: tRPC CRUD routers (`requireWriteAuth`), fixed `requireWriteAuth` env-constant bug (now uses `ctx.expectedSecret` injected from `createApp(deps)`)
- **MCP auth**: `x-api-key` header for write tools (create/update/delete) - `apps/mcp`: 3 new write tools (`create_document`, `update_document`, `delete_document`) gated by `x-webhook-secret`
- Read tools (list, get, search) — public
- Write tools (create, update, delete, index_document, reindex_all, restore_revision) — require key
- `createMcpServer(authKey?)` threads the header, same pattern as `x-webhook-secret`
### CRUD operations: #### Web UI
- **Create**: `POST /trpc/createDocument` (title, slug, section, body, tags) - `apps/web/app/api/auth/login/route.ts`: POST login → verify ADMIN_PASSWORD, set `mcpedia_admin` cookie
- Writes markdown file to `content/{section}/{slug}.md` - `apps/web/app/api/docs/route.ts`: POST (create)
- Triggers indexer (enqueues job or calls directly) - `apps/web/app/api/docs/[...slug]/route.ts`: PUT (update), DELETE (delete)
- **Update**: `POST /trpc/updateDocument` (slug, body, title, tags, status) - `apps/web/app/components/DocForm.tsx`: shared create/edit form
- Updates file + creates revision + reindexes - `apps/web/app/create/page.tsx`: create form
- **Delete**: `POST /trpc/deleteDocument` (slug) - `apps/web/app/login/page.tsx`: login form
- Removes file, DB rows, chunks; creates revision tombstone - `apps/web/app/[section]/[...slug]/page.tsx`: `?edit=1` inline edit, TOC, dark mode, Edit button
- All mutations require auth (web cookie OR webhook secret OR MCP api-key) - `apps/web/app/docs/page.tsx`: docs index listing
- `apps/web/app/components/TOC.tsx`: auto-generated TOC from h2/h3 headings
- `apps/web/app/components/ThemeToggle.tsx`: dark mode toggle (localStorage + system default)
### UI improvements: #### New deps (minimal — only for UX):
- Edit button on doc pages (auth-gated) → link to `/docs/{slug}/edit` - `rehype-slug` (heading anchors for TOC links)
- Create page: `/docs/create` with form (section dropdown, slug, title, tags, markdown editor) - `github-slugger` (matching slug algorithm for TOC client-side)
- Edit page: `/docs/{slug}/edit` pre-fills from getDocument
- Search page: live search results with keyboard nav, better empty states
- Doc page: table of contents (auto-generated from h2), dark mode toggle
### Verification steps: ### Gotchas (learned the hard way)
1. TDD: write failing tests for each new API endpoint/method first 1. tRPC fetch adapter expects input directly as JSON body, NOT JSON-RPC envelope
2. `bun run typecheck` — green 2. requireWriteAuth compared `ctx.webhookSecret !== WEBHOOK_SECRET` (module-level env constant) — untestable. Fixed: `ctx.webhookSecret !== ctx.expectedSecret` (injected per-app via deps).
3. `bun run test` — 32 existing + new tests green 3. Next.js catch-all routes: `[...slug]/edit/` is INVALID (catch-all must be last). Used `?edit=1` query param instead.
4. `bun run build` — green 4. Next.js App Router: PUT/DELETE on `/api/docs/route.ts` doesn't match `/api/docs/{slug}` — need dynamic route `/api/docs/[...slug]/route.ts`.
5. CI + Deploy passes 5. `@env.example` should be updated.
6. Browser: create doc → search sees it → edit doc → changes reflect → delete doc → gone 6. `ADMIN_PASSWORD` must be set in VPS `.env` (deployed separately).
7. MCP: create_document tool with key → doc appears; without key → unauthorized
### Acceptance criteria: ### Verification
- [ ] createDocument tRPC + MCP - Typecheck: ✅ 4/4 apps green
- [ ] updateDocument tRPC + MCP - Tests: ✅ 40 tests green (32 original + 8 new), no DB/Redis
- [ ] deleteDocument tRPC + MCP - Build: ✅ web compiled
- [ ] Web auth (cookie-based login) - CI: ✅ success → Deploy: ✅ success
- [ ] MCP write-tool auth (api-key) - Live: all 9 endpoints 200, 13 MCP tools live, CRUD e2e verified (login → create → view → delete via cookie auth), MCP create_document verified via header auth
- [ ] UI: edit/create pages - Test docs cleaned up (404 confirmed)
- [ ] UI: TOC + dark mode on doc pages
- [ ] All tests green, CI+Deploy passes ### Commits
1. `57f9001` feat: Phase 11 — CRUD + auth + web UI
2. `1dd16eb` feat(web): Phase 11 UI/UX — TOC, dark mode toggle, /docs index
3. `98437cb` fix(web): /api/docs accepts cookie OR header (not both required)
4. `8ed8c67` fix(web): split PUT/DELETE into /api/docs/[...slug]/route.ts
5. `f8b525d` chore: remove test docs
+72 -37
View File
@@ -7,10 +7,7 @@ import Markdown from "@/components/Markdown";
import DocForm from "@/components/DocForm"; import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC"; import TOC from "@/components/TOC";
// Render at request time. The content lives in Postgres (populated by the // Render at request time (content in Postgres, not available at build time).
// 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.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface DocPageProps { interface DocPageProps {
@@ -25,15 +22,22 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const doc = await getDocument(fullSlug); const doc = await getDocument(fullSlug);
if (!doc) notFound(); if (!doc) notFound();
// Check auth for edit mode.
const cookieStore = await cookies(); const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null; 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) { if (edit === "1" && canEdit) {
return ( return (
<div className="p-8"> <div>
<h1 className="text-2xl font-bold mb-6">Edit: {doc.title}</h1> <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 <DocForm
mode="edit" mode="edit"
slug={fullSlug} slug={fullSlug}
@@ -56,38 +60,67 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const revisions = await listRevisions(fullSlug, 10); const revisions = await listRevisions(fullSlug, 10);
return ( return (
<article className="space-y-4"> <article>
<div> <div className="mb-2 flex items-center gap-2 text-xs text-[#62666d]">
<Link href="/" className="text-sm text-zinc-500 hover:underline"> <Link
← Back href="/"
className="hover:text-[#d0d6e0] transition-colors"
>
MCPedia
</Link>
<span>/</span>
<Link
href="/docs"
className="hover:text-[#d0d6e0] transition-colors"
>
Docs
</Link> </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 && ( {canEdit && (
<Link <>
href={`/${doc.slug}?edit=1`} <span>/</span>
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" <Link
> href={`/${doc.slug}?edit=1`}
Edit className="hover:text-[#d0d6e0] text-[#8a8f98] hover:text-[#5e6ad2] transition-colors"
</Link> >
Edit
</Link>
</>
)} )}
</div> </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 && ( {related.length > 0 && (
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4"> <aside className="mt-12 pt-6 border-t border-[#1f2022]">
<h2 className="text-sm font-medium mb-2">Related</h2> <h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
<ul className="text-sm space-y-1"> Related
</h2>
<ul className="space-y-2">
{related.map((r) => ( {related.map((r) => (
<li key={r.slug}> <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} {r.title}
</Link> </Link>
</li> </li>
@@ -97,23 +130,25 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
)} )}
{revisions.length > 0 && ( {revisions.length > 0 && (
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4"> <aside className="mt-12 pt-6 border-t border-[#1f2022]">
<h2 className="text-sm font-medium mb-2">History</h2> <h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
<ul className="text-sm space-y-1"> History
</h2>
<ul className="text-sm">
{revisions.map((rev) => ( {revisions.map((rev) => (
<li <li
key={rev.id} 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} ·{" "} #{rev.revisionNo} · {rev.reason} ·{" "}
{new Date(rev.createdAt).toLocaleString()} · {rev.bodyLength} chars {new Date(rev.createdAt).toLocaleString()}
</span> </span>
<form action="/api/revisions/restore" method="post"> <form action="/api/revisions/restore" method="post">
<input type="hidden" name="id" value={rev.id} /> <input type="hidden" name="id" value={rev.id} />
<button <button
type="submit" 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 Restore
</button> </button>
+27 -61
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
interface DocFormProps { interface DocFormProps {
mode: "create" | "edit"; mode: "create" | "edit";
slug?: string; slug?: string;
secret: string; // injected server-side (never visible to the client JS bundle) secret: string;
initial?: { initial?: {
title?: string; title?: string;
body?: string; body?: string;
@@ -39,20 +39,15 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
const payload = { const payload = { title, body, section, type, status, author, tags: tagList };
title,
body,
section,
type,
status,
author,
tags: tagList,
};
try { try {
let res: Response; let res: Response;
@@ -87,68 +82,50 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl mx-auto"> <form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{error && ( {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} {error}
</div> </div>
)} )}
<div> <div>
<label className="block text-sm font-medium mb-1">Title</label> <label className="block text-xs font-medium text-[#d0d6e0] mb-1">Title</label>
<input <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className={baseInputCls} required />
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
/>
</div> </div>
<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 <input
type="text" type="text"
value={slug ?? ""} value={slug ?? ""}
onChange={(e) => router.replace(`/docs/${section}/edit?slug=${e.target.value}`)} readOnly
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800" className="w-full px-3 py-2 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
placeholder={section} 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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Section</label> <label className="block text-xs font-medium text-[#d0d6e0] mb-1">Section</label>
<select <select value={section} onChange={(e) => setSection(e.target.value as typeof section)} className={baseInputCls}>
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"
>
{SECTION_OPTIONS.map((s) => ( {SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option> <option key={s} value={s}>{s}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Type</label> <label className="block text-xs font-medium text-[#d0d6e0] mb-1">Type</label>
<select <select value={type} onChange={(e) => setType(e.target.value as typeof type)} className={baseInputCls}>
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"
>
{TYPE_OPTIONS.map((t) => ( {TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>{t}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-xs font-medium text-[#d0d6e0] mb-1">Status</label>
<select <select value={status} onChange={(e) => setStatus(e.target.value as typeof status)} className={baseInputCls}>
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"
>
<option value="published">Published</option> <option value="published">Published</option>
<option value="draft">Draft</option> <option value="draft">Draft</option>
</select> </select>
@@ -156,32 +133,21 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Tags</label> <label className="block text-xs font-medium text-[#d0d6e0] mb-1">Tags</label>
<input <input type="text" value={tags} onChange={(e) => setTags(e.target.value)} className={baseInputCls} placeholder="comma, separated, tags" />
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"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Author</label> <label className="block text-xs font-medium text-[#d0d6e0] mb-1">Author</label>
<input <input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} className={baseInputCls} />
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"
/>
</div> </div>
<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 <textarea
value={body} value={body}
onChange={(e) => setBody(e.target.value)} 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..." placeholder="# Heading&#10;&#10;Content here..."
/> />
</div> </div>
@@ -190,14 +156,14 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
<button <button
type="submit" type="submit"
disabled={loading} 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"} {loading ? "Saving..." : mode === "create" ? "Create" : "Update"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => router.back()} 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 Cancel
</button> </button>
+12 -4
View File
@@ -3,10 +3,18 @@ import rehypeSlug from "rehype-slug";
export default function Markdown({ source }: { source: string }) { export default function Markdown({ source }: { source: string }) {
return ( 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"> <div
<ReactMarkdown rehypePlugins={[rehypeSlug]}> className="prose prose-lg max-w-none
{source} prose-headings:text-[#f7f8f8] prose-headings:font-light prose-headings:mb-4
</ReactMarkdown> 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> </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"; 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() { export default function ThemeToggle() {
const [dark, setDark] = useState(false); const [dark, setDark] = useState(false);
@@ -15,27 +15,22 @@ export default function ThemeToggle() {
(stored === null && (stored === null &&
window.matchMedia("(prefers-color-scheme: dark)").matches); window.matchMedia("(prefers-color-scheme: dark)").matches);
setDark(initial); setDark(initial);
apply(initial);
}, []); }, []);
function apply(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark);
}
function toggle() { function toggle() {
const next = !dark; const next = !dark;
setDark(next); setDark(next);
localStorage.setItem(STORAGE_KEY, next ? "dark" : "light"); localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
apply(next); document.documentElement.classList.toggle("dark", next);
} }
return ( return (
<button <button
onClick={toggle} onClick={toggle}
aria-label="Toggle dark mode" 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> </button>
); );
} }
+20 -13
View File
@@ -3,24 +3,23 @@ import { cookies } from "next/headers";
import DocForm from "@/components/DocForm"; import DocForm from "@/components/DocForm";
import Link from "next/link"; import Link from "next/link";
// GET /create — show the document creation form. // GET /create — show the document create form.
// Auth: requires the mcpedia_admin cookie (set via /api/auth/login). // Requires admin cookie auth.
export const dynamic = "force-dynamic"; export default async function CreatePage() {
export default async function CreateDocPage() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null; const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
if (!canEdit) { if (!canEdit) {
return ( return (
<div className="p-8 max-w-md mx-auto"> <div className="text-center py-16">
<h1 className="text-xl font-semibold mb-4">Authentication required</h1> <h1 className="text-2xl font-light text-[#f7f8f8] mb-4">
<p className="text-zinc-600 dark:text-zinc-400 mb-4"> Authentication required
</h1>
<p className="text-[#8a8f98] mb-6">
You must be logged in to create documents. You must be logged in to create documents.
</p> </p>
<Link <Link
href="/login" 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 Login
</Link> </Link>
@@ -29,9 +28,17 @@ export default async function CreateDocPage() {
} }
return ( return (
<div className="p-8"> <div>
<h1 className="text-2xl font-bold mb-6">Create Document</h1> <Link
<DocForm mode="create" slug="" secret={WEBHOOK_SECRET} /> 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> </div>
); );
} }
+26 -16
View File
@@ -3,39 +3,49 @@ import { listDocuments } from "@mcpedia/core";
export const dynamic = "force-dynamic"; 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() { export default async function DocsIndexPage() {
const all = await listDocuments(); const all = await listDocuments();
const bySection = SECTIONS.map((section) => ({ const bySection = SECTIONS.map((section) => ({
section, section,
docs: all.filter((d) => d.section === section), docs: all.filter((d) => d.section === section.id),
})); }));
return ( return (
<div className="space-y-8"> <div>
<h1 className="text-2xl font-semibold tracking-tight">All Documents</h1> <h1 className="text-2xl font-light text-[#f7f8f8] mb-6">All Documents</h1>
{bySection.map(({ section, docs }) => (
<section key={section}> {bySection.map(({ section, docs }) => {
<h2 className="text-lg font-medium capitalize mb-3">{section}</h2> if (docs.length === 0) return null;
{docs.length === 0 ? ( return (
<p className="text-sm text-zinc-500">No documents in this section.</p> <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"> <ul className="space-y-2">
{docs.map((d) => ( {docs.map((d) => (
<li key={d.slug}> <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} {d.title}
</Link> </Link>
<span className="text-xs text-zinc-500 ml-2"> <span className="text-xs text-[#62666d] ml-2">
{d.author || "unknown"} {d.tags.slice(0, 2).map((t) => `#${t}`).join(" ") || "—"}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
)} </section>
</section> );
))} })}
</div> </div>
); );
} }
+34 -12
View File
@@ -1,22 +1,44 @@
@import "tailwindcss"; @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 *)); @custom-variant dark (&:where(.dark, .dark *));
:root { @theme inline {
--background: #ffffff; --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--foreground: #171717; --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 { html {
--color-background: var(--background); scroll-behavior: smooth;
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
} }
body { body {
background: var(--background); background: var(--color-background);
color: var(--foreground); color: var(--color-text-secondary);
font-family: Arial, Helvetica, sans-serif; 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 Link from "next/link";
import "./globals.css"; import "./globals.css";
import ThemeToggle from "@/components/ThemeToggle"; import ThemeToggle from "@/components/ThemeToggle";
import Sidebar from "@/components/Sidebar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "MCPedia", title: "MCPedia — Knowledge base for humans and AI agents",
description: "Knowledge base for humans and AI agents via MCP.", 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({ export default function RootLayout({
@@ -14,28 +16,51 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" className="h-full antialiased"> <html lang="en" className="h-full scroll-smooth">
<body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> <body className="min-h-screen bg-[#08090a] text-[#e2e4e7] font-inter">
<header className="border-b border-zinc-200 dark:border-zinc-800"> <header className="sticky top-0 z-20 border-b border-[#1f2022]">
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between"> <div className="mx-auto max-w-7xl px-4 h-14 flex items-center justify-between">
<Link href="/" className="font-semibold tracking-tight"> <Link href="/" className="font-medium text-[#f7f8f8] text-lg">
MCPedia MCPedia
</Link> </Link>
<nav className="flex items-center gap-4 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/" className="hover:underline"> <Link
Home href="/docs"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Docs
</Link> </Link>
<Link href="/search" className="hover:underline"> <Link
href="/search"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Search Search
</Link> </Link>
<Link href="/docs" className="hover:underline"> <Link
Docs href="/login"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Login
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
</nav> </nav>
</div> </div>
</header> </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> </body>
</html> </html>
); );
+39 -33
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
// Simple admin login form. POSTs password to /api/auth/login. // 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 // 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -30,7 +30,7 @@ export default function LoginPage() {
throw new Error(data.error ?? "Login failed"); throw new Error(data.error ?? "Login failed");
} }
router.push("/docs/create"); router.push("/create");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -39,40 +39,46 @@ export default function LoginPage() {
} }
return ( 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="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-light text-[#f7f8f8] mb-2">Admin Login</h1>
<h1 className="text-2xl font-bold mb-6 text-center">MCPedia Admin</h1> <p className="text-[#8a8f98] text-sm mb-6">
{error && ( Enter your admin password to manage documents.
<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"> </p>
{error}
</div> {error && (
)} <div className="p-3 mb-4 rounded border border-[#274360] text-[#fca5a5] text-sm">
<form onSubmit={handleSubmit} className="space-y-4"> {error}
<div> </div>
<label className="block text-sm font-medium mb-1">Password</label> )}
<input
type="password" <form onSubmit={handleSubmit} className="space-y-4">
value={password} <div>
onChange={(e) => setPassword(e.target.value)} <label className="block text-xs font-medium text-[#d0d6e0] mb-1">
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900" Password
required </label>
autoFocus <input
/> type="password"
</div> value={password}
<button onChange={(e) => setPassword(e.target.value)}
type="submit" 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]"
disabled={loading} required
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" autoFocus
> />
{loading ? "Logging in..." : "Login"} </div>
</button> <button
</form> type="submit"
</div> disabled={loading}
<div className="text-center mt-4"> 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 <Link
href="/" 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 ← Back to home
</Link> </Link>
+52 -45
View File
@@ -1,75 +1,82 @@
import Link from "next/link"; import Link from "next/link";
import { listDocuments } from "@mcpedia/core"; import { listDocuments } from "@mcpedia/core";
import type { DocumentMeta } from "@mcpedia/core";
import { cookies } from "next/headers"; 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"; 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 all = await listDocuments();
const bySection = SECTIONS.map((section) => ({ const bySection = SECTIONS.map((section) => ({
section, ...section,
docs: all.filter((d) => d.section === section), docs: all.filter((d) => d.section === section.id),
})); }));
// Auth check for edit/create buttons.
const cookieStore = await cookies(); const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null; const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
return ( return (
<div className="space-y-8"> <div>
<section> <div className="mb-12">
<h1 className="text-2xl font-semibold tracking-tight">MCPedia</h1> <h1 className="text-[2.5rem] font-light text-[#f7f8f8] mb-2">
<p className="mt-2 text-zinc-600 dark:text-zinc-400"> MCPedia
A content-first knowledge base. Humans read the Web UI; AI agents use </h1>
the MCP server. Both share one Core. <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> </p>
{canEdit && ( </div>
{canEdit && (
<div className="mb-8">
<Link <Link
href="/create" 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 + Create Document
</Link> </Link>
)} </div>
</section> )}
{bySection.map(({ section, docs }) => ( {bySection.map(({ id, label, docs }) => {
<section key={section}> if (docs.length === 0) return null;
<h2 className="text-lg font-medium capitalize mb-2">{section}</h2> return (
{docs.length === 0 ? ( <section key={id} className="mb-10">
<p className="text-sm text-zinc-500">No documents yet.</p> <h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-4">
) : ( {label}
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800"> </h2>
{docs.map((d: DocumentMeta) => ( <ul className="space-y-px text-sm">
<li key={d.slug} className="py-2"> {docs.map((d) => (
<Link <li key={d.slug}>
href={`/${d.slug}`}
className="hover:underline font-medium"
>
{d.title}
</Link>
{canEdit && (
<Link <Link
href={`/${d.slug}/edit`} href={`/${d.slug}`}
className="ml-2 text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300" 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> </Link>
)} </li>
<div className="text-xs text-zinc-500 mt-0.5">
{d.tags.map((t) => `#${t}`).join(" ")}
</div>
</li>
))} ))}
</ul> </ul>
)} </section>
</section> );
))} })}
</div> </div>
); );
} }
+71 -76
View File
@@ -1,91 +1,86 @@
"use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { keywordSearch, hybridSearch } from "@mcpedia/core";
// Render at request time — queries Postgres on each request (not at build, interface DocHit {
// where CI has no DB). slug: string;
export const dynamic = "force-dynamic"; 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({ async function handleSearch(q: string) {
searchParams, if (!q.trim()) {
}: { setResults([]);
searchParams: Promise<{ q?: string; mode?: string }>; return;
}) { }
const { q, mode } = await searchParams; setLoading(true);
const query = q?.trim() ?? ""; try {
const activeMode: Mode = mode === "hybrid" ? "hybrid" : "keyword"; 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 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
? activeMode === "hybrid" const val = e.target.value;
? await hybridSearch(query, 30) setQuery(val);
: await keywordSearch(query, 30) handleSearch(val);
: []; }
const toggle = (m: Mode) => `/search?q=${encodeURIComponent(query)}&mode=${m}`;
return ( return (
<div className="space-y-4"> <div>
<div className="flex items-center justify-between"> <h1 className="text-2xl font-light text-[#f7f8f8] mb-6">Search</h1>
<h1 className="text-2xl font-semibold tracking-tight">Search</h1> <div className="mb-6">
<div className="flex rounded overflow-hidden border border-zinc-300 dark:border-zinc-700 text-sm"> <input
<Link type="search"
href={toggle("keyword")} value={query}
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"}`} onChange={handleChange}
> placeholder="Search documents..."
Keyword 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]"
</Link> autoFocus
<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> </div>
<form method="get" className="flex gap-2"> {loading && <p className="text-[#8a8f98]">Searching...</p>}
<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>
{query && hits.length === 0 && ( {!loading && results.length === 0 && query && (
<p className="text-sm text-zinc-500">No results for “{query}”.</p> <p className="text-[#8a8f98]">No results for "{query}".</p>
)} )}
<ul className="space-y-3"> {!loading && results.length > 0 && (
{hits.map((h) => ( <ul className="space-y-3">
<li {results.map((r) => (
key={h.doc.slug} <li key={r.slug} className="border-b border-[#1f2022] pb-3">
className="rounded border border-zinc-200 dark:border-zinc-800 p-3" <Link
> href={`/${r.slug}`}
<Link className="text-[#f7f8f8] hover:text-[#7170ff] font-medium transition-colors"
href={`/${h.doc.slug}`} >
className="font-medium hover:underline" {r.title}
> </Link>
{h.doc.title} <span className="block text-xs text-[#62666d]">
</Link> {r.section} · {Math.round(r.score * 100)}% match
{/* Snippet comes from Postgres ts_headline (keyword mode) or our own </span>
chunk content (hybrid mode) — both trusted, first-party data, not {r.snippet && (
user input. The only markup is <mark> from ts_headline. */} <p className="mt-1 text-sm text-[#8a8f98] line-clamp-2">
<p {r.snippet}
className="text-sm text-zinc-600 dark:text-zinc-400 mt-1" </p>
dangerouslySetInnerHTML={{ __html: h.snippet }} )}
/> </li>
</li> ))}
))} </ul>
</ul> )}
</div> </div>
); );
} }