feat: Phase 14 — hierarchical folder structure (GitHub-style nested folders)
CI / typecheck + build (turbo) (push) Canceled after 0s
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:
@@ -0,0 +1,151 @@
|
|||||||
|
# Phase 14 — Hierarchical Folder Structure
|
||||||
|
|
||||||
|
> User: "gk ada bedanya, maksud saya inginnya itu bisa yg bertingkat seperti github yg memiliki folder dalam folder"
|
||||||
|
> Context: after the full dynamic-custom-fields overhaul (Phase 13), the user
|
||||||
|
> wants document URLs/content organized in **nested folders** like GitHub —
|
||||||
|
> `writeups/ctf/defcon-quals-2024/pwn-100/...` with subfolders under subfolders,
|
||||||
|
> not just one level deep.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current URL scheme is `/<section>/<slug>` where `slug` can contain `/`
|
||||||
|
(e.g. `writeups/ctf/defcon-quals-2024/pwn-100-ret2win-alignment` →
|
||||||
|
`/writeups/ctf/defcon-quals-2024/pwn-100-ret2win-alignment`). This works for
|
||||||
|
**files** but there are no **folder-level index pages** — navigating to
|
||||||
|
`/writeups/ctf/defcon-quals-2024/` returns 404 because Next.js catch-all
|
||||||
|
`[section]/[...slug]/page.tsx` requires at least one slug segment beyond the
|
||||||
|
section, and the sidebar only shows flat doc titles (no folder tree).
|
||||||
|
|
||||||
|
GitHub's model: `github.com/org/repo/tree/main/path/to/folder/file` — every
|
||||||
|
folder has an index page (`/path/to/folder/`) listing its contents.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### 1. Folder Index Pages
|
||||||
|
|
||||||
|
**Create `apps/web/app/[section]/[...slug]/folder.tsx`** (or a parallel route).
|
||||||
|
Actually — cleaner approach per Next.js App Router: the catch-all
|
||||||
|
`[section]/[...slug]/page.tsx` handles both. Add logic: if the slug resolves to
|
||||||
|
an actual markdown file → doc page (existing behavior). If the slug resolves to
|
||||||
|
a **directory** (folder of docs) → render a folder index listing all docs whose
|
||||||
|
`path` starts with that prefix.
|
||||||
|
|
||||||
|
**Mechanism:**
|
||||||
|
- Call `listDocuments()` to get all docs.
|
||||||
|
- The incoming URL path is `{section}/{...slug}`.
|
||||||
|
- Build the "folder prefix" = `${section}/${slug.join("/")}/` (with trailing `/`,
|
||||||
|
or just `${section}/${slug.join("/")}` if no slug segments).
|
||||||
|
- Filter docs whose `doc.path` starts with that prefix.
|
||||||
|
- If exactly one doc matches AND its path === prefix (trimmed .md) → it's a
|
||||||
|
doc page (existing). If zero or multiple match and they all start with the
|
||||||
|
prefix → it's a folder index.
|
||||||
|
- Edge: a folder with exactly one doc whose path matches exactly — still a doc
|
||||||
|
page. A folder is when there are docs at `prefix/sub/...`.
|
||||||
|
|
||||||
|
**Better heuristic:** A slug path is a "folder" if there exist docs whose `path`
|
||||||
|
is `prefix/deep/...` (i.e., the slug is a parent of other doc paths, not a
|
||||||
|
leaf itself). A slug is a "leaf doc" if `path === prefix + ".md"`.
|
||||||
|
|
||||||
|
### 2. Sidebar Tree
|
||||||
|
|
||||||
|
**Update `Sidebar.tsx`:**
|
||||||
|
- `listDocuments()` already returns all docs with their full `slug` and `path`.
|
||||||
|
- Build a **tree** from the flat list: split each slug by `/`, create nested
|
||||||
|
folder nodes.
|
||||||
|
- Render nested `<ul>` with indentation (already done via `marginLeft` based on
|
||||||
|
depth).
|
||||||
|
- **Folder nodes** (collapsed/expanded) get a folder icon 📁 and a CSS class.
|
||||||
|
- Clicking a folder → navigates to the folder index page `/{section}/{path}`.
|
||||||
|
- **Leaf doc nodes** → link to `/{doc.slug}` (existing behavior).
|
||||||
|
- Group by the first segment after section too (e.g. `ctf/defcon-quals-2024/`
|
||||||
|
is a folder, then `pwn-100-...` are children).
|
||||||
|
|
||||||
|
Tree-building algorithm (from flat slugs):
|
||||||
|
```
|
||||||
|
For slug "writeups/ctf/defcon-quals-2024/pwn-100-ret2win-alignment":
|
||||||
|
parts = ["writeups", "ctf", "defcon-quals-2024", "pwn-100-ret2win-alignment"]
|
||||||
|
→ tree: writeups → ctf → defcon-quals-2024 → pwn-100-ret2win-alignment (leaf)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DocForm / Folder Selection
|
||||||
|
|
||||||
|
**Update `DocForm.tsx`:**
|
||||||
|
- Add a "Parent folder" input (autocomplete or text) that shows existing folders
|
||||||
|
for the selected section. The slug field already supports `/` but the user
|
||||||
|
experience is better with folder picker.
|
||||||
|
- When creating, the slug becomes `{parentFolder}/{slug}` automatically.
|
||||||
|
- Show existing folder structure as `<select>` or tree picker.
|
||||||
|
|
||||||
|
### 4. Example Hierarchy
|
||||||
|
|
||||||
|
Create a real hierarchical structure to demonstrate:
|
||||||
|
```
|
||||||
|
writeups/
|
||||||
|
ctf/
|
||||||
|
defcon-quals-2024/
|
||||||
|
pwn/
|
||||||
|
pwn-100-ret2win-alignment.md
|
||||||
|
pwn-200-bof-heap.md
|
||||||
|
crypto/
|
||||||
|
crypto-100-xor.md
|
||||||
|
crypto-200-rsa.md
|
||||||
|
template/
|
||||||
|
writeup-template.md
|
||||||
|
_index.md ← folder index (optional intro)
|
||||||
|
hackthebox/
|
||||||
|
machine-name/
|
||||||
|
walkthrough.md
|
||||||
|
```
|
||||||
|
|
||||||
|
For now, reorganize the existing Defcon writeup into proper subfolders + add a
|
||||||
|
folder index page. The existing `content/writeups/ctf/defcon-quals-2024/` is
|
||||||
|
already a folder — just need the folder index route to work.
|
||||||
|
|
||||||
|
### 5. Route Changes
|
||||||
|
|
||||||
|
**Current:** `[section]/[...slug]/page.tsx` — catch-all requires ≥1 slug segment.
|
||||||
|
- `/writeups` → `notFound()` (no index for bare section unless we add one)
|
||||||
|
- `/writeups/ctf` → catch-all gets `slug=["ctf"]` → currently treated as a doc
|
||||||
|
(looks up `writeups/ctf` doc, 404 if none)
|
||||||
|
- `/writeups/ctf/defcon-quals-2024/` → catch-all `slug=["ctf","defcon-quals-2024"]`
|
||||||
|
|
||||||
|
**Plan:**
|
||||||
|
1. Add `/writeups/page.tsx` (section index) — lists top-level folders + root
|
||||||
|
docs in that section. (Currently `/docs/page.tsx` exists but `/writeups/page.tsx`
|
||||||
|
doesn't.)
|
||||||
|
2. In `[section]/[...slug]/page.tsx`: at the top of the page component, check if
|
||||||
|
the slug path is a folder (has child docs). If so, render folder index instead
|
||||||
|
of doc page.
|
||||||
|
|
||||||
|
**Section index pages:** Create `[section]/page.tsx` for all 4 sections, or a
|
||||||
|
generic one. Currently only `/docs/page.tsx` exists. Add a shared
|
||||||
|
`SectionIndex` component.
|
||||||
|
|
||||||
|
### 6. Files to Change
|
||||||
|
|
||||||
|
```
|
||||||
|
new: apps/web/app/[section]/page.tsx # generic section index (folder + doc listing)
|
||||||
|
mod: apps/web/app/[section]/[...slug]/page.tsx # add folder-index detection
|
||||||
|
mod: apps/web/app/components/Sidebar.tsx # tree from flat slugs
|
||||||
|
mod: apps/web/app/components/DocForm.tsx # parent folder picker
|
||||||
|
new: content/writeups/ctf/defcon-quals-2024/_index.md # folder intro (optional)
|
||||||
|
new: content/writeups/ctf/_index.md # CTF section intro
|
||||||
|
mod: apps/web/app/docs/page.tsx # may need generic version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Verification
|
||||||
|
|
||||||
|
- `/writeups` → 200, shows folders: ctf/, template/
|
||||||
|
- `/writeups/ctf` → 200, folder index listing defcon-quals-2024/
|
||||||
|
- `/writeups/ctf/defcon-quals-2024` → 200, folder index listing pwn-100-...
|
||||||
|
- `/writeups/ctf/defcon-quals-2024/pwn-100-ret2win-alignment` → 200, doc page
|
||||||
|
- Sidebar shows nested tree with folder icons
|
||||||
|
- DocForm parent folder picker works
|
||||||
|
- `bun run test` green, `turbo run typecheck` green
|
||||||
|
- Live verification via curl
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- User: "pastikan semua dinamis dan rapih untuk banyak situasi jadi tergantung
|
||||||
|
user bukan hardcode" — folder detection must be content-driven, not config.
|
||||||
|
- No breaking existing flat slugs.
|
||||||
|
- Section icons/labels stay the same.
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
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 { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import DocForm from "@/components/DocForm";
|
import DocForm from "@/components/DocForm";
|
||||||
import TOC from "@/components/TOC";
|
import TOC from "@/components/TOC";
|
||||||
|
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
||||||
|
|
||||||
// Render at request time (content in Postgres, not available at build time).
|
// Render at request time (content in Postgres, not available at build time).
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -78,7 +79,7 @@ function CustomFieldBadges({
|
|||||||
displayValue = value.join(", ");
|
displayValue = value.join(", ");
|
||||||
} else if (typeof value === "object" && value !== null) {
|
} else if (typeof value === "object" && value !== null) {
|
||||||
// Objects → neutral badge, truncated JSON
|
// Objects → neutral badge, truncated JSON
|
||||||
displayValue = JSON.stringify(value).slice(0, 40) +
|
displayValue = JSON.stringify(value).slice(0, 40) +
|
||||||
(JSON.stringify(value).length > 40 ? "…" : "");
|
(JSON.stringify(value).length > 40 ? "…" : "");
|
||||||
} else {
|
} else {
|
||||||
// String values — auto-detect content type for styling
|
// 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) {
|
export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||||
const { section, slug } = await params;
|
const { section, slug } = await params;
|
||||||
const { edit } = await searchParams;
|
const { edit } = await searchParams;
|
||||||
const fullSlug = `${section}/${slug.join("/")}`;
|
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);
|
const doc = await getDocument(fullSlug);
|
||||||
if (!doc) notFound();
|
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.
|
// Any other key in the document metadata becomes a dynamic badge.
|
||||||
const STANDARD_KEYS = new Set([
|
const STANDARD_KEYS = new Set([
|
||||||
"id", "slug", "title", "type", "section", "status",
|
"id", "slug", "title", "type", "section", "status",
|
||||||
"author", "tags", "path", "createdAt", "updatedAt",
|
"author", "tags", "path", "createdAt", "updatedAt", "extraFields",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Edit mode: inline form
|
// Edit mode: inline form
|
||||||
if (edit === "1" && canEdit) {
|
if (edit === "1" && canEdit) {
|
||||||
|
// Load existing folders for the folder picker in edit mode
|
||||||
|
const existingFolders = extractFoldersForSection(docPaths, doc.section);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
@@ -174,6 +302,9 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
mode="edit"
|
mode="edit"
|
||||||
slug={fullSlug}
|
slug={fullSlug}
|
||||||
secret={WEBHOOK_SECRET}
|
secret={WEBHOOK_SECRET}
|
||||||
|
existingFolders={{
|
||||||
|
[doc.section]: existingFolders,
|
||||||
|
}}
|
||||||
initial={{
|
initial={{
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
body: doc.body,
|
body: doc.body,
|
||||||
@@ -182,6 +313,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
status: doc.status,
|
status: doc.status,
|
||||||
tags: doc.tags,
|
tags: doc.tags,
|
||||||
author: doc.author,
|
author: doc.author,
|
||||||
|
extraFields: doc.extraFields,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +347,9 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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">
|
<span className="text-xs font-medium text-[#7170ff] uppercase">
|
||||||
{SECTION_LABEL[doc.section] || doc.section}
|
{SECTION_LABEL[doc.section] || doc.section}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ interface DocFormProps {
|
|||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
slug?: string;
|
slug?: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
|
existingFolders?: Record<string, string[]>;
|
||||||
initial?: {
|
initial?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
@@ -15,18 +16,26 @@ interface DocFormProps {
|
|||||||
status?: "published" | "draft";
|
status?: "published" | "draft";
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
author?: string;
|
author?: string;
|
||||||
|
extraFields?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
|
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
|
||||||
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] 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 router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [title, setTitle] = useState(initial?.title ?? "");
|
const [title, setTitle] = useState(initial?.title ?? "");
|
||||||
|
const [slugInput, setSlugInput] = useState("");
|
||||||
const [body, setBody] = useState(initial?.body ?? "");
|
const [body, setBody] = useState(initial?.body ?? "");
|
||||||
const [section, setSection] = useState(initial?.section ?? "docs");
|
const [section, setSection] = useState(initial?.section ?? "docs");
|
||||||
const [type, setType] = useState(initial?.type ?? "documentation");
|
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 ?? "");
|
const [author, setAuthor] = useState(initial?.author ?? "");
|
||||||
|
|
||||||
// Dynamic custom fields — content creators can add any metadata they want
|
// 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<
|
const [customFields, setCustomFields] = useState<
|
||||||
Array<{ key: string; value: string }>
|
Array<{ key: string; value: string }>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
|
// Parent folder selection (hierarchical structure like GitHub folders)
|
||||||
|
const [parentFolder, setParentFolder] = useState("");
|
||||||
|
|
||||||
const tagList = tags
|
const tagList = tags
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.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 =
|
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]";
|
"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);
|
setLoading(true);
|
||||||
setError(null);
|
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
|
// Build payload with custom fields flattened at top level
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
|
slug: effectiveSlug,
|
||||||
section,
|
section,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
@@ -88,19 +114,13 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
|||||||
try {
|
try {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
const slugVal =
|
|
||||||
slug ??
|
|
||||||
title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "");
|
|
||||||
res = await fetch("/api/docs", {
|
res = await fetch("/api/docs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-webhook-secret": secret,
|
"x-webhook-secret": secret,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ slug: slugVal, ...payload }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const editSlug = slug ?? "";
|
const editSlug = slug ?? "";
|
||||||
@@ -149,22 +169,6 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
<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}
|
value={section}
|
||||||
onChange={(e) => setSection(e.target.value as typeof section)}
|
onChange={(e) => setSection(e.target.value as typeof section)}
|
||||||
className={baseInputCls}
|
className={baseInputCls}
|
||||||
|
disabled={isEdit}
|
||||||
>
|
>
|
||||||
{SECTION_OPTIONS.map((s) => (
|
{SECTION_OPTIONS.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
@@ -213,6 +218,56 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
Tags
|
Tags
|
||||||
@@ -246,8 +301,8 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
|||||||
<p className="text-xs text-[#62666d] mb-3">
|
<p className="text-xs text-[#62666d] mb-3">
|
||||||
Add extra frontmatter fields. Content creators name and define their own
|
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
|
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
|
page based on its value.
|
||||||
difficulty badge, etc.).{" "}</p>
|
</p>
|
||||||
{customFields.length > 0 && (
|
{customFields.length > 0 && (
|
||||||
<div className="space-y-3 mb-3">
|
<div className="space-y-3 mb-3">
|
||||||
{customFields.map((field, i) => (
|
{customFields.map((field, i) => (
|
||||||
@@ -263,9 +318,7 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateCustomField(i, "value", e.target.value)}
|
||||||
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]"
|
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
|
<button
|
||||||
@@ -295,7 +348,7 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
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 Content here..."
|
placeholder="# Heading Content here..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,113 @@ const SECTIONS = [
|
|||||||
{ id: "notes", label: "Notes", icon: "📌" },
|
{ id: "notes", label: "Notes", icon: "📌" },
|
||||||
] as const;
|
] 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-side sidebar (fetches doc list via /api/docs at runtime).
|
||||||
* Client component so `next build` SSG doesn't hit Postgres (CI has no DB). */
|
* Client component so `next build` SSG doesn't hit Postgres (CI has no DB). */
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@@ -34,66 +141,64 @@ export default function Sidebar() {
|
|||||||
.catch(() => setDocs([]));
|
.catch(() => setDocs([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const bySection: Record<string, Doc[]> = {};
|
const tree = buildFolderTree(docs);
|
||||||
for (const doc of docs) {
|
|
||||||
if (!bySection[doc.section]) bySection[doc.section] = [];
|
// Flatten all docs count for display
|
||||||
bySection[doc.section].push(doc);
|
const totalDocs = docs.length;
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="h-full overflow-y-auto py-6">
|
<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 }) => {
|
{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;
|
if (sectionDocs.length === 0) return null;
|
||||||
|
|
||||||
// Check if any doc in this section is the active page
|
const isActive = pathname.startsWith(`/${id}`);
|
||||||
const isActive = sectionDocs.some(
|
|
||||||
(doc) => pathname === `/${doc.slug}` || pathname === `/${doc.section}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={id}>
|
<li key={id}>
|
||||||
<div
|
<div
|
||||||
className={`text-xs font-medium mb-1 mt-4 first:mt-0 flex items-center gap-1.5 ${
|
className={`text-xs font-medium mb-1 flex items-center gap-1.5 ${
|
||||||
isActive
|
isActive ? "text-[#7170ff]" : "text-[#62666d]"
|
||||||
? "text-[#7170ff]"
|
|
||||||
: "text-[#62666d]"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{icon}</span>
|
<span>{icon}</span>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{sectionDocs.map((doc) => {
|
{treeRoot && treeRoot.children.length > 0
|
||||||
const parts = doc.slug.split("/").slice(1);
|
? treeRoot.children.map((node) =>
|
||||||
const label = parts[parts.length - 1];
|
renderTreeNode(node, 0, pathname),
|
||||||
const formatted = label
|
)
|
||||||
.split(/[-_]/)
|
: sectionDocs.map((doc) => {
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
const parts = doc.slug.split("/").slice(1);
|
||||||
.join(" ");
|
const label = parts[parts.length - 1]
|
||||||
const depth = doc.slug.split("/").length;
|
.split(/[-_]/)
|
||||||
const indent = depth - 1;
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
const isDocActive = pathname === `/${doc.slug}`;
|
.join(" ");
|
||||||
return (
|
const isDocActive = pathname === `/${doc.slug}`;
|
||||||
<li
|
return (
|
||||||
key={doc.slug}
|
<li key={doc.slug}>
|
||||||
style={{ marginLeft: `${indent * 12}px` }}
|
<Link
|
||||||
>
|
href={`/${doc.slug}`}
|
||||||
<Link
|
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
|
||||||
href={`/${doc.slug}`}
|
isDocActive
|
||||||
className={`block text-xs py-0.5 rounded transition-all ${
|
? "text-[#7170ff] bg-[#191a1b]"
|
||||||
isDocActive
|
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
|
||||||
? "text-[#7170ff] bg-[#191a1b]"
|
}`}
|
||||||
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
|
title={doc.title}
|
||||||
}`}
|
>
|
||||||
title={doc.title}
|
<span>📄</span>
|
||||||
>
|
{label}
|
||||||
{formatted || doc.slug}
|
</Link>
|
||||||
</Link>
|
</li>
|
||||||
</li>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,44 +1,34 @@
|
|||||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
import { listDocuments, extractFoldersForSection } from "@mcpedia/core";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import DocForm from "@/components/DocForm";
|
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() {
|
export default async function CreatePage() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
|
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
|
||||||
if (!canEdit) {
|
if (!canEdit) redirect("/login");
|
||||||
return (
|
|
||||||
<div className="text-center py-16">
|
const allDocs = await listDocuments();
|
||||||
<h1 className="text-2xl font-light text-[#f7f8f8] mb-4">
|
|
||||||
Authentication required
|
// Build folder suggestions per section using the shared helper from core
|
||||||
</h1>
|
const sections = ["docs", "writeups", "research", "notes"];
|
||||||
<p className="text-[#8a8f98] mb-6">
|
const docPaths = allDocs.map((d) => d.path);
|
||||||
You must be logged in to create documents.
|
const foldersBySection: Record<string, string[]> = {};
|
||||||
</p>
|
for (const sec of sections) {
|
||||||
<Link
|
foldersBySection[sec] = extractFoldersForSection(docPaths, sec);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<h1 className="text-3xl font-medium text-[#f7f8f8] mb-6">Create Document</h1>
|
||||||
href="/"
|
<DocForm
|
||||||
className="text-xs text-[#8a8f98] hover:text-[#d0d6e0] mb-4 inline-block"
|
mode="create"
|
||||||
>
|
secret={WEBHOOK_SECRET}
|
||||||
← Back
|
existingFolders={foldersBySection}
|
||||||
</Link>
|
/>
|
||||||
<h1 className="text-2xl font-light text-[#f7f8f8] mb-6">
|
|
||||||
Create Document
|
|
||||||
</h1>
|
|
||||||
<DocForm mode="create" secret={WEBHOOK_SECRET} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
id: ctf
|
||||||
|
title: "CTF Writeups"
|
||||||
|
type: writeup
|
||||||
|
tags:
|
||||||
|
- ctf
|
||||||
|
- index
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
event: "CTF Archive"
|
||||||
|
category: index
|
||||||
|
difficulty: easy
|
||||||
|
points: 0
|
||||||
|
created_at: 2026-08-20
|
||||||
|
updated_at: 2026-08-20
|
||||||
|
---
|
||||||
|
|
||||||
|
# CTF Writeups
|
||||||
|
|
||||||
|
A collection of CTF challenge writeups organized by event. Each event folder
|
||||||
|
contains challenge writeups grouped by category (pwn, crypto, web, forensics, etc.).
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- [DEF CON CTF Quals 2024](defcon-quals-2024) — 1 challenge solved (pwn)
|
||||||
|
- [Template](template) — Use as a starting point for new writeups
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
id: defcon-quals-2024
|
||||||
|
title: "DEF CON CTF Quals 2024 — Challenge Writeups"
|
||||||
|
type: writeup
|
||||||
|
tags:
|
||||||
|
- ctf
|
||||||
|
- defcon
|
||||||
|
- writeup-index
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
event: "DEF CON CTF Quals 2024"
|
||||||
|
category: writeup-index
|
||||||
|
difficulty: hard
|
||||||
|
points: 0
|
||||||
|
created_at: 2026-08-20
|
||||||
|
updated_at: 2026-08-20
|
||||||
|
---
|
||||||
|
|
||||||
|
# DEF CON CTF Quals 2024 — Challenge Writeups
|
||||||
|
|
||||||
|
This folder contains writeups for challenges solved during **DEF CON CTF Quals 2024**.
|
||||||
|
|
||||||
|
## Writeups
|
||||||
|
|
||||||
|
- [pwn-100: ret2win Stack Alignment Fix](pwn/pwn-100-ret2win-alignment) — A buffer overflow ret2win exploit that required inserting a `ret` gadget for stack alignment on glibc 2.34+.
|
||||||
|
|
||||||
|
## Challenge List
|
||||||
|
|
||||||
|
| Challenge | Category | Difficulty | Points |
|
||||||
|
|-----------|----------|------------|--------|
|
||||||
|
| pwn-100 | pwn | easy | 100 |
|
||||||
@@ -304,3 +304,64 @@ export async function deleteDocument(slug: string): Promise<{ deleted: boolean }
|
|||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 14: Hierarchical folder structure helpers.
|
||||||
|
// These enable GitHub-style nested folder browsing — the content creator
|
||||||
|
// decides folder structure via where they place files; no config needed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the folder structure for a given section from a list of document paths.
|
||||||
|
* Returns all distinct folder prefixes (relative to the section), sorted.
|
||||||
|
*
|
||||||
|
* Example: for section "docs" with paths ["docs/a/b/c.md", "docs/a/b/d.md"],
|
||||||
|
* returns ["a", "a/b"].
|
||||||
|
*/
|
||||||
|
export function extractFoldersForSection(
|
||||||
|
docPaths: string[],
|
||||||
|
section: string,
|
||||||
|
): string[] {
|
||||||
|
const folders = new Set<string>();
|
||||||
|
const base = `${section}/`;
|
||||||
|
|
||||||
|
for (const path of docPaths) {
|
||||||
|
const rel = path.startsWith(base) ? path.slice(base.length) : path;
|
||||||
|
const cleanPath = rel.replace(/\.md$/, "");
|
||||||
|
const parts = cleanPath.split("/");
|
||||||
|
|
||||||
|
let acc = "";
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
acc = acc ? `${acc}/${parts[i]}` : parts[i];
|
||||||
|
folders.add(acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...folders].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a full URL slug path (e.g. "writeups/ctf/defcon-quals-2024"), determine
|
||||||
|
* if it represents a folder (i.e., there are other docs whose paths start with
|
||||||
|
* this prefix) or a leaf document.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - "doc" if the path is a leaf document (path + ".md" matches a doc)
|
||||||
|
* - "folder" if the path is a parent of other doc paths
|
||||||
|
* - "none" if neither
|
||||||
|
*/
|
||||||
|
export function classifyPath(
|
||||||
|
docPaths: string[],
|
||||||
|
path: string,
|
||||||
|
): "doc" | "folder" | "none" {
|
||||||
|
const dotMd = `${path}.md`;
|
||||||
|
|
||||||
|
// Check if it's a leaf document
|
||||||
|
if (docPaths.includes(dotMd)) return "doc";
|
||||||
|
|
||||||
|
// Check if it's a folder (parent of other paths)
|
||||||
|
const prefix = `${path}/`;
|
||||||
|
const hasChildren = docPaths.some((p) => p.startsWith(prefix));
|
||||||
|
if (hasChildren) return "folder";
|
||||||
|
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user