From 9b1e75311bce7e68a2470343e19ed8a71883fe46 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sat, 21 Mar 2026 14:45:13 +0800 Subject: [PATCH] feat(web): implement Knowledge Base two-column UI - Left column: searchable document list with title, author, time - Right column: document viewer with simple Markdown rendering (headings, code blocks, tables, lists, inline code) - Referenced-by section at bottom showing linked issue keys - Content centered with max-w-3xl for readability Co-Authored-By: Claude Opus 4.6 --- .../app/(dashboard)/knowledge-base/page.tsx | 339 +++++++++++++++++- 1 file changed, 333 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx index f07243d1..e02b2ea7 100644 --- a/apps/web/app/(dashboard)/knowledge-base/page.tsx +++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx @@ -1,10 +1,337 @@ -export default function KnowledgeBasePage() { - return ( -
-

Knowledge Base

-

- Your team's documents and references will appear here. +"use client"; + +import { useState } from "react"; +import { + FileText, + Plus, + Search, + Link as LinkIcon, +} from "lucide-react"; +import { MOCK_DOCUMENTS, type KBDocument } from "./_data/mock"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const hours = Math.floor(diff / 3600000); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +// --------------------------------------------------------------------------- +// Simple Markdown-ish renderer (handles headers, code blocks, tables, lists) +// --------------------------------------------------------------------------- + +function renderMarkdown(text: string): React.ReactNode[] { + const lines = text.split("\n"); + const elements: React.ReactNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Code block + if (line.startsWith("```")) { + const lang = line.slice(3).trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith("```")) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + elements.push( +

+          {codeLines.join("\n")}
+        
+ ); + continue; + } + + // Table (simplified: detect | pipes) + if (line.includes("|") && line.trim().startsWith("|")) { + const tableRows: string[] = []; + while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|")) { + tableRows.push(lines[i]); + i++; + } + // Filter out separator rows (|---|---|) + const dataRows = tableRows.filter((r) => !r.match(/^\|[\s-|]+\|$/)); + if (dataRows.length > 0) { + const parseRow = (row: string) => + row.split("|").filter((c) => c.trim() !== "").map((c) => c.trim()); + const header = parseRow(dataRows[0]); + const body = dataRows.slice(1).map(parseRow); + elements.push( +
+ + + + {header.map((h, hi) => ( + + ))} + + + + {body.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+ ); + } + continue; + } + + // Heading + if (line.startsWith("## ")) { + elements.push( +

+ {line.slice(3)} +

+ ); + i++; + continue; + } + if (line.startsWith("### ")) { + elements.push( +

+ {line.slice(4)} +

+ ); + i++; + continue; + } + + // List item + if (line.match(/^- \[[ x]\] /)) { + const checked = line.includes("[x]"); + const text = line.replace(/^- \[[ x]\] /, ""); + elements.push( +
+ + {text} +
+ ); + i++; + continue; + } + if (line.startsWith("- ")) { + elements.push( +
+ + {renderInline(line.slice(2))} +
+ ); + i++; + continue; + } + // Numbered list + if (line.match(/^\d+\. /)) { + const num = line.match(/^(\d+)\. /)![1]; + const text = line.replace(/^\d+\. /, ""); + elements.push( +
+ {num}. + {text} +
+ ); + i++; + continue; + } + + // Empty line + if (line.trim() === "") { + elements.push(
); + i++; + continue; + } + + // Paragraph + elements.push( +

+ {renderInline(line)}

+ ); + i++; + } + + return elements; +} + +function renderInline(text: string): React.ReactNode { + // Handle inline code `...` + const parts = text.split(/(`[^`]+`)/); + return parts.map((part, i) => { + if (part.startsWith("`") && part.endsWith("`")) { + return ( + + {part.slice(1, -1)} + + ); + } + return part; + }); +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +function DocListItem({ + doc, + isSelected, + onClick, +}: { + doc: KBDocument; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function DocDetail({ doc }: { doc: KBDocument }) { + return ( +
+
+ {/* Title */} +

{doc.title}

+ + {/* Meta */} +
+ By {doc.createdBy} + · + Updated {timeAgo(doc.updatedAt)} +
+ + {/* Content */} +
{renderMarkdown(doc.content)}
+ + {/* Referenced by */} + {doc.referencedBy.length > 0 && ( +
+
+ + Referenced by +
+
+ {doc.referencedBy.map((ref) => ( + + {ref} + + ))} +
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function KnowledgeBasePage() { + const [selectedId, setSelectedId] = useState( + MOCK_DOCUMENTS[0]?.id ?? "" + ); + const [search, setSearch] = useState(""); + + const filtered = search + ? MOCK_DOCUMENTS.filter((d) => + d.title.toLowerCase().includes(search.toLowerCase()) + ) + : MOCK_DOCUMENTS; + + const selected = MOCK_DOCUMENTS.find((d) => d.id === selectedId) ?? null; + + return ( +
+ {/* Left: Document list */} +
+
+

Knowledge Base

+ +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground" + /> +
+
+ + {/* Document list */} +
+ {filtered.map((doc) => ( + setSelectedId(doc.id)} + /> + ))} + {filtered.length === 0 && ( +
+ No documents found +
+ )} +
+
+ + {/* Right: Document content */} + {selected ? ( + + ) : ( +
+ Select a document +
+ )}
); }