feat(web): full layout overhaul — Linear design system
CI / typecheck + build (turbo) (push) Canceled after 0s
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:
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user