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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user