diff --git a/.hermes/plans/ui-ux-polish.md b/.hermes/plans/ui-ux-polish.md new file mode 100644 index 0000000..8d37ef6 --- /dev/null +++ b/.hermes/plans/ui-ux-polish.md @@ -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 diff --git a/apps/web/app/[section]/[...slug]/page.tsx b/apps/web/app/[section]/[...slug]/page.tsx index 851f095..06b27b1 100644 --- a/apps/web/app/[section]/[...slug]/page.tsx +++ b/apps/web/app/[section]/[...slug]/page.tsx @@ -7,6 +7,7 @@ import Markdown from "@/components/Markdown"; import DocForm from "@/components/DocForm"; import TOC from "@/components/TOC"; import { classifyPath, extractFoldersForSection } from "@mcpedia/core"; +import type { DocumentMeta } from "@mcpedia/core"; // Render at request time (content in Postgres, not available at build time). export const dynamic = "force-dynamic"; @@ -31,10 +32,10 @@ const SECTION_ICON: Record = { }; /** - * 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 - * to include, not the UI template. Standard fields (title, author, tags, etc.) - * are handled explicitly; any OTHER keys in frontmatter become badges here. + * to include. Standard fields (title, author, tags, etc.) are handled + * explicitly; any OTHER keys in frontmatter become badges here. */ function CustomFieldBadges({ doc, @@ -43,7 +44,6 @@ function CustomFieldBadges({ doc: Record; standardKeys: Set; }) { - // Convert doc to a plain record to get index signature const docRecord: Record = { ...doc } as Record; const customEntries = Object.entries(docRecord).filter( ([k, v]) => @@ -53,7 +53,7 @@ function CustomFieldBadges({ if (customEntries.length === 0) return null; return ( -
+
{customEntries.map(([key, value]) => { const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "); let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]"; @@ -64,30 +64,24 @@ function CustomFieldBadges({ // not by using specific key names) if (typeof value === "number") { - // Numeric values โ†’ purple "points"-style badge with + prefix colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = `${value}`; } else if (typeof value === "boolean") { - // Boolean values โ†’ green check/X colorClass = value ? "bg-green-500/15 border-green-500/25 text-green-400" : "bg-red-500/15 border-red-500/25 text-red-400"; displayValue = value ? "Yes" : "No"; } else if (Array.isArray(value)) { - // Arrays โ†’ purple badge, joined values colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = value.join(", "); } else if (typeof value === "object" && value !== null) { - // Objects โ†’ neutral badge, truncated JSON displayValue = JSON.stringify(value).slice(0, 40) + (JSON.stringify(value).length > 40 ? "โ€ฆ" : ""); } else { - // String values โ€” auto-detect content type for styling const v = String(value).toLowerCase(); - // Difficulty-like values (easy/medium/hard/etc) OR numeric with suffix 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 (/^easy|simple|beginner/i.test(v)) @@ -97,33 +91,24 @@ function CustomFieldBadges({ else if (/^hard|expert|advanced/i.test(v)) colorClass = "bg-red-500/10 border-red-500/30 text-red-400"; displayValue = diffMatch[0]; - } - // Event-like values (contains CTF, DEF CON, hack, etc.) - else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) { + } else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) { colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = String(value); - } - // Points-like values (number + "pts" suffix) - else if (/(\d+)\s*pts?$/i.test(v)) { + } else if (/(\d+)\s*pts?$/i.test(v)) { const match = v.match(/(\d+)\s*pts?$/i); colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; displayValue = match ? `${match[1]} pts` : String(value); - } - // Category-like values (pwn/web/crypto/etc) - else if (/^(pwn|web|crypto|misc|forensic|reverse|pwnable|binary|webexploit)$/i.test(v)) { + } 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"; displayValue = v.charAt(0).toUpperCase() + v.slice(1); - } - // Status-like values (solved/pending/wip) - else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) { + } else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) { colorClass = v.includes("solved") || v.includes("completed") ? "bg-green-500/10 border-green-500/30 text-green-400" : v.includes("wip") || v.includes("progress") ? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400" : "bg-red-500/10 border-red-500/30 text-red-400"; displayValue = v.charAt(0).toUpperCase() + v.slice(1); - } - else { + } else { displayValue = String(value); } } @@ -132,6 +117,7 @@ function CustomFieldBadges({ {displayValue} @@ -148,80 +134,113 @@ function CustomFieldBadges({ function FolderIndexPage({ section, slug, - docPaths, + allDocs, }: { section: string; slug: string; - docPaths: string[]; + allDocs: DocumentMeta[]; }) { const prefix = `${section}/${slug}/`; - const folderDocs = docPaths - .filter((p) => p.startsWith(prefix)) - .map((p) => p.slice(prefix.length).replace(/\.md$/, "")) - .sort(); + const folderDocPaths = allDocs + .filter((d) => d.path.startsWith(prefix)) + .map((d) => ({ + 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 - const immediateFolders = new Set(); - const immediateDocs: { name: string; slug: string }[] = []; + const immediateFolders = new Map(); + const immediateDocs: DocumentMeta[] = []; - for (const relPath of folderDocs) { - const parts = relPath.split("/"); + for (const { rel, doc } of folderDocPaths) { + const parts = rel.split("/"); if (parts.length === 1) { - // Leaf doc directly in this folder - immediateDocs.push({ name: parts[0], slug: `${section}/${slug}/${parts[0]}`.replace(/\/+/g, "/") }); + immediateDocs.push(doc); } else { - // Deeper โ€” register the immediate folder - immediateFolders.add(parts[0]); + const folder = 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 (
+ {/* Breadcrumb */} -
- ๐Ÿ“ -

{slug.split("/").pop()}

+ {/* Header */} +
+ ๐Ÿ“ +

+ {slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} +

+ + {immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""} +
{(folders.length > 0 || immediateDocs.length > 0) ? (
{folders.length > 0 && ( -
-

+
+

Subfolders

-
- {folders.map((folder) => ( +
+ {folders.map(([folder, info]) => ( - ๐Ÿ“ {folder} + ๐Ÿ“ +
+
+ {folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} +
+
+ {info.docCount} document{info.docCount !== 1 ? "s" : ""} +
+
))}
@@ -229,7 +248,7 @@ function FolderIndexPage({ )} {immediateDocs.length > 0 && (
-

+

Documents

@@ -237,9 +256,34 @@ function FolderIndexPage({ - ๐Ÿ“„ {doc.name} +
+ ๐Ÿ“„ +
+
+ {doc.title} +
+ {doc.tags.length > 0 && ( +
+ {doc.tags.slice(0, 3).map((t) => ( + + #{t} + + ))} +
+ )} +
+
+ ))}
@@ -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 (classification === "folder") { - return ; + return ; } // Otherwise, treat as a document (existing behavior) @@ -285,7 +329,6 @@ export default async function DocPage({ params, searchParams }: DocPageProps) { // Edit mode: inline form if (edit === "1" && canEdit) { - // Load existing folders for the folder picker in edit mode const existingFolders = extractFoldersForSection(docPaths, doc.section); return (
@@ -361,38 +404,58 @@ export default async function DocPage({ params, searchParams }: DocPageProps) { })} - {/* Header */} -
-
- + {/* Metadata Card */} +
+
+ {SECTION_ICON[doc.section] || "๐Ÿ“„"} - - {SECTION_LABEL[doc.section] || doc.section} - +
+
+ + {SECTION_LABEL[doc.section] || doc.section} + + ยท + + {doc.type} + +
+

+ {doc.title} +

+
-

- {doc.title} -

+ {/* Author + date + tags */}
- {doc.author || "unknown"} - ยท - - {doc.tags.map((t) => ( - - #{t} - - ))} + + {doc.tags.length > 0 && ( + <> + ยท +
+ {doc.tags.map((t) => ( + + #{t} + + ))} +
+ + )}
{/* Dynamic custom field badges โ€” content creator controls what shows */} @@ -400,7 +463,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
{/* Content + TOC */} -
+
diff --git a/apps/web/app/[section]/page.tsx b/apps/web/app/[section]/page.tsx index 6d55ddb..8044b98 100644 --- a/apps/web/app/[section]/page.tsx +++ b/apps/web/app/[section]/page.tsx @@ -1,10 +1,18 @@ import Link from "next/link"; -import { listDocuments, type DocumentMeta } from "@mcpedia/core"; -import type { DocSection } from "@mcpedia/core"; +import { listDocuments } from "@mcpedia/core"; 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", 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: - * - a folder (has `children`) or - * - a leaf doc (has `doc`) - * The tree is built from the path segments of each document's `path`. + * Build a hierarchical folder tree from a flat list of documents. + * Each node is either a folder (has children) or a leaf doc. */ interface TreeNode { name: string; slug: string; - doc?: DocumentMeta; - children?: TreeNode[]; + doc?: DocMeta; + 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 rootMap = new Map(); 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 current = 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; + currentPath = `${currentPath}/${part}`; - let node = currentLevel.find((n) => n.name === part); + let node = current.find((n) => n.name === part); if (!node) { node = { - name: part === "" ? section : part, + name: part, slug: currentPath, - children: isLeaf ? undefined : [], + children: [], + docCount: 0, }; - currentLevel.push(node); + current.push(node); } if (isLeaf) { node.doc = doc; + node.docCount = 1; } else { - if (!node.children) node.children = []; - currentLevel = node.children; + // This is a folder โ€” count will be accumulated below } + 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; } -function renderTree(nodes: TreeNode[], section: DocSection) { +function renderTree(nodes: TreeNode[], pathname: string) { return (
    {nodes.map((node) => { - const isFolder = !!node.children; - const isDoc = !!node.doc; + const isFolder = node.children.length > 0; + const isActive = pathname === `/${node.slug}`; + const hasActiveChild = pathname.startsWith(`/${node.slug}/`); + return (
  • - {isFolder ? ( - - ๐Ÿ“ - {node.name} - - ) : isDoc ? ( - - ๐Ÿ“„ + + + {isFolder ? "๐Ÿ“" : "๐Ÿ“„"} {node.doc?.title || node.name} - - ) : null} - {node.children && node.children.length > 0 && ( -
    - {renderTree(node.children, section)} + + {isFolder && ( + + {node.docCount} + + )} + + {node.children.length > 0 && ( +
    + {renderTree(node.children, pathname)}
    )}
  • @@ -115,7 +137,8 @@ export default async function SectionIndexPage({ }) { const { section } = await params; - if (!SECTIONS.some((s) => s.id === section)) { + const sectionInfo = SECTIONS.find((s) => s.id === section); + if (!sectionInfo) { return (

    Unknown section

    @@ -123,39 +146,42 @@ export default async function SectionIndexPage({ ); } - 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)!; + const allDocs = await listDocuments(); + const sectionDocs = allDocs.filter((d) => d.section === section); + const tree = buildFolderTree(allDocs, section); return (
    {sectionInfo.icon}

    {sectionInfo.label}

    + + {sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""} +
    -

    + +

    {sectionInfo.desc}

    -
    -

    - Folder structure -

    - {tree.length > 0 ? ( - renderTree(tree, section as DocSection) - ) : ( -

    No documents found.

    - )} -
    + {tree.length > 0 ? ( +
    +

    + Folder structure +

    + {renderTree(tree, section)} +
    + ) : ( +

    No documents found in this section.

    + )} {sectionDocs.length > 0 && (
    -

    - All documents ({sectionDocs.length}) +

    + Recently updated

    - {sectionDocs + {[...sectionDocs] .sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), @@ -164,10 +190,17 @@ export default async function SectionIndexPage({ - {d.title} + + {d.title} + + ))}
    diff --git a/apps/web/app/components/DocForm.tsx b/apps/web/app/components/DocForm.tsx index 31e7370..68a58bb 100644 --- a/apps/web/app/components/DocForm.tsx +++ b/apps/web/app/components/DocForm.tsx @@ -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 = { 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({
    )} + {/* Title */}
    + {/* Section + Type + Status */}