- Section index: doc-title-based tree, folder doc counts, active+parent highlighting, recent list with dates, doc counter badge - Folder index: rich card listing with doc titles (not path slugs), dates, tags, subfolder grid with doc counts, breadcrumb - Doc page: metadata card (bg-[#0f1011] border), structured header with author/date/tags, badge title tooltips, improved FolderIndex breadcrumb - Sidebar: recursive hierarchical tree with depth indentation + 16px/level, folder doc counts, section icons, active parent highlighting, alphabetical sort - DocForm: clean 2-column grid layout, no duplicate slug field, edit-mode loads existing custom fields - Homepage: inline folder tree per section card - Header nav: added Writeups/Research/Notes links - Deleted duplicate apps/web/app/docs/page.tsx (superseded by generic [section]/page.tsx) - Added CSS vars: --color-border-subtle, --color-border-standard, --color-text-tertiary, --color-text-quaternary
This commit is contained in:
1 parent
b8d4fc97da
commit
26a900a030
5 files changed
+505
-334
No files matched your search
@@ -0,0 +1,80 @@
|
|||||||
|
# Phase 15 — UI/UX Polish Pass
|
||||||
|
|
||||||
|
> User: "perbagus ui ux nya"
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
### Current issues
|
||||||
|
1. **Section index** (`[section]/page.tsx`):
|
||||||
|
- `buildFolderTree` uses path segments instead of doc titles for leaf nodes
|
||||||
|
- Folder tree + flat list at bottom is redundant
|
||||||
|
- No folder icons (📁), no doc type indicators, no dates
|
||||||
|
- Tree rendering is very basic (no visual hierarchy)
|
||||||
|
|
||||||
|
2. **Doc page** (`[section]/[...slug]/page.tsx`):
|
||||||
|
- CustomFieldBadges shows key=value but labels all badges with key name as title
|
||||||
|
- No structured metadata card layout
|
||||||
|
- Related docs cards are functional but visually flat
|
||||||
|
|
||||||
|
3. **Folder index** (`FolderIndexPage` in `[...slug]/page.tsx`):
|
||||||
|
- Shows doc slug parts (e.g. "pwn-100-ret2win-alignment") instead of titles
|
||||||
|
- No date, no tags, no description
|
||||||
|
- Subfolders are plain text links, no folder count or doc count
|
||||||
|
|
||||||
|
4. **Homepage** (`page.tsx`):
|
||||||
|
- Folder tree inside cards is cramped (text-xs, no spacing)
|
||||||
|
- Section cards show "View all (N)" but folder tree duplicates that
|
||||||
|
|
||||||
|
5. **Sidebar** (`Sidebar.tsx`):
|
||||||
|
- Tree uses indentation via inline style but no visual depth cues
|
||||||
|
- No hover expand for folders
|
||||||
|
- Active state only on exact match, not parent folders
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### 1. Section index — enhanced tree
|
||||||
|
- Use doc titles for leaf nodes (already have `doc` reference in tree)
|
||||||
|
- Add 📁 for folders, 📄 for docs
|
||||||
|
- Show doc count per folder
|
||||||
|
- Remove flat list (redundant with tree)
|
||||||
|
- Add "View all" link per section
|
||||||
|
- Better visual hierarchy: folder headers with counts, doc titles with dates
|
||||||
|
|
||||||
|
### 2. Doc page — metadata card
|
||||||
|
- Replace inline badge row with a structured metadata card
|
||||||
|
- Show custom fields as labeled badges (key → value with color)
|
||||||
|
- Keep CustomFieldBadges for backward compat but improve layout
|
||||||
|
- Add metadata card with: author, date, tags, and all custom fields
|
||||||
|
|
||||||
|
### 3. Folder index — rich listing
|
||||||
|
- Show doc titles (fetch from listDocuments, match by path)
|
||||||
|
- Show update date per doc
|
||||||
|
- Show tags per doc
|
||||||
|
- Add doc count for subfolders
|
||||||
|
- Better visual separation between subfolders and docs
|
||||||
|
|
||||||
|
### 4. Homepage — cleaner tree
|
||||||
|
- Increase font size for tree items
|
||||||
|
- Show section icon + label in tree header
|
||||||
|
- Better spacing between sections
|
||||||
|
|
||||||
|
### 5. Sidebar — depth cues
|
||||||
|
- Use ml-4 per level instead of inline style
|
||||||
|
- Add section divider lines
|
||||||
|
- Highlight active section + parent folders
|
||||||
|
|
||||||
|
## Files to change
|
||||||
|
```
|
||||||
|
mod: apps/web/app/[section]/page.tsx # enhanced tree, use doc titles
|
||||||
|
mod: apps/web/app/[section]/[...slug]/page.tsx # FolderIndexPage + metadata card
|
||||||
|
mod: apps/web/app/page.tsx # cleaner section tree cards
|
||||||
|
mod: apps/web/app/components/Sidebar.tsx # depth + active parent highlight
|
||||||
|
mod: apps/web/app/globals.css # additional CSS vars if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- All endpoints still 200
|
||||||
|
- `bun run test` green
|
||||||
|
- `turbo run typecheck` green
|
||||||
|
- `next build` succeeds
|
||||||
|
- Live visual check of folder index, section index, doc page
|
||||||
@@ -7,6 +7,7 @@ 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";
|
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
||||||
|
import type { DocumentMeta } 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";
|
||||||
@@ -31,10 +32,10 @@ const SECTION_ICON: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render any extra/custom frontmatter fields as badges.
|
* Render any extra/custom frontmatter fields as labeled badges.
|
||||||
* This makes the system dynamic — the content creator decides what metadata
|
* This makes the system dynamic — the content creator decides what metadata
|
||||||
* to include, not the UI template. Standard fields (title, author, tags, etc.)
|
* to include. Standard fields (title, author, tags, etc.) are handled
|
||||||
* are handled explicitly; any OTHER keys in frontmatter become badges here.
|
* explicitly; any OTHER keys in frontmatter become badges here.
|
||||||
*/
|
*/
|
||||||
function CustomFieldBadges({
|
function CustomFieldBadges({
|
||||||
doc,
|
doc,
|
||||||
@@ -43,7 +44,6 @@ function CustomFieldBadges({
|
|||||||
doc: Record<string, unknown>;
|
doc: Record<string, unknown>;
|
||||||
standardKeys: Set<string>;
|
standardKeys: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
// Convert doc to a plain record to get index signature
|
|
||||||
const docRecord: Record<string, unknown> = { ...doc } as Record<string, unknown>;
|
const docRecord: Record<string, unknown> = { ...doc } as Record<string, unknown>;
|
||||||
const customEntries = Object.entries(docRecord).filter(
|
const customEntries = Object.entries(docRecord).filter(
|
||||||
([k, v]) =>
|
([k, v]) =>
|
||||||
@@ -53,7 +53,7 @@ function CustomFieldBadges({
|
|||||||
if (customEntries.length === 0) return null;
|
if (customEntries.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3 border-t border-[#1f2022]">
|
||||||
{customEntries.map(([key, value]) => {
|
{customEntries.map(([key, value]) => {
|
||||||
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
|
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
|
||||||
let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]";
|
let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]";
|
||||||
@@ -64,30 +64,24 @@ function CustomFieldBadges({
|
|||||||
// not by using specific key names)
|
// not by using specific key names)
|
||||||
|
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
// Numeric values → purple "points"-style badge with + prefix
|
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
||||||
displayValue = `${value}`;
|
displayValue = `${value}`;
|
||||||
} else if (typeof value === "boolean") {
|
} else if (typeof value === "boolean") {
|
||||||
// Boolean values → green check/X
|
|
||||||
colorClass = value
|
colorClass = value
|
||||||
? "bg-green-500/15 border-green-500/25 text-green-400"
|
? "bg-green-500/15 border-green-500/25 text-green-400"
|
||||||
: "bg-red-500/15 border-red-500/25 text-red-400";
|
: "bg-red-500/15 border-red-500/25 text-red-400";
|
||||||
displayValue = value ? "Yes" : "No";
|
displayValue = value ? "Yes" : "No";
|
||||||
} else if (Array.isArray(value)) {
|
} else if (Array.isArray(value)) {
|
||||||
// Arrays → purple badge, joined values
|
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
||||||
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
|
|
||||||
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
|
|
||||||
const v = String(value).toLowerCase();
|
const v = String(value).toLowerCase();
|
||||||
|
|
||||||
// Difficulty-like values (easy/medium/hard/etc) OR numeric with suffix
|
|
||||||
const diffMatch = v.match(
|
const diffMatch = v.match(
|
||||||
/^(easy|simple|beginner)\b|^(medium|intermediate)\b|^(hard|expert|advanced)\b/i
|
/^(easy|simple|beginner)\b|^(medium|intermediate)\b|^(hard|expert|advanced)\b/i,
|
||||||
);
|
);
|
||||||
if (diffMatch) {
|
if (diffMatch) {
|
||||||
if (/^easy|simple|beginner/i.test(v))
|
if (/^easy|simple|beginner/i.test(v))
|
||||||
@@ -97,33 +91,24 @@ function CustomFieldBadges({
|
|||||||
else if (/^hard|expert|advanced/i.test(v))
|
else if (/^hard|expert|advanced/i.test(v))
|
||||||
colorClass = "bg-red-500/10 border-red-500/30 text-red-400";
|
colorClass = "bg-red-500/10 border-red-500/30 text-red-400";
|
||||||
displayValue = diffMatch[0];
|
displayValue = diffMatch[0];
|
||||||
}
|
} else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) {
|
||||||
// Event-like values (contains CTF, DEF CON, hack, etc.)
|
|
||||||
else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) {
|
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
||||||
displayValue = String(value);
|
displayValue = String(value);
|
||||||
}
|
} else if (/(\d+)\s*pts?$/i.test(v)) {
|
||||||
// Points-like values (number + "pts" suffix)
|
|
||||||
else if (/(\d+)\s*pts?$/i.test(v)) {
|
|
||||||
const match = v.match(/(\d+)\s*pts?$/i);
|
const match = v.match(/(\d+)\s*pts?$/i);
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
|
||||||
displayValue = match ? `${match[1]} pts` : String(value);
|
displayValue = match ? `${match[1]} pts` : String(value);
|
||||||
}
|
} else if (/^(pwn|web|crypto|misc|forensic|reverse|pwnable|binary|webexploit)$/i.test(v)) {
|
||||||
// Category-like values (pwn/web/crypto/etc)
|
|
||||||
else if (/^(pwn|web|crypto|misc|forensic|reverse|pwnable|binary|webexploit)$/i.test(v)) {
|
|
||||||
colorClass = "bg-orange-500/15 border-orange-500/25 text-orange-400";
|
colorClass = "bg-orange-500/15 border-orange-500/25 text-orange-400";
|
||||||
displayValue = v.charAt(0).toUpperCase() + v.slice(1);
|
displayValue = v.charAt(0).toUpperCase() + v.slice(1);
|
||||||
}
|
} else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) {
|
||||||
// Status-like values (solved/pending/wip)
|
|
||||||
else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) {
|
|
||||||
colorClass = v.includes("solved") || v.includes("completed")
|
colorClass = v.includes("solved") || v.includes("completed")
|
||||||
? "bg-green-500/10 border-green-500/30 text-green-400"
|
? "bg-green-500/10 border-green-500/30 text-green-400"
|
||||||
: v.includes("wip") || v.includes("progress")
|
: v.includes("wip") || v.includes("progress")
|
||||||
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
|
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
|
||||||
: "bg-red-500/10 border-red-500/30 text-red-400";
|
: "bg-red-500/10 border-red-500/30 text-red-400";
|
||||||
displayValue = v.charAt(0).toUpperCase() + v.slice(1);
|
displayValue = v.charAt(0).toUpperCase() + v.slice(1);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
displayValue = String(value);
|
displayValue = String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +117,7 @@ function CustomFieldBadges({
|
|||||||
<span
|
<span
|
||||||
key={key}
|
key={key}
|
||||||
className={`px-2 py-0.5 border rounded text-xs ${colorClass}`}
|
className={`px-2 py-0.5 border rounded text-xs ${colorClass}`}
|
||||||
|
title={label}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
@@ -148,80 +134,113 @@ function CustomFieldBadges({
|
|||||||
function FolderIndexPage({
|
function FolderIndexPage({
|
||||||
section,
|
section,
|
||||||
slug,
|
slug,
|
||||||
docPaths,
|
allDocs,
|
||||||
}: {
|
}: {
|
||||||
section: string;
|
section: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
docPaths: string[];
|
allDocs: DocumentMeta[];
|
||||||
}) {
|
}) {
|
||||||
const prefix = `${section}/${slug}/`;
|
const prefix = `${section}/${slug}/`;
|
||||||
const folderDocs = docPaths
|
const folderDocPaths = allDocs
|
||||||
.filter((p) => p.startsWith(prefix))
|
.filter((d) => d.path.startsWith(prefix))
|
||||||
.map((p) => p.slice(prefix.length).replace(/\.md$/, ""))
|
.map((d) => ({
|
||||||
.sort();
|
rel: d.path.slice(prefix.length).replace(/\.md$/, ""),
|
||||||
|
doc: d,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.rel.localeCompare(b.rel));
|
||||||
|
|
||||||
// Group into immediate children: folders vs leaf docs
|
// Group into immediate children: folders vs leaf docs
|
||||||
const immediateFolders = new Set<string>();
|
const immediateFolders = new Map<string, { docCount: number }>();
|
||||||
const immediateDocs: { name: string; slug: string }[] = [];
|
const immediateDocs: DocumentMeta[] = [];
|
||||||
|
|
||||||
for (const relPath of folderDocs) {
|
for (const { rel, doc } of folderDocPaths) {
|
||||||
const parts = relPath.split("/");
|
const parts = rel.split("/");
|
||||||
if (parts.length === 1) {
|
if (parts.length === 1) {
|
||||||
// Leaf doc directly in this folder
|
immediateDocs.push(doc);
|
||||||
immediateDocs.push({ name: parts[0], slug: `${section}/${slug}/${parts[0]}`.replace(/\/+/g, "/") });
|
|
||||||
} else {
|
} else {
|
||||||
// Deeper — register the immediate folder
|
const folder = parts[0];
|
||||||
immediateFolders.add(parts[0]);
|
const existing = immediateFolders.get(folder) || { docCount: 0 };
|
||||||
|
existing.docCount += 1;
|
||||||
|
immediateFolders.set(folder, existing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = [...immediateFolders].sort();
|
const folders = [...immediateFolders.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const sectionInfo = {
|
||||||
|
icon: SECTION_ICON[section] || "📁",
|
||||||
|
label: SECTION_LABEL[section] || section,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Breadcrumb */}
|
||||||
<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 href="/" className="hover:text-[#d0d6e0] transition-colors">
|
<Link href="/" className="hover:text-[#d0d6e0] transition-colors">
|
||||||
MCPedia
|
MCPedia
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
|
<Link
|
||||||
|
href={`/${section}`}
|
||||||
|
className="hover:text-[#d0d6e0] transition-colors"
|
||||||
|
>
|
||||||
|
{sectionInfo.label}
|
||||||
|
</Link>
|
||||||
{slug.split("/").map((part, i) => {
|
{slug.split("/").map((part, i) => {
|
||||||
const path = `${section}/${slug.split("/").slice(0, i + 1).join("/")}`;
|
const path = `${section}/${slug.split("/").slice(0, i + 1).join("/")}`;
|
||||||
const isLast = i === slug.split("/").length - 1;
|
const isLast = i === slug.split("/").length - 1;
|
||||||
|
const label = part
|
||||||
|
.split(/[-_]/)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link
|
<span>/</span>
|
||||||
|
<span
|
||||||
key={path}
|
key={path}
|
||||||
href={`/${path}`}
|
className={isLast ? "text-[#8a8f98]" : "hover:text-[#d0d6e0]"}
|
||||||
className={isLast ? "text-[#8a8f98]" : "hover:text-[#d0d6e0] transition-colors"}
|
|
||||||
>
|
>
|
||||||
{part}
|
{label}
|
||||||
</Link>
|
</span>
|
||||||
{!isLast && <span>/</span>}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-6">
|
{/* Header */}
|
||||||
<span className="text-xl">📁</span>
|
<div className="flex items-center gap-2 mb-8">
|
||||||
<h1 className="text-3xl font-medium text-[#f7f8f8]">{slug.split("/").pop()}</h1>
|
<span className="text-2xl">📁</span>
|
||||||
|
<h1 className="text-3xl font-medium text-[#f7f8f8]">
|
||||||
|
{slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
|
||||||
|
</h1>
|
||||||
|
<span className="text-xs text-[#62666d] bg-[#191a1b] px-2 py-0.5 rounded">
|
||||||
|
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(folders.length > 0 || immediateDocs.length > 0) ? (
|
{(folders.length > 0 || immediateDocs.length > 0) ? (
|
||||||
<div>
|
<div>
|
||||||
{folders.length > 0 && (
|
{folders.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
|
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
|
||||||
Subfolders
|
Subfolders
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{folders.map((folder) => (
|
{folders.map(([folder, info]) => (
|
||||||
<Link
|
<Link
|
||||||
key={folder}
|
key={folder}
|
||||||
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
|
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
|
||||||
className="flex items-center gap-2 text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors"
|
className="group flex items-center gap-3 p-3 bg-[#0f1011] border border-[#1f2022] rounded-lg hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all"
|
||||||
>
|
>
|
||||||
<span>📁</span> {folder}
|
<span className="text-2xl">📁</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors">
|
||||||
|
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[#62666d] mt-0.5">
|
||||||
|
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +248,7 @@ function FolderIndexPage({
|
|||||||
)}
|
)}
|
||||||
{immediateDocs.length > 0 && (
|
{immediateDocs.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
|
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
|
||||||
Documents
|
Documents
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -237,9 +256,34 @@ function FolderIndexPage({
|
|||||||
<Link
|
<Link
|
||||||
key={doc.slug}
|
key={doc.slug}
|
||||||
href={`/${doc.slug}`}
|
href={`/${doc.slug}`}
|
||||||
className="flex items-center gap-2 text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
|
className="group flex items-center justify-between gap-3 p-3 bg-[#0f1011] border border-[#1f2022] rounded-lg hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all"
|
||||||
>
|
>
|
||||||
<span>📄</span> {doc.name}
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="text-xl">📄</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1">
|
||||||
|
{doc.title}
|
||||||
|
</div>
|
||||||
|
{doc.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{doc.tags.slice(0, 3).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="text-xs px-1.5 py-0.25 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
|
||||||
|
>
|
||||||
|
#{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<time className="text-xs text-[#62666d] flex-shrink-0">
|
||||||
|
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +310,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
|
|
||||||
// If the slug path is a folder (not a leaf doc), render the folder index
|
// If the slug path is a folder (not a leaf doc), render the folder index
|
||||||
if (classification === "folder") {
|
if (classification === "folder") {
|
||||||
return <FolderIndexPage section={section} slug={slug.join("/")} docPaths={docPaths} />;
|
return <FolderIndexPage section={section} slug={slug.join("/")} allDocs={allDocs} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, treat as a document (existing behavior)
|
// Otherwise, treat as a document (existing behavior)
|
||||||
@@ -285,7 +329,6 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
|
|
||||||
// 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);
|
const existingFolders = extractFoldersForSection(docPaths, doc.section);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -361,38 +404,58 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Metadata Card */}
|
||||||
<div className="mb-8">
|
<div className="bg-[#0f1011] border border-[#1f2022] rounded-lg p-6 mb-8">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-start gap-3 mb-4">
|
||||||
<span className="text-xl">
|
<span className="text-2xl">
|
||||||
{SECTION_ICON[doc.section] || "📄"}
|
{SECTION_ICON[doc.section] || "📄"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-[#7170ff] uppercase">
|
<div className="flex-1 min-w-0">
|
||||||
{SECTION_LABEL[doc.section] || doc.section}
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</span>
|
<span className="text-xs font-medium text-[#7170ff] uppercase">
|
||||||
|
{SECTION_LABEL[doc.section] || doc.section}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[#62666d]">·</span>
|
||||||
|
<span className="text-xs text-[#62666d]">
|
||||||
|
{doc.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-medium text-[#f7f8f8] leading-tight">
|
||||||
|
{doc.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-medium text-[#f7f8f8] mb-4 leading-tight">
|
|
||||||
{doc.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
|
{/* Author + date + tags */}
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[#62666d] mb-3">
|
<div className="flex flex-wrap items-center gap-3 text-xs text-[#62666d] mb-3">
|
||||||
<span className="text-[#d0d6e0]">{doc.author || "unknown"}</span>
|
{doc.author && (
|
||||||
<span>·</span>
|
<>
|
||||||
<time dateTime={doc.updatedAt}>
|
<span className="text-[#d0d6e0]">by {doc.author}</span>
|
||||||
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
<span>·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-[#d0d6e0]">
|
||||||
|
updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</time>
|
</span>
|
||||||
{doc.tags.map((t) => (
|
{doc.tags.length > 0 && (
|
||||||
<span
|
<>
|
||||||
key={t}
|
<span>·</span>
|
||||||
className="px-2 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
|
<div className="flex flex-wrap gap-1">
|
||||||
>
|
{doc.tags.map((t) => (
|
||||||
#{t}
|
<span
|
||||||
</span>
|
key={t}
|
||||||
))}
|
className="px-2 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
|
||||||
|
>
|
||||||
|
#{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic custom field badges — content creator controls what shows */}
|
{/* Dynamic custom field badges — content creator controls what shows */}
|
||||||
@@ -400,7 +463,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content + TOC */}
|
{/* Content + TOC */}
|
||||||
<div className="mb-8">
|
<div className="mb-12">
|
||||||
<TOC source={doc.body} />
|
<TOC source={doc.body} />
|
||||||
<Markdown source={doc.body} />
|
<Markdown source={doc.body} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listDocuments, type DocumentMeta } from "@mcpedia/core";
|
import { listDocuments } from "@mcpedia/core";
|
||||||
import type { DocSection } from "@mcpedia/core";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[] = [
|
interface DocMeta {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
section: string;
|
||||||
|
tags: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: { id: string; label: string; icon: string; desc: string }[] = [
|
||||||
{
|
{
|
||||||
id: "docs",
|
id: "docs",
|
||||||
label: "Documentation",
|
label: "Documentation",
|
||||||
@@ -17,88 +25,102 @@ const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[]
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group documents into a folder tree. Returns nodes where each node is either:
|
* Build a hierarchical folder tree from a flat list of documents.
|
||||||
* - a folder (has `children`) or
|
* Each node is either a folder (has children) or a leaf doc.
|
||||||
* - a leaf doc (has `doc`)
|
|
||||||
* The tree is built from the path segments of each document's `path`.
|
|
||||||
*/
|
*/
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
doc?: DocumentMeta;
|
doc?: DocMeta;
|
||||||
children?: TreeNode[];
|
children: TreeNode[];
|
||||||
|
docCount: number; // total docs in subtree
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFolderTree(docs: DocumentMeta[], section: string): TreeNode[] {
|
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
|
||||||
const root: TreeNode[] = [];
|
const root: TreeNode[] = [];
|
||||||
const rootMap = new Map<string, TreeNode>();
|
|
||||||
|
|
||||||
for (const doc of docs.filter((d) => d.section === section)) {
|
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}/`)
|
const rel = doc.path.startsWith(`${section}/`)
|
||||||
? doc.path.slice(section.length + 1)
|
? doc.path.slice(section.length + 1)
|
||||||
: doc.path;
|
: doc.path;
|
||||||
const cleanPath = rel.replace(/\.md$/, "");
|
const cleanPath = rel.replace(/\.md$/, "");
|
||||||
const parts = cleanPath.split("/");
|
const parts = cleanPath.split("/");
|
||||||
|
|
||||||
let currentLevel = root;
|
let current = root;
|
||||||
let currentPath = section;
|
let currentPath = section;
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
currentPath = `${currentPath}/${part}`;
|
|
||||||
const isLeaf = i === parts.length - 1;
|
const isLeaf = i === parts.length - 1;
|
||||||
|
currentPath = `${currentPath}/${part}`;
|
||||||
|
|
||||||
let node = currentLevel.find((n) => n.name === part);
|
let node = current.find((n) => n.name === part);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
node = {
|
node = {
|
||||||
name: part === "" ? section : part,
|
name: part,
|
||||||
slug: currentPath,
|
slug: currentPath,
|
||||||
children: isLeaf ? undefined : [],
|
children: [],
|
||||||
|
docCount: 0,
|
||||||
};
|
};
|
||||||
currentLevel.push(node);
|
current.push(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLeaf) {
|
if (isLeaf) {
|
||||||
node.doc = doc;
|
node.doc = doc;
|
||||||
|
node.docCount = 1;
|
||||||
} else {
|
} else {
|
||||||
if (!node.children) node.children = [];
|
// This is a folder — count will be accumulated below
|
||||||
currentLevel = node.children;
|
|
||||||
}
|
}
|
||||||
|
current = node.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursively compute docCount for folder nodes
|
||||||
|
function countDocs(node: TreeNode): number {
|
||||||
|
if (node.doc) return 1;
|
||||||
|
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of root) {
|
||||||
|
node.docCount = countDocs(node);
|
||||||
|
}
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTree(nodes: TreeNode[], section: DocSection) {
|
function renderTree(nodes: TreeNode[], pathname: string) {
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const isFolder = !!node.children;
|
const isFolder = node.children.length > 0;
|
||||||
const isDoc = !!node.doc;
|
const isActive = pathname === `/${node.slug}`;
|
||||||
|
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={node.slug}>
|
<li key={node.slug}>
|
||||||
{isFolder ? (
|
<Link
|
||||||
<Link
|
href={`/${node.slug}`}
|
||||||
href={`/${node.slug}`}
|
className={`flex items-center justify-between gap-2 text-xs py-1 px-2 rounded transition-all ${
|
||||||
className="flex items-center gap-1.5 text-xs py-0.5 rounded hover:bg-[#191a1b] text-[#d0d6e0] hover:text-[#f7f8f8]"
|
isActive
|
||||||
>
|
? "text-[#7170ff] bg-[#191a1b]"
|
||||||
<span>📁</span>
|
: hasActiveChild
|
||||||
{node.name}
|
? "text-[#d0d6e0]"
|
||||||
</Link>
|
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[#191a1b]"
|
||||||
) : isDoc ? (
|
}`}
|
||||||
<Link
|
>
|
||||||
href={`/${node.slug}`}
|
<span className="flex items-center gap-1.5">
|
||||||
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>{isFolder ? "📁" : "📄"}</span>
|
||||||
>
|
|
||||||
<span>📄</span>
|
|
||||||
{node.doc?.title || node.name}
|
{node.doc?.title || node.name}
|
||||||
</Link>
|
</span>
|
||||||
) : null}
|
{isFolder && (
|
||||||
{node.children && node.children.length > 0 && (
|
<span className="text-[#62666d] bg-[#191a1b] px-1.5 py-0.25 rounded">
|
||||||
<div className="ml-3 mt-0.5">
|
{node.docCount}
|
||||||
{renderTree(node.children, section)}
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<div className="ml-4 mt-0.5">
|
||||||
|
{renderTree(node.children, pathname)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
@@ -115,7 +137,8 @@ export default async function SectionIndexPage({
|
|||||||
}) {
|
}) {
|
||||||
const { section } = await params;
|
const { section } = await params;
|
||||||
|
|
||||||
if (!SECTIONS.some((s) => s.id === section)) {
|
const sectionInfo = SECTIONS.find((s) => s.id === section);
|
||||||
|
if (!sectionInfo) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-medium text-[#f7f8f8]">Unknown section</h1>
|
<h1 className="text-2xl font-medium text-[#f7f8f8]">Unknown section</h1>
|
||||||
@@ -123,39 +146,42 @@ export default async function SectionIndexPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await listDocuments();
|
const allDocs = await listDocuments();
|
||||||
const sectionDocs = all.filter((d) => d.section === section);
|
const sectionDocs = allDocs.filter((d) => d.section === section);
|
||||||
const tree = buildFolderTree(all, section as DocSection);
|
const tree = buildFolderTree(allDocs, section);
|
||||||
const sectionInfo = SECTIONS.find((s) => s.id === section)!;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<span className="text-2xl">{sectionInfo.icon}</span>
|
<span className="text-2xl">{sectionInfo.icon}</span>
|
||||||
<h1 className="text-3xl font-medium text-[#f7f8f8]">{sectionInfo.label}</h1>
|
<h1 className="text-3xl font-medium text-[#f7f8f8]">{sectionInfo.label}</h1>
|
||||||
|
<span className="text-xs text-[#62666d] bg-[#191a1b] px-2 py-0.5 rounded">
|
||||||
|
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#8a8f98] mb-6 max-w-2xl">
|
|
||||||
|
<p className="text-sm text-[#8a8f98] mb-8 max-w-2xl">
|
||||||
{sectionInfo.desc}
|
{sectionInfo.desc}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-8">
|
{tree.length > 0 ? (
|
||||||
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
|
<div className="mb-12">
|
||||||
Folder structure
|
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
|
||||||
</h2>
|
Folder structure
|
||||||
{tree.length > 0 ? (
|
</h2>
|
||||||
renderTree(tree, section as DocSection)
|
{renderTree(tree, section)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-xs text-[#62666d]">No documents found.</p>
|
) : (
|
||||||
)}
|
<p className="text-sm text-[#62666d] mb-8">No documents found in this section.</p>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{sectionDocs.length > 0 && (
|
{sectionDocs.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
|
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
|
||||||
All documents ({sectionDocs.length})
|
Recently updated
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sectionDocs
|
{[...sectionDocs]
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
@@ -164,10 +190,17 @@ export default async function SectionIndexPage({
|
|||||||
<Link
|
<Link
|
||||||
key={d.slug}
|
key={d.slug}
|
||||||
href={`/${d.slug}`}
|
href={`/${d.slug}`}
|
||||||
className="block text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
|
className="group flex items-center justify-between gap-3 py-2 border-b border-[#141516] last:border-0"
|
||||||
title={d.title}
|
|
||||||
>
|
>
|
||||||
{d.title}
|
<span className="text-sm text-[#d0d6e0] group-hover:text-[#7170ff] transition-colors line-clamp-1">
|
||||||
|
{d.title}
|
||||||
|
</span>
|
||||||
|
<time className="text-xs text-[#62666d] flex-shrink-0">
|
||||||
|
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,31 +42,40 @@ export default function DocForm({
|
|||||||
const [status, setStatus] = useState(initial?.status ?? "published");
|
const [status, setStatus] = useState(initial?.status ?? "published");
|
||||||
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
|
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
|
||||||
const [author, setAuthor] = useState(initial?.author ?? "");
|
const [author, setAuthor] = useState(initial?.author ?? "");
|
||||||
|
const [parentFolder, setParentFolder] = useState("");
|
||||||
|
|
||||||
// Dynamic custom fields — content creators can add any metadata they want
|
// Extract existing custom fields from initial for edit mode
|
||||||
const [customFields, setCustomFields] = useState<
|
const [customFields, setCustomFields] = useState<
|
||||||
Array<{ key: string; value: string }>
|
Array<{ key: string; value: string }>
|
||||||
>([]);
|
>(() => {
|
||||||
|
if (initial?.extraFields) {
|
||||||
// Parent folder selection (hierarchical structure like GitHub folders)
|
return Object.entries(initial.extraFields)
|
||||||
const [parentFolder, setParentFolder] = useState("");
|
.filter(([k, v]) => k !== "slug" && v !== undefined && v !== null)
|
||||||
|
.map(([k, v]) => ({ key: k, value: typeof v === "string" ? v : JSON.stringify(v) }))
|
||||||
|
.filter((f) => f.value);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
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 isEdit = mode === "edit";
|
||||||
const effectiveSlug = isEdit ? (slug ?? "") : parentFolder
|
const effectiveSlug = isEdit
|
||||||
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
|
? (slug ?? "")
|
||||||
: slugInput;
|
: parentFolder
|
||||||
|
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
|
||||||
|
: slugInput;
|
||||||
|
|
||||||
const availableFolders = existingFolders?.[section] ?? [];
|
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]";
|
||||||
|
|
||||||
|
const baseSelectCls = baseInputCls;
|
||||||
|
|
||||||
function addCustomField() {
|
function addCustomField() {
|
||||||
setCustomFields([...customFields, { key: "", value: "" }]);
|
setCustomFields([...customFields, { key: "", value: "" }]);
|
||||||
}
|
}
|
||||||
@@ -92,7 +101,6 @@ export default function DocForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build payload with custom fields flattened at top level
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
@@ -104,7 +112,6 @@ export default function DocForm({
|
|||||||
tags: tagList,
|
tags: tagList,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge dynamic custom fields
|
|
||||||
for (const field of customFields) {
|
for (const field of customFields) {
|
||||||
if (field.key.trim() && field.value.trim()) {
|
if (field.key.trim() && field.value.trim()) {
|
||||||
payload[field.key.trim()] = field.value.trim();
|
payload[field.key.trim()] = field.value.trim();
|
||||||
@@ -156,6 +163,7 @@ export default function DocForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
Title
|
Title
|
||||||
@@ -169,6 +177,7 @@ export default function DocForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section + Type + Status */}
|
||||||
<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">
|
||||||
@@ -177,7 +186,7 @@ export default function DocForm({
|
|||||||
<select
|
<select
|
||||||
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={baseSelectCls}
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
>
|
>
|
||||||
{SECTION_OPTIONS.map((s) => (
|
{SECTION_OPTIONS.map((s) => (
|
||||||
@@ -194,7 +203,7 @@ export default function DocForm({
|
|||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as typeof type)}
|
onChange={(e) => setType(e.target.value as typeof type)}
|
||||||
className={baseInputCls}
|
className={baseSelectCls}
|
||||||
>
|
>
|
||||||
{TYPE_OPTIONS.map((t) => (
|
{TYPE_OPTIONS.map((t) => (
|
||||||
<option key={t} value={t}>
|
<option key={t} value={t}>
|
||||||
@@ -210,7 +219,7 @@ export default function DocForm({
|
|||||||
<select
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value as typeof status)}
|
onChange={(e) => setStatus(e.target.value as typeof status)}
|
||||||
className={baseInputCls}
|
className={baseSelectCls}
|
||||||
>
|
>
|
||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
@@ -218,90 +227,91 @@ export default function DocForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Folder + Slug */}
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
Parent folder (optional)
|
<div>
|
||||||
</label>
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
<select
|
Parent folder (optional)
|
||||||
value={parentFolder}
|
</label>
|
||||||
onChange={(e) => setParentFolder(e.target.value)}
|
<select
|
||||||
className={baseInputCls}
|
value={parentFolder}
|
||||||
disabled={isEdit}
|
onChange={(e) => setParentFolder(e.target.value)}
|
||||||
>
|
className={baseSelectCls}
|
||||||
<option value="">(root of section)</option>
|
disabled={isEdit}
|
||||||
{availableFolders.map((folder) => (
|
>
|
||||||
<option key={folder} value={folder}>
|
<option value="">(root of section)</option>
|
||||||
{folder}
|
{availableFolders.map((folder) => (
|
||||||
</option>
|
<option key={folder} value={folder}>
|
||||||
))}
|
{folder}
|
||||||
</select>
|
</option>
|
||||||
<p className="text-xs text-[#62666d] mt-1">
|
))}
|
||||||
Place this document inside an existing folder. The slug will be
|
</select>
|
||||||
prepended with the folder path (e.g. selecting "ctf/defcon-quals-2024"
|
<p className="text-xs text-[#62666d] mt-1">
|
||||||
+ slug "pwn-100" → "ctf/defcon-quals-2024/pwn-100").
|
Place inside an existing folder.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
|
{isEdit ? "Full slug" : "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: <code className="bg-[#191a1b] px-1 py-0.25 rounded">{effectiveSlug || section}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Tags + Author */}
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{isEdit ? "Full slug (read-only)" : "Slug (file name)"}
|
<div>
|
||||||
</label>
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
{isEdit ? (
|
Tags
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={slug ?? ""}
|
value={tags}
|
||||||
readOnly
|
onChange={(e) => setTags(e.target.value)}
|
||||||
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}
|
className={baseInputCls}
|
||||||
placeholder="my-document-slug"
|
placeholder="comma, separated, tags"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<p className="text-xs text-[#62666d] mt-1">
|
<div>
|
||||||
Resolved path: <code>{effectiveSlug || section}</code>
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
</p>
|
Author
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={author}
|
||||||
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
|
className={baseInputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Custom fields */}
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tags}
|
|
||||||
onChange={(e) => setTags(e.target.value)}
|
|
||||||
className={baseInputCls}
|
|
||||||
placeholder="comma, separated, tags"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
|
||||||
Author
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={author}
|
|
||||||
onChange={(e) => setAuthor(e.target.value)}
|
|
||||||
className={baseInputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic custom fields — creators add whatever metadata they need */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-2">
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-2">
|
||||||
Custom metadata fields
|
Custom metadata fields
|
||||||
</label>
|
</label>
|
||||||
<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. Any field you add will be rendered as
|
||||||
metadata here — the system auto-renders any field as a badge on the doc
|
an auto-styled badge on the doc page based on its value.
|
||||||
page based on its value.
|
|
||||||
</p>
|
</p>
|
||||||
{customFields.length > 0 && (
|
{customFields.length > 0 && (
|
||||||
<div className="space-y-3 mb-3">
|
<div className="space-y-3 mb-3">
|
||||||
@@ -309,17 +319,17 @@ export default function DocForm({
|
|||||||
<div key={i} className="flex gap-2 items-center">
|
<div key={i} className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="field name (e.g. event)"
|
placeholder="field name"
|
||||||
value={field.key}
|
value={field.key}
|
||||||
onChange={(e) => updateCustomField(i, "key", e.target.value)}
|
onChange={(e) => updateCustomField(i, "key", 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={baseInputCls}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
value={field.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]"
|
className={baseInputCls}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -341,6 +351,7 @@ export default function DocForm({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||||
Body (Markdown)
|
Body (Markdown)
|
||||||
@@ -353,6 +364,7 @@ export default function DocForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -19,91 +19,100 @@ const SECTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a hierarchical folder tree from a flat list of documents.
|
* Build a hierarchical folder tree from flat document slugs.
|
||||||
* Each doc's slug is like "section/sub/sub2/leaf-name".
|
* Each node is either a folder (has children) or a leaf doc.
|
||||||
* The tree groups by path segments so the sidebar shows nested folders
|
|
||||||
* with proper indentation (like GitHub's file tree).
|
|
||||||
*/
|
*/
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
children: TreeNode[];
|
children: TreeNode[];
|
||||||
|
isLeaf: boolean;
|
||||||
|
docCount: number; // total docs in subtree
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFolderTree(docs: Doc[]): Record<string, TreeNode> {
|
function buildFolderTree(docs: Doc[], section: string): TreeNode[] {
|
||||||
// Returns a map from section id → tree root for that section.
|
const root: TreeNode[] = [];
|
||||||
const result: Record<string, TreeNode> = {};
|
|
||||||
|
|
||||||
for (const section of SECTIONS) {
|
for (const doc of docs.filter((d) => d.section === section)) {
|
||||||
const sectionDocs = docs.filter((d) => d.section === section.id);
|
const parts = doc.slug.split("/");
|
||||||
const root: TreeNode = {
|
// parts[0] should be the section
|
||||||
name: section.id,
|
let current = root;
|
||||||
slug: section.id,
|
|
||||||
title: section.label,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
const nodeMap = new Map<string, TreeNode>();
|
|
||||||
nodeMap.set(section.id, root);
|
|
||||||
|
|
||||||
for (const doc of sectionDocs) {
|
for (let i = 1; i < parts.length; i++) {
|
||||||
// slug is like "docs/websocket/contract" → parts after section
|
const part = parts[i];
|
||||||
const parts = doc.slug.split("/");
|
const isLeaf = i === parts.length - 1;
|
||||||
// parts[0] should be the section
|
|
||||||
let current = root;
|
|
||||||
|
|
||||||
for (let i = 1; i < parts.length; i++) {
|
let node = current.find((n) => n.name === part);
|
||||||
const part = parts[i];
|
if (!node) {
|
||||||
const path = parts.slice(0, i + 1).join("/");
|
node = {
|
||||||
const isLeaf = i === parts.length - 1;
|
name: part,
|
||||||
|
slug: parts.slice(0, i + 1).join("/"),
|
||||||
let child = current.children.find((n) => n.name === part);
|
title: "",
|
||||||
if (!child) {
|
children: [],
|
||||||
child = {
|
isLeaf: false,
|
||||||
name: part,
|
docCount: 0,
|
||||||
slug: path,
|
};
|
||||||
title: isLeaf ? doc.title : part,
|
current.push(node);
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
current.children.push(child);
|
|
||||||
nodeMap.set(path, child);
|
|
||||||
} else if (isLeaf) {
|
|
||||||
child.title = doc.title;
|
|
||||||
}
|
|
||||||
current = child;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
root.children.sort((a, b) => {
|
if (isLeaf) {
|
||||||
// Folders first, then docs
|
node.isLeaf = true;
|
||||||
|
node.title = doc.title;
|
||||||
|
} else if (!node.title) {
|
||||||
|
node.title = part
|
||||||
|
.split(/[-_]/)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
current = node.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively compute docCount
|
||||||
|
function countDocs(node: TreeNode): number {
|
||||||
|
if (node.isLeaf) return 1;
|
||||||
|
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of root) {
|
||||||
|
node.docCount = countDocs(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: folders first, then docs, alphabetically
|
||||||
|
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
|
return nodes.sort((a, b) => {
|
||||||
const aFolder = a.children.length > 0 ? 0 : 1;
|
const aFolder = a.children.length > 0 ? 0 : 1;
|
||||||
const bFolder = b.children.length > 0 ? 0 : 1;
|
const bFolder = b.children.length > 0 ? 0 : 1;
|
||||||
if (aFolder !== bFolder) return aFolder - bFolder;
|
if (aFolder !== bFolder) return aFolder - bFolder;
|
||||||
return a.name.localeCompare(b.name);
|
return a.title.localeCompare(b.title);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
result[section.id] = root;
|
for (const node of root) {
|
||||||
|
sortNodes(node.children);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return sortNodes(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively render the folder tree with indentation.
|
* Recursively render the folder tree with proper depth styling.
|
||||||
* depth = 0 is the top-level items under a section.
|
|
||||||
*/
|
*/
|
||||||
function renderTreeNode(node: TreeNode, depth: number, pathname: string) {
|
function renderTreeNode(
|
||||||
|
node: TreeNode,
|
||||||
|
depth: number,
|
||||||
|
pathname: string,
|
||||||
|
): React.ReactElement {
|
||||||
const isFolder = node.children.length > 0;
|
const isFolder = node.children.length > 0;
|
||||||
const isActive = pathname === `/${node.slug}`;
|
const isActive = pathname === `/${node.slug}`;
|
||||||
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
||||||
|
const indent = depth * 16; // 16px per level
|
||||||
const indent = depth * 12; // 12px per level
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={node.slug}>
|
<li key={node.slug}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${node.slug}`}
|
href={`/${node.slug}`}
|
||||||
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
|
className={`flex items-center gap-1.5 text-xs py-1 px-2 rounded transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? "text-[#7170ff] bg-[#191a1b]"
|
? "text-[#7170ff] bg-[#191a1b]"
|
||||||
: hasActiveChild
|
: hasActiveChild
|
||||||
@@ -114,10 +123,15 @@ function renderTreeNode(node: TreeNode, depth: number, pathname: string) {
|
|||||||
title={node.title}
|
title={node.title}
|
||||||
>
|
>
|
||||||
<span>{isFolder ? "📁" : "📄"}</span>
|
<span>{isFolder ? "📁" : "📄"}</span>
|
||||||
{node.title || node.name}
|
<span className="truncate">{node.title || node.name}</span>
|
||||||
|
{isFolder && node.docCount > 0 && (
|
||||||
|
<span className="ml-auto text-[#62666d] bg-[#191a1b] px-1 py-0 rounded">
|
||||||
|
{node.docCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{node.children && node.children.length > 0 && (
|
{node.children.length > 0 && (
|
||||||
<ul>
|
<ul className="mt-0.5">
|
||||||
{node.children.map((child) => renderTreeNode(child, 0, pathname))}
|
{node.children.map((child) => renderTreeNode(child, 0, pathname))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -141,64 +155,33 @@ export default function Sidebar() {
|
|||||||
.catch(() => setDocs([]));
|
.catch(() => setDocs([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tree = buildFolderTree(docs);
|
|
||||||
|
|
||||||
// Flatten all docs count for display
|
|
||||||
const totalDocs = docs.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="h-full overflow-y-auto py-6">
|
<nav className="h-full overflow-y-auto py-6">
|
||||||
<div className="mb-4 px-3">
|
<div className="mb-4 px-3">
|
||||||
<span className="text-xs text-[#62666d] uppercase">
|
<span className="text-xs text-[#62666d] uppercase">
|
||||||
{totalDocs} documents · {SECTIONS.length} sections
|
{docs.length} documents
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-0.5 px-3">
|
<ul className="space-y-2 px-3 text-sm">
|
||||||
{SECTIONS.map(({ id, label, icon }) => {
|
{SECTIONS.map(({ id, label, icon }) => {
|
||||||
const treeRoot = tree[id];
|
|
||||||
const sectionDocs = docs.filter((d) => d.section === id);
|
const sectionDocs = docs.filter((d) => d.section === id);
|
||||||
if (sectionDocs.length === 0) return null;
|
if (sectionDocs.length === 0) return null;
|
||||||
|
|
||||||
const isActive = pathname.startsWith(`/${id}`);
|
const tree = buildFolderTree(docs, id);
|
||||||
|
const isActive = pathname === `/${id}` || pathname.startsWith(`/${id}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={id}>
|
<li key={id}>
|
||||||
<div
|
<div
|
||||||
className={`text-xs font-medium mb-1 flex items-center gap-1.5 ${
|
className={`flex items-center gap-1.5 mb-1 mt-4 first:mt-0 ${
|
||||||
isActive ? "text-[#7170ff]" : "text-[#62666d]"
|
isActive ? "text-[#7170ff]" : "text-[#62666d]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{icon}</span>
|
<span>{icon}</span>
|
||||||
{label}
|
<span className="text-xs font-medium">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5 pl-0">
|
||||||
{treeRoot && treeRoot.children.length > 0
|
{tree.map((node) => renderTreeNode(node, 0, pathname))}
|
||||||
? 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user