feat: Phase 14 — hierarchical folder structure (GitHub-style nested folders)
CI / typecheck + build (turbo) (push) Canceled after 0s

- Section index pages ([section]/page.tsx): shows folder tree + flat doc list
- Folder index pages: [section]/[...slug]/page.tsx detects folder paths
  and renders subfolder + document listing instead of 404
- Sidebar tree: hierarchical grouping from flat doc slugs, folder icons (📁)
  with proper indentation per depth level
- DocForm v2: parent folder dropdown (populated from existing folders),
  slug input for leaf name, resolved path display
- Core helpers: extractFoldersForSection + classifyPath exported from @mcpedia/core
- CTF writeup reorganized: pwn-100 ret2win moved into pwn/ subfolder
- _index.md folder intro pages for ctf/ and defcon-quals-2024/
- STANDARD_KEYS includes extraFields to avoid badge duplication
This commit is contained in:
asepharyana
2026-08-21 08:37:41 +07:00
parent 90a43e5d0c
commit ab3a2dc456
10 changed files with 840 additions and 111 deletions
+138 -4
View File
@@ -1,11 +1,12 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import { getDocument, getRelated, listRevisions } from "@mcpedia/core";
import { getDocument, getRelated, listRevisions, listDocuments } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import Markdown from "@/components/Markdown";
import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC";
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
// Render at request time (content in Postgres, not available at build time).
export const dynamic = "force-dynamic";
@@ -78,7 +79,7 @@ function CustomFieldBadges({
displayValue = value.join(", ");
} else if (typeof value === "object" && value !== null) {
// Objects → neutral badge, truncated JSON
displayValue = JSON.stringify(value).slice(0, 40) +
displayValue = JSON.stringify(value).slice(0, 40) +
(JSON.stringify(value).length > 40 ? "…" : "");
} else {
// String values — auto-detect content type for styling
@@ -140,10 +141,135 @@ function CustomFieldBadges({
);
}
/**
* Folder index page — lists all docs (and subfolders) whose path starts with
* the given prefix. This enables GitHub-style nested folder browsing.
*/
function FolderIndexPage({
section,
slug,
docPaths,
}: {
section: string;
slug: string;
docPaths: string[];
}) {
const prefix = `${section}/${slug}/`;
const folderDocs = docPaths
.filter((p) => p.startsWith(prefix))
.map((p) => p.slice(prefix.length).replace(/\.md$/, ""))
.sort();
// Group into immediate children: folders vs leaf docs
const immediateFolders = new Set<string>();
const immediateDocs: { name: string; slug: string }[] = [];
for (const relPath of folderDocs) {
const parts = relPath.split("/");
if (parts.length === 1) {
// Leaf doc directly in this folder
immediateDocs.push({ name: parts[0], slug: `${section}/${slug}/${parts[0]}`.replace(/\/+/g, "/") });
} else {
// Deeper — register the immediate folder
immediateFolders.add(parts[0]);
}
}
const folders = [...immediateFolders].sort();
return (
<div>
<nav className="flex items-center gap-2 text-xs text-[#62666d] mb-6">
<Link href="/" className="hover:text-[#d0d6e0] transition-colors">
MCPedia
</Link>
<span>/</span>
{slug.split("/").map((part, i) => {
const path = `${section}/${slug.split("/").slice(0, i + 1).join("/")}`;
const isLast = i === slug.split("/").length - 1;
return (
<>
<Link
key={path}
href={`/${path}`}
className={isLast ? "text-[#8a8f98]" : "hover:text-[#d0d6e0] transition-colors"}
>
{part}
</Link>
{!isLast && <span>/</span>}
</>
);
})}
</nav>
<div className="flex items-center gap-2 mb-6">
<span className="text-xl">📁</span>
<h1 className="text-3xl font-medium text-[#f7f8f8]">{slug.split("/").pop()}</h1>
</div>
{(folders.length > 0 || immediateDocs.length > 0) ? (
<div>
{folders.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
Subfolders
</h2>
<div className="space-y-2">
{folders.map((folder) => (
<Link
key={folder}
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
className="flex items-center gap-2 text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors"
>
<span>📁</span> {folder}
</Link>
))}
</div>
</div>
)}
{immediateDocs.length > 0 && (
<div>
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
Documents
</h2>
<div className="space-y-2">
{immediateDocs.map((doc) => (
<Link
key={doc.slug}
href={`/${doc.slug}`}
className="flex items-center gap-2 text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
>
<span>📄</span> {doc.name}
</Link>
))}
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-[#62666d]">No documents found in this folder.</p>
)}
</div>
);
}
export default async function DocPage({ params, searchParams }: DocPageProps) {
const { section, slug } = await params;
const { edit } = await searchParams;
const fullSlug = `${section}/${slug.join("/")}`;
// Fetch all docs to classify the path as "doc" or "folder"
const allDocs = await listDocuments();
const docPaths = allDocs.map((d) => d.path);
const classification = classifyPath(docPaths, fullSlug);
// If the slug path is a folder (not a leaf doc), render the folder index
if (classification === "folder") {
return <FolderIndexPage section={section} slug={slug.join("/")} docPaths={docPaths} />;
}
// Otherwise, treat as a document (existing behavior)
const doc = await getDocument(fullSlug);
if (!doc) notFound();
@@ -154,11 +280,13 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
// Any other key in the document metadata becomes a dynamic badge.
const STANDARD_KEYS = new Set([
"id", "slug", "title", "type", "section", "status",
"author", "tags", "path", "createdAt", "updatedAt",
"author", "tags", "path", "createdAt", "updatedAt", "extraFields",
]);
// Edit mode: inline form
if (edit === "1" && canEdit) {
// Load existing folders for the folder picker in edit mode
const existingFolders = extractFoldersForSection(docPaths, doc.section);
return (
<div>
<Link
@@ -174,6 +302,9 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
mode="edit"
slug={fullSlug}
secret={WEBHOOK_SECRET}
existingFolders={{
[doc.section]: existingFolders,
}}
initial={{
title: doc.title,
body: doc.body,
@@ -182,6 +313,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
status: doc.status,
tags: doc.tags,
author: doc.author,
extraFields: doc.extraFields,
}}
/>
</div>
@@ -215,7 +347,9 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">{SECTION_ICON[doc.section] || "📄"}</span>
<span className="text-xl">
{SECTION_ICON[doc.section] || "📄"}
</span>
<span className="text-xs font-medium text-[#7170ff] uppercase">
{SECTION_LABEL[doc.section] || doc.section}
</span>
+178
View File
@@ -0,0 +1,178 @@
import Link from "next/link";
import { listDocuments, type DocumentMeta } from "@mcpedia/core";
import type { DocSection } from "@mcpedia/core";
export const dynamic = "force-dynamic";
const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[] = [
{
id: "docs",
label: "Documentation",
icon: "📄",
desc: "Setup guides, API references, and protocol specs.",
},
{ id: "writeups", label: "Writeups", icon: "📝", desc: "Post-mortems, debugging stories, and case studies." },
{ id: "research", label: "Research", icon: "🔬", desc: "Deep-dive analysis, architecture notes, and experiments." },
{ id: "notes", label: "Notes", icon: "📌", desc: "Quick references, patterns, and gotchas." },
];
/**
* 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`.
*/
interface TreeNode {
name: string;
slug: string;
doc?: DocumentMeta;
children?: TreeNode[];
}
function buildFolderTree(docs: DocumentMeta[], 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 currentPath = section;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
currentPath = `${currentPath}/${part}`;
const isLeaf = i === parts.length - 1;
let node = currentLevel.find((n) => n.name === part);
if (!node) {
node = {
name: part === "" ? section : part,
slug: currentPath,
children: isLeaf ? undefined : [],
};
currentLevel.push(node);
}
if (isLeaf) {
node.doc = doc;
} else {
if (!node.children) node.children = [];
currentLevel = node.children;
}
}
}
return root;
}
function renderTree(nodes: TreeNode[], section: DocSection) {
return (
<ul className="space-y-0.5">
{nodes.map((node) => {
const isFolder = !!node.children;
const isDoc = !!node.doc;
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>
{node.doc?.title || node.name}
</Link>
) : null}
{node.children && node.children.length > 0 && (
<div className="ml-3 mt-0.5">
{renderTree(node.children, section)}
</div>
)}
</li>
);
})}
</ul>
);
}
export default async function SectionIndexPage({
params,
}: {
params: Promise<{ section: string }>;
}) {
const { section } = await params;
if (!SECTIONS.some((s) => s.id === section)) {
return (
<div>
<h1 className="text-2xl font-medium text-[#f7f8f8]">Unknown section</h1>
</div>
);
}
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)!;
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>
</div>
<p className="text-sm text-[#8a8f98] mb-6 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>
{sectionDocs.length > 0 && (
<div>
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
All documents ({sectionDocs.length})
</h2>
<div className="space-y-2">
{sectionDocs
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.map((d) => (
<Link
key={d.slug}
href={`/${d.slug}`}
className="block text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
title={d.title}
>
{d.title}
</Link>
))}
</div>
</div>
)}
</div>
);
}
+84 -31
View File
@@ -7,6 +7,7 @@ interface DocFormProps {
mode: "create" | "edit";
slug?: string;
secret: string;
existingFolders?: Record<string, string[]>;
initial?: {
title?: string;
body?: string;
@@ -15,18 +16,26 @@ interface DocFormProps {
status?: "published" | "draft";
tags?: string[];
author?: string;
extraFields?: Record<string, unknown>;
};
}
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] as const;
export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
export default function DocForm({
mode,
slug,
secret,
existingFolders,
initial,
}: DocFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState(initial?.title ?? "");
const [slugInput, setSlugInput] = useState("");
const [body, setBody] = useState(initial?.body ?? "");
const [section, setSection] = useState(initial?.section ?? "docs");
const [type, setType] = useState(initial?.type ?? "documentation");
@@ -35,16 +44,26 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
const [author, setAuthor] = useState(initial?.author ?? "");
// Dynamic custom fields — content creators can add any metadata they want
// (e.g. CTF: event, challenge, category, difficulty, points, team_name, ...)
const [customFields, setCustomFields] = useState<
Array<{ key: string; value: string }>
>([]);
// Parent folder selection (hierarchical structure like GitHub folders)
const [parentFolder, setParentFolder] = useState("");
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
// For edit mode, slug is fixed (full path). For create mode, build from folder + slugInput.
const isEdit = mode === "edit";
const effectiveSlug = isEdit ? (slug ?? "") : parentFolder
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
: slugInput;
const availableFolders = existingFolders?.[section] ?? [];
const baseInputCls =
"w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]";
@@ -67,10 +86,17 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
setLoading(true);
setError(null);
if (!effectiveSlug || !title || !body) {
setError("Slug, title, and body are required.");
setLoading(false);
return;
}
// Build payload with custom fields flattened at top level
const payload: Record<string, unknown> = {
title,
body,
slug: effectiveSlug,
section,
type,
status,
@@ -88,19 +114,13 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
try {
let res: Response;
if (mode === "create") {
const slugVal =
slug ??
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
res = await fetch("/api/docs", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-webhook-secret": secret,
},
body: JSON.stringify({ slug: slugVal, ...payload }),
body: JSON.stringify(payload),
});
} else {
const editSlug = slug ?? "";
@@ -149,22 +169,6 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
/>
</div>
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
Slug
</label>
<input
type="text"
value={slug ?? ""}
readOnly
className="w-full px-3 py-2 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
placeholder={section}
/>
<p className="text-xs text-[#62666d] mt-1">
URL-safe path under the section.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
@@ -174,6 +178,7 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
value={section}
onChange={(e) => setSection(e.target.value as typeof section)}
className={baseInputCls}
disabled={isEdit}
>
{SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>
@@ -213,6 +218,56 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
</div>
</div>
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
Parent folder (optional)
</label>
<select
value={parentFolder}
onChange={(e) => setParentFolder(e.target.value)}
className={baseInputCls}
disabled={isEdit}
>
<option value="">(root of section)</option>
{availableFolders.map((folder) => (
<option key={folder} value={folder}>
{folder}
</option>
))}
</select>
<p className="text-xs text-[#62666d] mt-1">
Place this document inside an existing folder. The slug will be
prepended with the folder path (e.g. selecting "ctf/defcon-quals-2024"
+ slug "pwn-100" → "ctf/defcon-quals-2024/pwn-100").
</p>
</div>
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
{isEdit ? "Full slug (read-only)" : "Slug (file name)"}
</label>
{isEdit ? (
<input
type="text"
value={slug ?? ""}
readOnly
className="w-full px-3 py-2 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
/>
) : (
<input
type="text"
value={slugInput}
onChange={(e) => setSlugInput(e.target.value)}
className={baseInputCls}
placeholder="my-document-slug"
required
/>
)}
<p className="text-xs text-[#62666d] mt-1">
Resolved path: <code>{effectiveSlug || section}</code>
</p>
</div>
<div>
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
Tags
@@ -246,8 +301,8 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
<p className="text-xs text-[#62666d] mb-3">
Add extra frontmatter fields. Content creators name and define their own
metadata here — the system auto-renders any field as a badge on the doc
page based on its value (e.g. numbers → points style, "easy" → green
difficulty badge, etc.).{" "}</p>
page based on its value.
</p>
{customFields.length > 0 && (
<div className="space-y-3 mb-3">
{customFields.map((field, i) => (
@@ -263,9 +318,7 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
type="text"
placeholder="value"
value={field.value}
onChange={(e) =>
updateCustomField(i, "value", e.target.value)
}
onChange={(e) => updateCustomField(i, "value", e.target.value)}
className="flex-1 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
/>
<button
@@ -295,7 +348,7 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#d0d6e0] placeholder-[#62666d] font-mono text-sm focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
className="w-full h-96 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] font-mono text-sm focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"
placeholder="# Heading&#10;&#10;Content here..."
/>
</div>
+150 -45
View File
@@ -18,6 +18,113 @@ const SECTIONS = [
{ 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<string, TreeNode> {
// Returns a map from section id → tree root for that section.
const result: Record<string, TreeNode> = {};
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<string, TreeNode>();
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 (
<li key={node.slug}>
<Link
href={`/${node.slug}`}
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
isActive
? "text-[#7170ff] bg-[#191a1b]"
: hasActiveChild
? "text-[#d0d6e0]"
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[#191a1b]"
}`}
style={{ marginLeft: `${indent}px` }}
title={node.title}
>
<span>{isFolder ? "📁" : "📄"}</span>
{node.title || node.name}
</Link>
{node.children && node.children.length > 0 && (
<ul>
{node.children.map((child) => renderTreeNode(child, 0, pathname))}
</ul>
)}
</li>
);
}
/** 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() {
@@ -34,66 +141,64 @@ export default function Sidebar() {
.catch(() => setDocs([]));
}, []);
const bySection: Record<string, Doc[]> = {};
for (const doc of docs) {
if (!bySection[doc.section]) bySection[doc.section] = [];
bySection[doc.section].push(doc);
}
const tree = buildFolderTree(docs);
// Flatten all docs count for display
const totalDocs = docs.length;
return (
<nav className="h-full overflow-y-auto py-6">
<ul className="space-y-1 px-3 text-sm">
<div className="mb-4 px-3">
<span className="text-xs text-[#62666d] uppercase">
{totalDocs} documents · {SECTIONS.length} sections
</span>
</div>
<ul className="space-y-0.5 px-3">
{SECTIONS.map(({ id, label, icon }) => {
const sectionDocs = bySection[id] || [];
const treeRoot = tree[id];
const sectionDocs = docs.filter((d) => d.section === id);
if (sectionDocs.length === 0) return null;
// Check if any doc in this section is the active page
const isActive = sectionDocs.some(
(doc) => pathname === `/${doc.slug}` || pathname === `/${doc.section}`,
);
const isActive = pathname.startsWith(`/${id}`);
return (
<li key={id}>
<div
className={`text-xs font-medium mb-1 mt-4 first:mt-0 flex items-center gap-1.5 ${
isActive
? "text-[#7170ff]"
: "text-[#62666d]"
className={`text-xs font-medium mb-1 flex items-center gap-1.5 ${
isActive ? "text-[#7170ff]" : "text-[#62666d]"
}`}
>
<span>{icon}</span>
{label}
</div>
<ul className="space-y-0.5">
{sectionDocs.map((doc) => {
const parts = doc.slug.split("/").slice(1);
const label = parts[parts.length - 1];
const formatted = label
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
const depth = doc.slug.split("/").length;
const indent = depth - 1;
const isDocActive = pathname === `/${doc.slug}`;
return (
<li
key={doc.slug}
style={{ marginLeft: `${indent * 12}px` }}
>
<Link
href={`/${doc.slug}`}
className={`block text-xs py-0.5 rounded transition-all ${
isDocActive
? "text-[#7170ff] bg-[#191a1b]"
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
}`}
title={doc.title}
>
{formatted || doc.slug}
</Link>
</li>
);
})}
{treeRoot && treeRoot.children.length > 0
? treeRoot.children.map((node) =>
renderTreeNode(node, 0, pathname),
)
: sectionDocs.map((doc) => {
const parts = doc.slug.split("/").slice(1);
const label = parts[parts.length - 1]
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
const isDocActive = pathname === `/${doc.slug}`;
return (
<li key={doc.slug}>
<Link
href={`/${doc.slug}`}
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
isDocActive
? "text-[#7170ff] bg-[#191a1b]"
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
}`}
title={doc.title}
>
<span>📄</span>
{label}
</Link>
</li>
);
})}
</ul>
</li>
);
+21 -31
View File
@@ -1,44 +1,34 @@
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { listDocuments, extractFoldersForSection } from "@mcpedia/core";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import DocForm from "@/components/DocForm";
import Link from "next/link";
import { WEBHOOK_SECRET } from "@mcpedia/config";
export const dynamic = "force-dynamic";
// GET /create — show the document create form.
// Requires admin cookie auth.
export default async function CreatePage() {
const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
if (!canEdit) {
return (
<div className="text-center py-16">
<h1 className="text-2xl font-light text-[#f7f8f8] mb-4">
Authentication required
</h1>
<p className="text-[#8a8f98] mb-6">
You must be logged in to create documents.
</p>
<Link
href="/login"
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-[#5e6ad2] text-white hover:bg-[#7170ff] transition-colors font-medium text-sm"
>
Login
</Link>
</div>
);
if (!canEdit) redirect("/login");
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 foldersBySection: Record<string, string[]> = {};
for (const sec of sections) {
foldersBySection[sec] = extractFoldersForSection(docPaths, sec);
}
return (
<div>
<Link
href="/"
className="text-xs text-[#8a8f98] hover:text-[#d0d6e0] mb-4 inline-block"
>
← Back
</Link>
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6">
Create Document
</h1>
<DocForm mode="create" secret={WEBHOOK_SECRET} />
<h1 className="text-3xl font-medium text-[#f7f8f8] mb-6">Create Document</h1>
<DocForm
mode="create"
secret={WEBHOOK_SECRET}
existingFolders={foldersBySection}
/>
</div>
);
}