"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; interface Doc { slug: string; title: string; section: string; tags: string[]; } const SECTIONS = [ { id: "docs", label: "Documentation", icon: "๐Ÿ“„" }, { id: "writeups", label: "Writeups", icon: "๐Ÿ“" }, { id: "research", label: "Research", icon: "๐Ÿ”ฌ" }, { id: "notes", label: "Notes", icon: "๐Ÿ“Œ" }, ] as const; /** * Build a hierarchical folder tree from a flat list of documents. * Each doc's slug is like "section/sub/sub2/leaf-name". * The tree groups by path segments so the sidebar shows nested folders * with proper indentation (like GitHub's file tree). */ interface TreeNode { name: string; slug: string; title: string; children: TreeNode[]; } function buildFolderTree(docs: Doc[]): Record { // Returns a map from section id โ†’ tree root for that section. const result: Record = {}; for (const section of SECTIONS) { const sectionDocs = docs.filter((d) => d.section === section.id); const root: TreeNode = { name: section.id, slug: section.id, title: section.label, children: [], }; const nodeMap = new Map(); nodeMap.set(section.id, root); for (const doc of sectionDocs) { // slug is like "docs/websocket/contract" โ†’ parts after section const parts = doc.slug.split("/"); // parts[0] should be the section let current = root; for (let i = 1; i < parts.length; i++) { const part = parts[i]; const path = parts.slice(0, i + 1).join("/"); const isLeaf = i === parts.length - 1; let child = current.children.find((n) => n.name === part); if (!child) { child = { name: part, slug: path, title: isLeaf ? doc.title : part, children: [], }; current.children.push(child); nodeMap.set(path, child); } else if (isLeaf) { child.title = doc.title; } current = child; } } root.children.sort((a, b) => { // Folders first, then docs const aFolder = a.children.length > 0 ? 0 : 1; const bFolder = b.children.length > 0 ? 0 : 1; if (aFolder !== bFolder) return aFolder - bFolder; return a.name.localeCompare(b.name); }); result[section.id] = root; } return result; } /** * Recursively render the folder tree with indentation. * depth = 0 is the top-level items under a section. */ function renderTreeNode(node: TreeNode, depth: number, pathname: string) { const isFolder = node.children.length > 0; const isActive = pathname === `/${node.slug}`; const hasActiveChild = pathname.startsWith(`/${node.slug}/`); const indent = depth * 12; // 12px per level return (
  • {isFolder ? "๐Ÿ“" : "๐Ÿ“„"} {node.title || node.name} {node.children && node.children.length > 0 && (
      {node.children.map((child) => renderTreeNode(child, 0, pathname))}
    )}
  • ); } /** Client-side sidebar (fetches doc list via /api/docs at runtime). * Client component so `next build` SSG doesn't hit Postgres (CI has no DB). */ export default function Sidebar() { const [docs, setDocs] = useState([]); const pathname = usePathname(); useEffect(() => { fetch("/api/docs") .then((r) => r.json()) .then((data) => { if (Array.isArray(data)) setDocs(data); else if (data?.docs) setDocs(data.docs); }) .catch(() => setDocs([])); }, []); const tree = buildFolderTree(docs); // Flatten all docs count for display const totalDocs = docs.length; return ( ); }