feat(ui): overhaul UI/UX with CommandMenu, modern Linear design, interactive TOC, and live preview
CI / typecheck + build (turbo) (push) Canceled after 0s
CI / typecheck + build (turbo) (push) Canceled after 0s
This commit is contained in:
1 parent
a67a8c6469
commit
62116be88d
16 files changed
+2048
-849
No files matched your search
@@ -10,11 +10,9 @@ interface TOCEntry {
|
||||
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[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const slugger = new GithubSlugger();
|
||||
@@ -31,24 +29,79 @@ export default function TOC({ source }: { source: string }) {
|
||||
setToc(entries);
|
||||
}, [source]);
|
||||
|
||||
// Scroll spy using IntersectionObserver
|
||||
useEffect(() => {
|
||||
if (toc.length === 0) return;
|
||||
|
||||
const headingElements = toc
|
||||
.map((entry) => document.getElementById(entry.id))
|
||||
.filter((el): el is HTMLElement => el !== null);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "-80px 0% -60% 0%",
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
headingElements.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [toc]);
|
||||
|
||||
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"
|
||||
<nav className="text-xs">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[#1f2022]">
|
||||
<svg className="w-3.5 h-3.5 text-[#7170ff]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
<span className="font-semibold uppercase tracking-wider text-[#8a8f98] text-[11px]">
|
||||
On this page
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 border-l border-[#1f2022] pl-2.5">
|
||||
{toc.map((entry) => {
|
||||
const isActive = activeId === entry.id;
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className={`transition-all ${entry.level === 3 ? "ml-3" : ""}`}
|
||||
>
|
||||
{entry.text}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<Link
|
||||
href={`#${entry.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const target = document.getElementById(entry.id);
|
||||
if (target) {
|
||||
const offset = 80;
|
||||
const top = target.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
setActiveId(entry.id);
|
||||
history.pushState(null, "", `#${entry.id}`);
|
||||
}
|
||||
}}
|
||||
className={`block py-0.5 transition-colors line-clamp-1 ${
|
||||
isActive
|
||||
? "text-[#7170ff] font-medium translate-x-0.5"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0]"
|
||||
}`}
|
||||
title={entry.text}
|
||||
>
|
||||
{entry.text}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user