feat(web): Phase 11 UI/UX — TOC, dark mode toggle, /docs index

- Install rehype-slug (proper heading anchors) + github-slugger (matching TOC)
- TOC component: auto-generated from h2/h3 headings, clickable anchors
- Dark mode toggle: ThemeToggle (localStorage + system default), class-based
- /docs index page (lists all docs by section)
- Markdown.tsx uses rehype-slug for stable heading ids
- globals.css: @custom-variant dark (.dark class) for class-based dark mode
- layout.tsx: nav includes ThemeToggle

Note: per user instruction, installed real deps (rehype-slug, github-slugger,
@types/hast) instead of hacky inline any-cast hacks.
This commit is contained in:
asepharyana
2026-08-20 13:21:53 +07:00
parent b998826b71
commit 1dd16ebcba
11 changed files with 260 additions and 25 deletions
+4 -1
View File
@@ -1,9 +1,12 @@
import ReactMarkdown from "react-markdown";
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>{source}</ReactMarkdown>
<ReactMarkdown rehypePlugins={[rehypeSlug]}>
{source}
</ReactMarkdown>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import GithubSlugger from "github-slugger";
interface TOCEntry {
id: string;
text: string;
level: number;
}
/** Extract h2/h3 headings from markdown and render as a clickable TOC. The
* heading `id`s are generated by rehype-slug (via github-slugger) in Markdown.tsx,
* so the same slugger is used here to produce matching anchors. */
export default function TOC({ source }: { source: string }) {
const [toc, setToc] = useState<TOCEntry[]>([]);
useEffect(() => {
const slugger = new GithubSlugger();
const lines = source.split("\n");
const entries: TOCEntry[] = [];
for (const line of lines) {
const m = line.match(/^(#{2,3})\s+(.+?)\s*#*\s*$/);
if (m) {
const level = m[1].length;
const text = m[2].trim();
entries.push({ id: slugger.slug(text), text, level });
}
}
setToc(entries);
}, [source]);
if (toc.length === 0) return null;
return (
<nav className="mb-6 text-sm">
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
Contents
</h3>
<ul className="space-y-1">
{toc.map((entry) => (
<li key={entry.id} className={entry.level === 3 ? "ml-3" : ""}>
<Link
href={`#${entry.id}`}
className="text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:underline"
>
{entry.text}
</Link>
</li>
))}
</ul>
</nav>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
const STORAGE_KEY = "mcpedia-theme";
/** Dark mode toggle. Defaults to system preference, persisted in localStorage. */
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
const initial =
stored === "dark" ||
(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);
}
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"
>
{dark ? "🌙" : "☀️"}
</button>
);
}