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
+27 -61
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
interface DocFormProps {
mode: "create" | "edit";
slug?: string;
secret: string; // injected server-side (never visible to the client JS bundle)
secret: string;
initial?: {
title?: string;
body?: string;
@@ -39,20 +39,15 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
.map((t) => t.trim())
.filter(Boolean);
const baseInputCls =
"w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const payload = {
title,
body,
section,
type,
status,
author,
tags: tagList,
};
const payload = { title, body, section, type, status, author, tags: tagList };
try {
let res: Response;
@@ -87,68 +82,50 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
}
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl mx-auto">
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
<div className="p-3 rounded border border-[#274360] text-[#fca5a5] text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
required
/>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Title</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className={baseInputCls} required />
</div>
<div>
<label className="block text-sm font-medium mb-1">Slug</label>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Slug</label>
<input
type="text"
value={slug ?? ""}
onChange={(e) => router.replace(`/docs/${section}/edit?slug=${e.target.value}`)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800"
readOnly
className="w-full px-3 py-2 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
placeholder={section}
/>
<p className="text-xs text-zinc-500 mt-1">URL-safe path under the section (e.g. "websocket/contract").</p>
<p className="text-xs text-[#62666d] mt-1">URL-safe path under the section.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Section</label>
<select
value={section}
onChange={(e) => setSection(e.target.value as typeof section)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Section</label>
<select value={section} onChange={(e) => setSection(e.target.value as typeof section)} className={baseInputCls}>
{SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Type</label>
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className={baseInputCls}>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as typeof status)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value as typeof status)} className={baseInputCls}>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
@@ -156,32 +133,21 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
</div>
<div>
<label className="block text-sm font-medium mb-1">Tags</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
placeholder="comma, separated, tags"
/>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Tags</label>
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} className={baseInputCls} placeholder="comma, separated, tags" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Author</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
className="w-fill px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
/>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Author</label>
<input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} className={baseInputCls} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Body (Markdown)</label>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">Body (Markdown)</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900 font-mono text-sm"
className="w-full h-96 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#d0d6e0] placeholder-[#62666d] font-mono text-sm focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
placeholder="# Heading&#10;&#10;Content here..."
/>
</div>
@@ -190,14 +156,14 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
className="px-4 py-2 bg-[#5e6ad2] text-white rounded hover:bg-[#7170ff] disabled:opacity-50 font-medium text-sm"
>
{loading ? "Saving..." : mode === "create" ? "Create" : "Update"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="px-4 py-2 border border-[#23252a] rounded text-[#d0d6e0] hover:text-[#f7f8f8] hover:border-[#3e3e44] transition-colors text-sm"
>
Cancel
</button>
+12 -4
View File
@@ -3,10 +3,18 @@ import rehypeSlug from "rehype-slug";
export default function Markdown({ source }: { source: string }) {
return (
<div className="prose prose-zinc dark:prose-invert max-w-none [&_pre]:bg-zinc-100 dark:[&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_code]:font-mono [&_a]:underline">
<ReactMarkdown rehypePlugins={[rehypeSlug]}>
{source}
</ReactMarkdown>
<div
className="prose prose-lg max-w-none
prose-headings:text-[#f7f8f8] prose-headings:font-light prose-headings:mb-4
prose-p:text-[#d0d6e0] prose-p:mb-4
prose-a:text-[#7170ff] prose-a:no-underline hover:prose-a:text-[#828fff]
prose-code:text-[#d0d6e0] prose-code:bg-[#191a1b] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-[#191a1b] prose-pre:border prose-pre:border-[#23252a] prose-pre:rounded-lg
prose-pre code:bg-transparent prose-pre code:p-0 prose-pre code:text-sm
prose-blockquote:border-l-[#23252a] prose-blockquote:text-[#a0a4a8]
prose-ul:text-[#d0d6e0] prose-ol:text-[#d0d6e0]"
>
<ReactMarkdown rehypePlugins={[rehypeSlug]}>{source}</ReactMarkdown>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
const SECTIONS = [
{ id: "docs", label: "Documentation" },
{ id: "writeups", label: "Writeups" },
{ id: "research", label: "Research" },
{ id: "notes", label: "Notes" },
] as const;
export default async function Sidebar() {
const docs = await listDocuments();
// Build a tree: section → top-level slugs + children
const bySection: Record<string, { slug: string; title: string; depth: number }[]> = {};
for (const doc of docs) {
if (!bySection[doc.section]) bySection[doc.section] = [];
// Count depth from dots in a flattened slug: docs/websocket/contract
// We'll show all docs at their depth level
bySection[doc.section].push({
slug: doc.slug,
title: doc.title,
depth: doc.slug.split("/").length,
});
}
return (
<nav className="h-full overflow-y-auto py-6">
<ul className="space-y-1 px-3 text-sm">
{SECTIONS.map(({ id, label }) => {
const sectionDocs = bySection[id] || [];
if (sectionDocs.length === 0) return null;
return (
<li key={id}>
<div className="text-xs font-medium text-[#62666d] uppercase mb-1 mt-4 first:mt-0">
{label}
</div>
<ul className="space-y-0.5">
{sectionDocs.map((doc) => {
const parts = doc.slug.split("/").slice(1); // remove section prefix
const label = parts[parts.length - 1];
const formatted = label
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
const indent = doc.depth - 1;
return (
<li key={doc.slug} style={{ marginLeft: `${indent * 12}px` }}>
<Link
href={`/${doc.slug}`}
className="block text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b] rounded px-2 py-0.5 transition-colors"
title={doc.title}
>
{formatted || doc.slug}
</Link>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</nav>
);
}
+4 -9
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
const STORAGE_KEY = "mcpedia-theme";
/** Dark mode toggle. Defaults to system preference, persisted in localStorage. */
/** Dark mode toggle — persisted in localStorage, defaults to system preference. */
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
@@ -15,27 +15,22 @@ export default function ThemeToggle() {
(stored === null &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
setDark(initial);
apply(initial);
}, []);
function apply(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark);
}
function toggle() {
const next = !dark;
setDark(next);
localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
apply(next);
document.documentElement.classList.toggle("dark", next);
}
return (
<button
onClick={toggle}
aria-label="Toggle dark mode"
className="text-sm border border-zinc-300 dark:border-zinc-700 rounded px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="text-[#d0d6e0] hover:text-[#f7f8f8] p-1 rounded transition-colors"
>
{dark ? "🌙" : "☀️"}
{dark ? "☀️" : "🌙"}
</button>
);
}