- 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:
@@ -42,31 +42,40 @@ export default function DocForm({
|
||||
const [status, setStatus] = useState(initial?.status ?? "published");
|
||||
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
|
||||
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<
|
||||
Array<{ key: string; value: string }>
|
||||
>([]);
|
||||
|
||||
// Parent folder selection (hierarchical structure like GitHub folders)
|
||||
const [parentFolder, setParentFolder] = useState("");
|
||||
>(() => {
|
||||
if (initial?.extraFields) {
|
||||
return Object.entries(initial.extraFields)
|
||||
.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
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// For edit mode, slug is fixed (full path). For create mode, build from folder + slugInput.
|
||||
const isEdit = mode === "edit";
|
||||
const effectiveSlug = isEdit ? (slug ?? "") : parentFolder
|
||||
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
|
||||
: slugInput;
|
||||
const effectiveSlug = isEdit
|
||||
? (slug ?? "")
|
||||
: parentFolder
|
||||
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
|
||||
: slugInput;
|
||||
|
||||
const availableFolders = existingFolders?.[section] ?? [];
|
||||
|
||||
const baseInputCls =
|
||||
"w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]";
|
||||
|
||||
const baseSelectCls = baseInputCls;
|
||||
|
||||
function addCustomField() {
|
||||
setCustomFields([...customFields, { key: "", value: "" }]);
|
||||
}
|
||||
@@ -92,7 +101,6 @@ export default function DocForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Build payload with custom fields flattened at top level
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
body,
|
||||
@@ -104,7 +112,6 @@ export default function DocForm({
|
||||
tags: tagList,
|
||||
};
|
||||
|
||||
// Merge dynamic custom fields
|
||||
for (const field of customFields) {
|
||||
if (field.key.trim() && field.value.trim()) {
|
||||
payload[field.key.trim()] = field.value.trim();
|
||||
@@ -156,6 +163,7 @@ export default function DocForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||
Title
|
||||
@@ -169,6 +177,7 @@ export default function DocForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section + Type + Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||
@@ -177,7 +186,7 @@ export default function DocForm({
|
||||
<select
|
||||
value={section}
|
||||
onChange={(e) => setSection(e.target.value as typeof section)}
|
||||
className={baseInputCls}
|
||||
className={baseSelectCls}
|
||||
disabled={isEdit}
|
||||
>
|
||||
{SECTION_OPTIONS.map((s) => (
|
||||
@@ -194,7 +203,7 @@ export default function DocForm({
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as typeof type)}
|
||||
className={baseInputCls}
|
||||
className={baseSelectCls}
|
||||
>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
@@ -210,7 +219,7 @@ export default function DocForm({
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as typeof status)}
|
||||
className={baseInputCls}
|
||||
className={baseSelectCls}
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
@@ -218,90 +227,91 @@ export default function DocForm({
|
||||
</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>
|
||||
{/* Folder + Slug */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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={baseSelectCls}
|
||||
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 inside an existing folder.
|
||||
</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>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||
{isEdit ? "Full slug (read-only)" : "Slug (file name)"}
|
||||
</label>
|
||||
{isEdit ? (
|
||||
{/* Tags + Author */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||
Tags
|
||||
</label>
|
||||
<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)}
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className={baseInputCls}
|
||||
placeholder="my-document-slug"
|
||||
required
|
||||
placeholder="comma, separated, tags"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-[#62666d] mt-1">
|
||||
Resolved path: <code>{effectiveSlug || section}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className={baseInputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 */}
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-2">
|
||||
Custom metadata fields
|
||||
</label>
|
||||
<p className="text-xs text-[#62666d] mb-3">
|
||||
Add extra frontmatter fields. Content creators name and define their own
|
||||
metadata here — the system auto-renders any field as a badge on the doc
|
||||
page based on its value.
|
||||
Add extra frontmatter fields. Any field you add will be rendered as
|
||||
an auto-styled badge on the doc page based on its value.
|
||||
</p>
|
||||
{customFields.length > 0 && (
|
||||
<div className="space-y-3 mb-3">
|
||||
@@ -309,17 +319,17 @@ export default function DocForm({
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="field name (e.g. event)"
|
||||
placeholder="field name"
|
||||
value={field.key}
|
||||
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
|
||||
type="text"
|
||||
placeholder="value"
|
||||
value={field.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
|
||||
type="button"
|
||||
@@ -341,6 +351,7 @@ export default function DocForm({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[#d0d6e0] mb-1">
|
||||
Body (Markdown)
|
||||
@@ -353,6 +364,7 @@ export default function DocForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -19,91 +19,100 @@ const SECTIONS = [
|
||||
] 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).
|
||||
* Build a hierarchical folder tree from flat document slugs.
|
||||
* Each node is either a folder (has children) or a leaf doc.
|
||||
*/
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
children: TreeNode[];
|
||||
isLeaf: boolean;
|
||||
docCount: number; // total docs in subtree
|
||||
}
|
||||
|
||||
function buildFolderTree(docs: Doc[]): Record<string, TreeNode> {
|
||||
// Returns a map from section id → tree root for that section.
|
||||
const result: Record<string, TreeNode> = {};
|
||||
function buildFolderTree(docs: Doc[], section: string): TreeNode[] {
|
||||
const root: 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 docs.filter((d) => d.section === section)) {
|
||||
const parts = doc.slug.split("/");
|
||||
// parts[0] should be the section
|
||||
let current = 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 isLeaf = i === parts.length - 1;
|
||||
|
||||
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;
|
||||
let node = current.find((n) => n.name === part);
|
||||
if (!node) {
|
||||
node = {
|
||||
name: part,
|
||||
slug: parts.slice(0, i + 1).join("/"),
|
||||
title: "",
|
||||
children: [],
|
||||
isLeaf: false,
|
||||
docCount: 0,
|
||||
};
|
||||
current.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
root.children.sort((a, b) => {
|
||||
// Folders first, then docs
|
||||
if (isLeaf) {
|
||||
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 bFolder = b.children.length > 0 ? 0 : 1;
|
||||
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.
|
||||
* depth = 0 is the top-level items under a section.
|
||||
* Recursively render the folder tree with proper depth styling.
|
||||
*/
|
||||
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 isActive = pathname === `/${node.slug}`;
|
||||
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
||||
|
||||
const indent = depth * 12; // 12px per level
|
||||
const indent = depth * 16; // 16px 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 ${
|
||||
className={`flex items-center gap-1.5 text-xs py-1 px-2 rounded transition-all ${
|
||||
isActive
|
||||
? "text-[#7170ff] bg-[#191a1b]"
|
||||
: hasActiveChild
|
||||
@@ -114,10 +123,15 @@ function renderTreeNode(node: TreeNode, depth: number, pathname: string) {
|
||||
title={node.title}
|
||||
>
|
||||
<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>
|
||||
{node.children && node.children.length > 0 && (
|
||||
<ul>
|
||||
{node.children.length > 0 && (
|
||||
<ul className="mt-0.5">
|
||||
{node.children.map((child) => renderTreeNode(child, 0, pathname))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -141,64 +155,33 @@ export default function Sidebar() {
|
||||
.catch(() => setDocs([]));
|
||||
}, []);
|
||||
|
||||
const tree = buildFolderTree(docs);
|
||||
|
||||
// Flatten all docs count for display
|
||||
const totalDocs = docs.length;
|
||||
|
||||
return (
|
||||
<nav className="h-full overflow-y-auto py-6">
|
||||
<div className="mb-4 px-3">
|
||||
<span className="text-xs text-[#62666d] uppercase">
|
||||
{totalDocs} documents · {SECTIONS.length} sections
|
||||
{docs.length} documents
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 px-3">
|
||||
<ul className="space-y-2 px-3 text-sm">
|
||||
{SECTIONS.map(({ id, label, icon }) => {
|
||||
const treeRoot = tree[id];
|
||||
const sectionDocs = docs.filter((d) => d.section === id);
|
||||
if (sectionDocs.length === 0) return null;
|
||||
|
||||
const isActive = pathname.startsWith(`/${id}`);
|
||||
const tree = buildFolderTree(docs, id);
|
||||
const isActive = pathname === `/${id}` || pathname.startsWith(`/${id}/`);
|
||||
|
||||
return (
|
||||
<li key={id}>
|
||||
<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]"
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{treeRoot && treeRoot.children.length > 0
|
||||
? treeRoot.children.map((node) =>
|
||||
renderTreeNode(node, 0, pathname),
|
||||
)
|
||||
: sectionDocs.map((doc) => {
|
||||
const parts = doc.slug.split("/").slice(1);
|
||||
const label = parts[parts.length - 1]
|
||||
.split(/[-_]/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
const isDocActive = pathname === `/${doc.slug}`;
|
||||
return (
|
||||
<li key={doc.slug}>
|
||||
<Link
|
||||
href={`/${doc.slug}`}
|
||||
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
|
||||
isDocActive
|
||||
? "text-[#7170ff] bg-[#191a1b]"
|
||||
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
|
||||
}`}
|
||||
title={doc.title}
|
||||
>
|
||||
<span>📄</span>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<ul className="space-y-0.5 pl-0">
|
||||
{tree.map((node) => renderTreeNode(node, 0, pathname))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user