- Section index: doc-title-based tree, folder doc counts, active+parent highlighting, recent list with dates, doc counter badge - Folder index: rich card listing with doc titles (not path slugs), dates, tags, subfolder grid with doc counts, breadcrumb - Doc page: metadata card (bg-[#0f1011] border), structured header with author/date/tags, badge title tooltips, improved FolderIndex breadcrumb - Sidebar: recursive hierarchical tree with depth indentation + 16px/level, folder doc counts, section icons, active parent highlighting, alphabetical sort - DocForm: clean 2-column grid layout, no duplicate slug field, edit-mode loads existing custom fields - Homepage: inline folder tree per section card - Header nav: added Writeups/Research/Notes links - Deleted duplicate apps/web/app/docs/page.tsx (superseded by generic [section]/page.tsx) - Added CSS vars: --color-border-subtle, --color-border-standard, --color-text-tertiary, --color-text-quaternary
This commit is contained in:
1 parent
b8d4fc97da
commit
26a900a030
5 files changed
+505
-334
No files matched your search
@@ -1,10 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { listDocuments, type DocumentMeta } from "@mcpedia/core";
|
||||
import type { DocSection } from "@mcpedia/core";
|
||||
import { listDocuments } from "@mcpedia/core";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[] = [
|
||||
interface DocMeta {
|
||||
slug: string;
|
||||
title: string;
|
||||
section: string;
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SECTIONS: { id: string; label: string; icon: string; desc: string }[] = [
|
||||
{
|
||||
id: "docs",
|
||||
label: "Documentation",
|
||||
@@ -17,88 +25,102 @@ const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[]
|
||||
];
|
||||
|
||||
/**
|
||||
* Group documents into a folder tree. Returns nodes where each node is either:
|
||||
* - a folder (has `children`) or
|
||||
* - a leaf doc (has `doc`)
|
||||
* The tree is built from the path segments of each document's `path`.
|
||||
* Build a hierarchical folder tree from a flat list of documents.
|
||||
* Each node is either a folder (has children) or a leaf doc.
|
||||
*/
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
slug: string;
|
||||
doc?: DocumentMeta;
|
||||
children?: TreeNode[];
|
||||
doc?: DocMeta;
|
||||
children: TreeNode[];
|
||||
docCount: number; // total docs in subtree
|
||||
}
|
||||
|
||||
function buildFolderTree(docs: DocumentMeta[], section: string): TreeNode[] {
|
||||
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
const rootMap = new Map<string, TreeNode>();
|
||||
|
||||
for (const doc of docs.filter((d) => d.section === section)) {
|
||||
// path is like "docs/websocket/contract.md" → strip section prefix + .md
|
||||
const rel = doc.path.startsWith(`${section}/`)
|
||||
? doc.path.slice(section.length + 1)
|
||||
: doc.path;
|
||||
const cleanPath = rel.replace(/\.md$/, "");
|
||||
const parts = cleanPath.split("/");
|
||||
|
||||
let currentLevel = root;
|
||||
let current = root;
|
||||
let currentPath = section;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
currentPath = `${currentPath}/${part}`;
|
||||
const isLeaf = i === parts.length - 1;
|
||||
currentPath = `${currentPath}/${part}`;
|
||||
|
||||
let node = currentLevel.find((n) => n.name === part);
|
||||
let node = current.find((n) => n.name === part);
|
||||
if (!node) {
|
||||
node = {
|
||||
name: part === "" ? section : part,
|
||||
name: part,
|
||||
slug: currentPath,
|
||||
children: isLeaf ? undefined : [],
|
||||
children: [],
|
||||
docCount: 0,
|
||||
};
|
||||
currentLevel.push(node);
|
||||
current.push(node);
|
||||
}
|
||||
|
||||
if (isLeaf) {
|
||||
node.doc = doc;
|
||||
node.docCount = 1;
|
||||
} else {
|
||||
if (!node.children) node.children = [];
|
||||
currentLevel = node.children;
|
||||
// This is a folder — count will be accumulated below
|
||||
}
|
||||
current = node.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively compute docCount for folder nodes
|
||||
function countDocs(node: TreeNode): number {
|
||||
if (node.doc) return 1;
|
||||
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
|
||||
}
|
||||
|
||||
for (const node of root) {
|
||||
node.docCount = countDocs(node);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderTree(nodes: TreeNode[], section: DocSection) {
|
||||
function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{nodes.map((node) => {
|
||||
const isFolder = !!node.children;
|
||||
const isDoc = !!node.doc;
|
||||
const isFolder = node.children.length > 0;
|
||||
const isActive = pathname === `/${node.slug}`;
|
||||
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
||||
|
||||
return (
|
||||
<li key={node.slug}>
|
||||
{isFolder ? (
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className="flex items-center gap-1.5 text-xs py-0.5 rounded hover:bg-[#191a1b] text-[#d0d6e0] hover:text-[#f7f8f8]"
|
||||
>
|
||||
<span>📁</span>
|
||||
{node.name}
|
||||
</Link>
|
||||
) : isDoc ? (
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className="flex items-center gap-1.5 text-xs py-0.5 rounded hover:bg-[#191a1b] text-[#d0d6e0] hover:text-[#f7f8f8] line-clamp-1"
|
||||
>
|
||||
<span>📄</span>
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className={`flex items-center justify-between gap-2 text-xs py-1 px-2 rounded transition-all ${
|
||||
isActive
|
||||
? "text-[#7170ff] bg-[#191a1b]"
|
||||
: hasActiveChild
|
||||
? "text-[#d0d6e0]"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[#191a1b]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{isFolder ? "📁" : "📄"}</span>
|
||||
{node.doc?.title || node.name}
|
||||
</Link>
|
||||
) : null}
|
||||
{node.children && node.children.length > 0 && (
|
||||
<div className="ml-3 mt-0.5">
|
||||
{renderTree(node.children, section)}
|
||||
</span>
|
||||
{isFolder && (
|
||||
<span className="text-[#62666d] bg-[#191a1b] px-1.5 py-0.25 rounded">
|
||||
{node.docCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{node.children.length > 0 && (
|
||||
<div className="ml-4 mt-0.5">
|
||||
{renderTree(node.children, pathname)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
@@ -115,7 +137,8 @@ export default async function SectionIndexPage({
|
||||
}) {
|
||||
const { section } = await params;
|
||||
|
||||
if (!SECTIONS.some((s) => s.id === section)) {
|
||||
const sectionInfo = SECTIONS.find((s) => s.id === section);
|
||||
if (!sectionInfo) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-[#f7f8f8]">Unknown section</h1>
|
||||
@@ -123,39 +146,42 @@ export default async function SectionIndexPage({
|
||||
);
|
||||
}
|
||||
|
||||
const all = await listDocuments();
|
||||
const sectionDocs = all.filter((d) => d.section === section);
|
||||
const tree = buildFolderTree(all, section as DocSection);
|
||||
const sectionInfo = SECTIONS.find((s) => s.id === section)!;
|
||||
const allDocs = await listDocuments();
|
||||
const sectionDocs = allDocs.filter((d) => d.section === section);
|
||||
const tree = buildFolderTree(allDocs, section);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-2xl">{sectionInfo.icon}</span>
|
||||
<h1 className="text-3xl font-medium text-[#f7f8f8]">{sectionInfo.label}</h1>
|
||||
<span className="text-xs text-[#62666d] bg-[#191a1b] px-2 py-0.5 rounded">
|
||||
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#8a8f98] mb-6 max-w-2xl">
|
||||
|
||||
<p className="text-sm text-[#8a8f98] mb-8 max-w-2xl">
|
||||
{sectionInfo.desc}
|
||||
</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
|
||||
Folder structure
|
||||
</h2>
|
||||
{tree.length > 0 ? (
|
||||
renderTree(tree, section as DocSection)
|
||||
) : (
|
||||
<p className="text-xs text-[#62666d]">No documents found.</p>
|
||||
)}
|
||||
</div>
|
||||
{tree.length > 0 ? (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
|
||||
Folder structure
|
||||
</h2>
|
||||
{renderTree(tree, section)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#62666d] mb-8">No documents found in this section.</p>
|
||||
)}
|
||||
|
||||
{sectionDocs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
|
||||
All documents ({sectionDocs.length})
|
||||
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
|
||||
Recently updated
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{sectionDocs
|
||||
{[...sectionDocs]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
@@ -164,10 +190,17 @@ export default async function SectionIndexPage({
|
||||
<Link
|
||||
key={d.slug}
|
||||
href={`/${d.slug}`}
|
||||
className="block text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
|
||||
title={d.title}
|
||||
className="group flex items-center justify-between gap-3 py-2 border-b border-[#141516] last:border-0"
|
||||
>
|
||||
{d.title}
|
||||
<span className="text-sm text-[#d0d6e0] group-hover:text-[#7170ff] transition-colors line-clamp-1">
|
||||
{d.title}
|
||||
</span>
|
||||
<time className="text-xs text-[#62666d] flex-shrink-0">
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user