refactor: Phase 14 consistency pass — remove duplicate, fix breadcrumbs, header nav, DocForm
CI / typecheck + build (turbo) (push) Canceled after 0s

- Delete apps/web/app/docs/page.tsx (superseded by generic [section]/page.tsx)
- Homepage: inline folder tree per section card (📁📄), removed Suspense
- Doc page breadcrumbs: dynamic path from doc.slug (was hardcoded 'Docs')
- Header nav: added Writeups/Research/Notes section links
- DocForm: parent folder dropdown populated from existing folders, slug read-
  only in edit mode, edit mode keeps section disabled, resolved slug shown
- create/page.tsx: use extractFoldersForSection from @mcpedia/core (dedup)
- Removed unused extractFoldersForSection import from page.tsx
This commit is contained in:
asepharyana
2026-08-21 09:02:55 +07:00
parent cc99bd2b74
commit b8d4fc97da
5 changed files with 228 additions and 247 deletions
+23 -6
View File
@@ -325,7 +325,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
return ( return (
<article> <article>
{/* Breadcrumb */} {/* Breadcrumb — dynamic based on the doc's actual path */}
<nav className="flex items-center gap-2 text-xs text-[#62666d] mb-6"> <nav className="flex items-center gap-2 text-xs text-[#62666d] mb-6">
<Link <Link
href="/" href="/"
@@ -333,15 +333,32 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
> >
MCPedia MCPedia
</Link> </Link>
{doc.slug.split("/").map((part, i) => {
const crumbPath = doc.slug.split("/").slice(0, i + 1).join("/");
const isLast = i === doc.slug.split("/").length - 1;
const label = i === 0
? SECTION_LABEL[part] || part
: part
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
return (
<>
<span>/</span> <span>/</span>
<Link <Link
href="/docs" key={crumbPath}
className="hover:text-[#d0d6e0] transition-colors" href={`/${crumbPath}`}
className={
isLast
? "text-[#8a8f98]"
: "hover:text-[#d0d6e0] transition-colors"
}
> >
Docs {label}
</Link> </Link>
<span>/</span> </>
<span className="text-[#8a8f98]">{doc.title}</span> );
})}
</nav> </nav>
{/* Header */} {/* Header */}
+3 -3
View File
@@ -12,10 +12,10 @@ export default async function CreatePage() {
if (!canEdit) redirect("/login"); if (!canEdit) redirect("/login");
const allDocs = await listDocuments(); const allDocs = await listDocuments();
// Build folder suggestions per section using the shared helper from core
const sections = ["docs", "writeups", "research", "notes"];
const docPaths = allDocs.map((d) => d.path); const docPaths = allDocs.map((d) => d.path);
// Build folder suggestions per section using the shared core helper
const sections = ["docs", "writeups", "research", "notes"];
const foldersBySection: Record<string, string[]> = {}; const foldersBySection: Record<string, string[]> = {};
for (const sec of sections) { for (const sec of sections) {
foldersBySection[sec] = extractFoldersForSection(docPaths, sec); foldersBySection[sec] = extractFoldersForSection(docPaths, sec);
-175
View File
@@ -1,175 +0,0 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import type { DocSection } from "@mcpedia/core";
export const dynamic = "force-dynamic";
const SECTIONS: { id: DocSection; label: string }[] = [
{ id: "docs", label: "Documentation" },
{ id: "writeups", label: "Writeups" },
{ id: "research", label: "Research" },
{ id: "notes", label: "Notes" },
];
const SECTION_ICONS: Record<DocSection, string> = {
docs: "📄",
writeups: "📝",
research: "🔬",
notes: "📌",
};
export default async function DocsIndexPage() {
const all = await listDocuments();
// Group by section, sort by updatedAt desc for recency
const bySection = SECTIONS.map((section) => ({
section,
docs: all
.filter((d) => d.section === section.id)
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
),
}));
// Flatten + sort all docs by updatedAt for the hero "recent" strip
const recent = [...all]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 6);
return (
<>
{/* Hero */}
<section className="mb-12">
<h1 className="text-4xl font-medium text-[#f7f8f8] mb-3">
MCPedia Knowledge Base
</h1>
<p className="text-lg text-[#8a8f98] max-w-2xl leading-relaxed">
A content-first knowledge base for humans and AI agents. Documents
covering MCP protocol, TypeScript, infrastructure, and security.
</p>
{/* Search bar */}
<div className="mt-8">
<Link
href="/search"
className="relative flex items-center max-w-2xl group"
>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#62666d] group-focus-within:text-[#5e6ad2] transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span className="w-full pl-10 pr-4 py-3 bg-[#191a1b] border border-[#1f2022] rounded-lg text-[#62666d] group-hover:border-[#5e6ad2]/40 transition-colors">
Search documents, tags, authors...
</span>
</Link>
</div>
{/* Recent strip */}
{recent.length > 0 && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((d) => (
<Link
key={d.slug}
href={`/${d.slug}`}
className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-4 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200"
>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="text-2xl">
{SECTION_ICONS[d.section] || "📄"}
</span>
<time className="text-xs text-[#62666d]">
{new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1">
{d.title}
</h3>
<div className="mt-2 flex flex-wrap gap-1">
{d.tags.slice(0, 3).map((t) => (
<span
key={t}
className="text-xs px-1.5 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
</Link>
))}
</div>
)}
</section>
{/* Sections */}
{bySection.map(({ section, docs }) => {
if (docs.length === 0) return null;
return (
<section key={section.id} className="mb-12">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase">
{section.label}
</h2>
<Link
href={`/${section.id}`}
className="text-xs text-[#62666d] hover:text-[#7170ff] transition-colors"
>
View all ({docs.length})
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{docs.map((d) => (
<Link
key={d.slug}
href={`/${d.slug}`}
className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-5 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200"
>
<div className="flex items-start justify-between gap-2 mb-3">
<span className="text-2xl">
{SECTION_ICONS[section.id] || "📄"}
</span>
<time className="text-xs text-[#62666d]">
{new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1 mb-2">
{d.title}
</h3>
<div className="flex flex-wrap gap-1">
{d.tags.slice(0, 4).map((t) => (
<span
key={t}
className="text-xs px-1.5 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
</Link>
))}
</div>
</section>
);
})}
</>
);
}
+25
View File
@@ -10,6 +10,13 @@ export const metadata: Metadata = {
"A content-first knowledge base. Humans read the Web UI; AI agents use the MCP server. Both share one Core.", "A content-first knowledge base. Humans read the Web UI; AI agents use the MCP server. Both share one Core.",
}; };
const SECTIONS = [
{ id: "docs", label: "Documentation", icon: "📄" },
{ id: "writeups", label: "Writeups", icon: "📝" },
{ id: "research", label: "Research", icon: "🔬" },
{ id: "notes", label: "Notes", icon: "📌" },
] as const;
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@@ -30,6 +37,24 @@ export default function RootLayout({
> >
Docs Docs
</Link> </Link>
<Link
href="/writeups"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Writeups
</Link>
<Link
href="/research"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Research
</Link>
<Link
href="/notes"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
>
Notes
</Link>
<Link <Link
href="/search" href="/search"
className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors" className="text-[#d0d6e0] hover:text-[#f7f8f8] transition-colors"
+166 -52
View File
@@ -1,6 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { listDocuments } from "@mcpedia/core"; import { listDocuments } from "@mcpedia/core";
import { cookies } from "next/headers";
import type { DocSection } from "@mcpedia/core"; import type { DocSection } from "@mcpedia/core";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,10 +16,140 @@ const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[]
{ id: "notes", label: "Notes", icon: "📌", desc: "Quick references, patterns, and gotchas." }, { id: "notes", label: "Notes", icon: "📌", desc: "Quick references, patterns, and gotchas." },
]; ];
/**
* Build a hierarchical folder tree from a flat list of document paths.
* Each tree node is either a folder (has children) or a leaf doc.
*/
interface TreeNode {
name: string;
slug: string;
title: string;
children: TreeNode[];
isLeaf: boolean;
}
function buildFolderTree(paths: string[], section: string): TreeNode[] {
const tree: TreeNode[] = [];
const base = `${section}/`;
// Build a map of path → title (leaf docs only)
// We don't have titles here, so we use the last path segment as display name
const pathToTitle = new Map<string, string>();
for (const p of paths) {
const rel = p.startsWith(base) ? p.slice(base.length) : p;
const cleanPath = rel.replace(/\.md$/, "");
const parts = cleanPath.split("/");
const leafName = parts[parts.length - 1];
const title = leafName
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
pathToTitle.set(cleanPath, title);
}
const addPath = (cleanPath: string) => {
const parts = cleanPath.split("/");
let current = tree;
let currentPath = section;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLeaf = i === parts.length - 1;
currentPath = `${currentPath}/${part}`;
let node = current.find((n) => n.name === part);
if (!node) {
node = {
name: part,
slug: currentPath,
title: "",
children: [],
isLeaf: false,
};
current.push(node);
}
if (isLeaf) {
node.isLeaf = true;
node.title = pathToTitle.get(cleanPath) ?? part;
} else if (!node.title) {
// Folder title: capitalize the folder name
node.title = part
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
current = node.children;
}
};
for (const docPath of paths) {
const rel = docPath.startsWith(base) ? docPath.slice(base.length) : docPath;
const cleanPath = rel.replace(/\.md$/, "");
addPath(cleanPath);
}
return tree;
}
function renderTree(nodes: TreeNode[], pathname: string) {
return (
<ul className="space-y-0.5">
{nodes.map((node) => (
<li key={node.slug}>
<Link
href={`/${node.slug}`}
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
pathname === `/${node.slug}`
? "text-[#7170ff] bg-[#191a1b]"
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[#191a1b]"
}`}
title={node.title}
>
<span>{node.isLeaf && node.children.length === 0 ? "📄" : "📁"}</span>
{node.title || node.name}
</Link>
{node.children.length > 0 && (
<div className="ml-3 mt-0.5">
{renderTree(node.children, pathname)}
</div>
)}
</li>
))}
</ul>
);
}
interface SectionTreeProps {
section: string;
docPaths: string[];
pathname: string;
}
async function SectionTree({ section, docPaths, pathname }: SectionTreeProps) {
const tree = buildFolderTree(docPaths.filter((p) => p.startsWith(`${section}/`)), section);
const sectionInfo = SECTIONS.find((s) => s.id === section)!;
return (
<div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-xl">{sectionInfo.icon}</span>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors">
{sectionInfo.label}
</h3>
</div>
{tree.length > 0 ? (
renderTree(tree, pathname)
) : (
<p className="text-xs text-[#62666d] pl-1">No documents</p>
)}
</div>
);
}
export default async function HomePage() { export default async function HomePage() {
const all = await listDocuments(); const all = await listDocuments();
const cookieStore = await cookies(); const docPaths = all.map((d) => d.path);
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
const recent = [...all] const recent = [...all]
.sort( .sort(
@@ -29,23 +158,18 @@ export default async function HomePage() {
) )
.slice(0, 6); .slice(0, 6);
const bySection = SECTIONS.map((s) => ({
...s,
docs: all.filter((d) => d.section === s.id).slice(0, 4),
total: all.filter((d) => d.section === s.id).length,
}));
return ( return (
<> <>
{/* ── Hero ── */} {/* Hero */}
<section className="mb-16"> <section className="mb-16">
<div className="max-w-3xl"> <div className="max-w-3xl">
<h1 className="text-5xl font-medium text-[#f7f8f8] mb-4 leading-tight"> <h1 className="text-5xl font-medium text-[#f7f8f8] mb-4 leading-tight">
MCPedia MCPedia
</h1> </h1>
<p className="text-xl text-[#8a8f98] mb-8 leading-relaxed max-w-2xl"> <p className="text-xl text-[#8a8f98] mb-8 leading-relaxed max-w-2xl">
A content-first knowledge base for humans and AI agents. Read the A content-first knowledge base for humans and AI agents. Browse the
web docs, call the MCP server, or browse the full document archive. hierarchical folder structure below, search all documents, or connect
via the MCP server.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3 mb-8"> <div className="flex flex-col sm:flex-row gap-3 mb-8">
@@ -57,65 +181,55 @@ export default async function HomePage() {
</Link> </Link>
<Link <Link
href="/search" href="/search"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-[#191a1b] border border-[#1f2022] text-[#d0d6e0] rounded-lg font-medium hover:border-[#5e6ad2]/40 hover:text-[#f7f8f8] transition-colors" className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-[#191a1b] border border-[#222] text-[#d0d6e0] rounded-lg font-medium hover:border-[#5e6ad2]/40 hover:text-[#f7f8f8] transition-colors"
> >
Search Search
</Link> </Link>
</div> </div>
{/* Search box */}
<Link
href="/search"
className="relative flex items-center max-w-2xl group mb-12"
>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#62666d] group-focus-within:text-[#5e6ad2] transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span className="w-full pl-10 pr-4 py-3 bg-[#191a1b] border border-[#1f2022] rounded-lg text-[#62666d] group-hover:border-[#5e6ad2]/40 transition-colors">
Search documents, tags, authors...
</span>
</Link>
</div> </div>
</section> </section>
{/* ── Section Overview ── */} {/* Hierarchical Folder Tree */}
<section className="mb-16"> <section className="mb-16">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-6"> <h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-6">
Browse by section Browse by section
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{bySection.map((s) => ( {SECTIONS.map((s) => {
<Link const sectionDocs = all.filter((d) => d.section === s.id);
return (
<div
key={s.id} key={s.id}
href={`/${s.id}`}
className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-5 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200" className="group block bg-[#0f1011] border border-[#1f2022] rounded-lg p-5 hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all duration-200"
> >
<div className="text-3xl mb-3">{s.icon}</div> <div className="flex items-start justify-between gap-2 mb-3">
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors mb-1"> <span className="text-2xl">{s.icon}</span>
<Link
href={`/${s.id}`}
className="text-xs text-[#62666d] hover:text-[#7170ff] transition-colors"
>
View all ({sectionDocs.length})
</Link>
</div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors mb-3">
{s.label} {s.label}
</h3> </h3>
<p className="text-xs text-[#62666d] mb-2 line-clamp-2"> <p className="text-xs text-[#8a8f98] mb-3 line-clamp-2">
{s.desc} {s.desc}
</p> </p>
<span className="text-xs text-[#8a8f98]"> {/* Inline folder tree */}
{s.total} document{s.total !== 1 ? "s" : ""} <SectionTree
</span> section={s.id}
</Link> docPaths={docPaths}
))} pathname="/"
/>
</div>
);
})}
</div> </div>
</section> </section>
{/* ── Recent Documents ── */} {/* Recent Documents */}
{recent.length > 0 && ( {recent.length > 0 && (
<section className="mb-16"> <section className="mb-16">
<div className="flex items-baseline justify-between mb-6"> <div className="flex items-baseline justify-between mb-6">
@@ -147,10 +261,10 @@ export default async function HomePage() {
})} })}
</time> </time>
</div> </div>
<h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1 mb-2"> <h3 className="font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1">
{d.title} {d.title}
</h3> </h3>
<div className="flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{d.tags.slice(0, 3).map((t) => ( {d.tags.slice(0, 3).map((t) => (
<span <span
key={t} key={t}
@@ -166,7 +280,7 @@ export default async function HomePage() {
</section> </section>
)} )}
{/* ── MCP info ── */} {/* MCP info */}
<section className="border-t border-[#1f2022] pt-8"> <section className="border-t border-[#1f2022] pt-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>