feat(web): overhaul frontend with cyber-intelligence design and interactive topology
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-08-22 14:55:19 +07:00
parent 9591c67478
commit 011571e12d
22 changed files with 1763 additions and 869 deletions
+151 -49
View File
@@ -3,6 +3,21 @@
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Search,
FileText,
Plus,
Compass,
Sparkles,
ArrowRight,
BookOpen,
Flag,
Layers,
SunMoon,
Command,
X,
Flame,
} from "lucide-react";
interface Hit {
slug: string;
@@ -12,6 +27,15 @@ interface Hit {
score: number;
}
interface QuickAction {
id: string;
title: string;
subtitle: string;
icon: any;
href?: string;
action?: () => void;
}
export default function CommandMenu() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@@ -21,10 +45,48 @@ export default function CommandMenu() {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const quickActions: QuickAction[] = [
{
id: "create-doc",
title: "Create New Document",
subtitle: "Write technical doc, writeup, or research note",
icon: Plus,
href: "/create",
},
{
id: "hybrid-search",
title: "Multi-Modal Hybrid Search",
subtitle: "Full-text + Semantic vector search console",
icon: Search,
href: "/search",
},
{
id: "browse-docs",
title: "Documentation Hub",
subtitle: "System setup, API references, protocol specs",
icon: BookOpen,
href: "/docs",
},
{
id: "browse-writeups",
title: "CTF & Challenge Writeups",
subtitle: "Pwn, Reverse, Web, Crypto writeups and solutions",
icon: Flag,
href: "/writeups",
},
{
id: "browse-research",
title: "Deep-Dive Research",
subtitle: "Architecture notes, benchmarks, experiments",
icon: Sparkles,
href: "/research",
},
];
// Global shortcut ⌘K or /
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
@@ -56,7 +118,7 @@ export default function CommandMenu() {
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=8`);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`);
const data = await res.json();
setResults(data.results || []);
setSelectedIndex(0);
@@ -65,25 +127,31 @@ export default function CommandMenu() {
} finally {
setLoading(false);
}
}, 150);
}, 120);
return () => clearTimeout(timer);
}, [query]);
function handleNavigate(slug: string) {
function handleNavigate(href: string) {
setOpen(false);
router.push(`/${slug}`);
router.push(href);
}
const totalItems = query.trim() ? results.length : quickActions.length;
function handleKeyNavigation(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (results.length > 0 ? (prev + 1) % results.length : 0));
setSelectedIndex((prev) => (totalItems > 0 ? (prev + 1) % totalItems : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (results.length > 0 ? (prev - 1 + results.length) % results.length : 0));
} else if (e.key === "Enter" && results[selectedIndex]) {
setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
} else if (e.key === "Enter") {
e.preventDefault();
handleNavigate(results[selectedIndex].slug);
if (query.trim() && results[selectedIndex]) {
handleNavigate(`/${results[selectedIndex].slug}`);
} else if (!query.trim() && quickActions[selectedIndex]?.href) {
handleNavigate(quickActions[selectedIndex].href!);
}
}
}
@@ -91,13 +159,12 @@ export default function CommandMenu() {
return (
<button
onClick={() => setOpen(true)}
className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-sm"
type="button"
className="hidden md:flex items-center gap-2.5 px-3 py-1.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer group"
>
<svg className="w-3.5 h-3.5 text-[var(--text-dim)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search docs...</span>
<kbd className="ml-2 font-mono bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-dim)]">
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors" />
<span className="font-sans">Search knowledge...</span>
<kbd className="ml-2 font-mono bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-dim)] shadow-xs">
⌘K
</kbd>
</button>
@@ -105,87 +172,118 @@ export default function CommandMenu() {
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4">
<div className="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm transition-opacity"
className="fixed inset-0 bg-slate-950/60 dark:bg-black/80 backdrop-blur-md transition-opacity"
onClick={() => setOpen(false)}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in">
{/* Omnisearch Modal */}
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl shadow-2xl overflow-hidden animate-fade-in ring-1 ring-white/10">
{/* Search input bar */}
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
<svg className="w-5 h-5 text-[var(--brand)] dark:text-[var(--accent)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<div className="flex items-center gap-3 px-4 sm:px-5 py-4 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]/70">
<Search className="w-5 h-5 text-[var(--brand)] dark:text-[var(--accent)] shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyNavigation}
placeholder="Type to search (titles, keywords, concepts)..."
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
placeholder="Search documents, writeups, concepts, or commands..."
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm sm:text-base focus:outline-none"
/>
{loading && (
<div className="w-4 h-4 border-2 border-[var(--brand)] border-t-transparent rounded-full animate-spin shrink-0" />
)}
<button
onClick={() => setOpen(false)}
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
type="button"
className="text-[11px] font-mono text-[var(--text-dim)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-2 py-0.5 rounded border border-[var(--border-color)]"
>
ESC
</button>
</div>
{/* Results / Suggestions */}
<div className="max-h-96 overflow-y-auto p-2">
{/* Results / Quick Actions List */}
<div className="max-h-[22rem] overflow-y-auto p-2">
{query.trim() === "" ? (
<div className="px-3 py-6 text-center">
<p className="text-xs text-[var(--text-muted)]">Search full-text, semantic embeddings, and CTF writeups.</p>
<div className="flex justify-center gap-2 mt-3 text-[11px] text-[var(--text-dim)]">
<span>Navigation: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↑</kbd> <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↓</kbd></span>
<span>Select: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↵</kbd></span>
<div className="p-2 space-y-1">
<div className="px-3 py-1.5 text-[10px] font-mono uppercase tracking-wider text-[var(--text-dim)]">
Quick Shortcuts & Actions
</div>
{quickActions.map((action, idx) => {
const Icon = action.icon;
const isSelected = idx === selectedIndex;
return (
<div
key={action.id}
onClick={() => action.href && handleNavigate(action.href)}
onMouseEnter={() => setSelectedIndex(idx)}
className={`flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${
isSelected
? "bg-[var(--brand)]/15 border border-[var(--brand)]/40 text-[var(--text-primary)] shadow-xs"
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isSelected ? "bg-[var(--brand)] text-white" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="text-xs sm:text-sm font-semibold text-[var(--text-primary)]">
{action.title}
</div>
<div className="text-[11px] text-[var(--text-muted)]">
{action.subtitle}
</div>
</div>
</div>
<ArrowRight className={`w-3.5 h-3.5 text-[var(--text-dim)] ${isSelected ? "text-[var(--brand)] translate-x-0.5" : ""} transition-all`} />
</div>
);
})}
</div>
) : results.length === 0 && !loading ? (
<div className="px-4 py-8 text-center text-xs text-[var(--text-muted)]">
No documents matching &quot;{query}&quot;
<div className="px-4 py-12 text-center text-xs text-[var(--text-muted)] space-y-2">
<p>No knowledge base items matched &quot;{query}&quot;</p>
<p className="text-[11px] text-[var(--text-dim)]">
Try searching with partial words or check semantic search
</p>
</div>
) : (
<ul className="space-y-1">
<ul className="space-y-1 p-1">
{results.map((hit, index) => {
const isSelected = index === selectedIndex;
return (
<li
key={hit.slug}
onClick={() => handleNavigate(hit.slug)}
onClick={() => handleNavigate(`/${hit.slug}`)}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all ${
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${
isSelected
? "bg-[var(--brand)]/15 border border-[var(--brand)]/40 text-[var(--text-primary)]"
? "bg-[var(--brand)]/15 border border-[var(--brand)]/40 text-[var(--text-primary)] shadow-xs"
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--brand)] dark:text-[var(--accent)] font-mono">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] font-mono px-2 py-0.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--brand)] dark:text-[var(--accent)] uppercase font-semibold">
{hit.section}
</span>
<span className={`text-sm font-medium ${isSelected ? "text-[var(--brand)] dark:text-[var(--accent)]" : "text-[var(--text-primary)]"} truncate`}>
<span className={`text-xs sm:text-sm font-semibold ${isSelected ? "text-[var(--brand)] dark:text-[var(--accent)]" : "text-[var(--text-primary)]"} truncate`}>
{hit.title}
</span>
</div>
{hit.snippet && (
<p
className="text-xs text-[var(--text-muted)] mt-1 line-clamp-1 [&>mark]:bg-[var(--brand)]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:rounded [&>mark]:px-0.5"
className="text-xs text-[var(--text-muted)] line-clamp-1 mt-1 [&>mark]:bg-[var(--brand)]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:rounded [&>mark]:px-0.5"
dangerouslySetInnerHTML={{ __html: hit.snippet }}
/>
)}
</div>
<span className="text-[11px] text-[var(--text-dim)] shrink-0 self-center">
{hit.slug}
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">
/{hit.slug}
</span>
</li>
);
@@ -195,14 +293,18 @@ export default function CommandMenu() {
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[11px] text-[var(--text-dim)]">
<span>Tip: Press <kbd className="bg-[var(--bg-surface)] px-1 py-0.5 rounded border border-[var(--border-color)]">Hybrid</kbd> search is enabled</span>
<div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border-color)] bg-[var(--bg-elevated)]/60 text-[11px] text-[var(--text-dim)]">
<div className="flex items-center gap-2">
<span>Navigate: <kbd className="bg-[var(--bg-surface)] px-1 py-0.5 rounded border border-[var(--border-color)]">↑↓</kbd></span>
<span>Select: <kbd className="bg-[var(--bg-surface)] px-1 py-0.5 rounded border border-[var(--border-color)]">↵</kbd></span>
</div>
<Link
href={`/search?q=${encodeURIComponent(query)}`}
onClick={() => setOpen(false)}
className="hover:text-[var(--brand)] dark:hover:text-[var(--accent)] transition-colors"
className="text-[var(--brand)] dark:text-[var(--accent)] hover:underline font-medium flex items-center gap-1"
>
Open full search page →
<span>Full Search Console</span>
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
+23 -27
View File
@@ -2,6 +2,14 @@
import { useState } from "react";
import Link from "next/link";
import {
Copy,
Check,
Share2,
FileDown,
Edit3,
ExternalLink,
} from "lucide-react";
interface DocActionsProps {
slug: string;
@@ -27,49 +35,41 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
return (
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
{/* Copy Markdown */}
{/* Copy Raw Markdown */}
<button
onClick={handleCopyMarkdown}
type="button"
title="Copy raw markdown content"
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
title="Copy raw markdown content to clipboard"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer"
>
{copiedMd ? (
<>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied MD</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
<Copy className="w-3.5 h-3.5" />
<span>Raw MD</span>
</>
)}
</button>
{/* Copy Link */}
{/* Copy Share Link */}
<button
onClick={handleCopyLink}
type="button"
title="Copy link to document"
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer"
>
{copiedLink ? (
<>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied Link</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<Share2 className="w-3.5 h-3.5" />
<span>Share</span>
</>
)}
@@ -78,23 +78,19 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
{/* Export PDF */}
<Link
href={`/${slug}/export`}
title="Export document or parent folder to PDF"
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
title="Export document or parent folder to publication-grade PDF"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
<span>PDF</span>
<FileDown className="w-3.5 h-3.5" />
<span>Export PDF</span>
</Link>
{/* Edit Link */}
<Link
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--brand)]/10 hover:bg-[var(--brand)]/20 border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 text-[var(--brand)] dark:text-[var(--accent)] rounded-md transition-all font-medium"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--brand)]/10 hover:bg-[var(--brand)]/20 border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 text-[var(--brand)] dark:text-[var(--accent)] rounded-lg transition-all font-medium shadow-xs"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<Edit3 className="w-3.5 h-3.5" />
<span>Edit</span>
</Link>
</div>
+165 -106
View File
@@ -3,6 +3,27 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Markdown from "./Markdown";
import {
Bold,
Italic,
Heading2,
Heading3,
Code,
Link as LinkIcon,
Table as TableIcon,
Info,
Lightbulb,
AlertTriangle,
Plus,
Trash2,
Check,
Eye,
Edit3,
FileCode,
Tag,
Folder,
Sliders,
} from "lucide-react";
interface DocFormProps {
mode: "create" | "edit";
@@ -22,8 +43,26 @@ interface DocFormProps {
};
}
const DEFAULT_SECTION_LIST = ["docs", "writeups", "research", "notes", "guides", "tutorials", "ctf", "api", "projects"];
const DEFAULT_TYPE_LIST = ["documentation", "writeup", "research", "note", "guide", "tutorial", "spec"];
const DEFAULT_SECTION_LIST = [
"docs",
"writeups",
"research",
"notes",
"guides",
"tutorials",
"ctf",
"api",
"projects",
];
const DEFAULT_TYPE_LIST = [
"documentation",
"writeup",
"research",
"note",
"guide",
"tutorial",
"spec",
];
const PRESET_FIELDS = [
{ key: "difficulty", value: "medium" },
@@ -88,7 +127,7 @@ export default function DocForm({
const availableFolders = existingFolders?.[section] ?? [];
const baseInputCls =
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[var(--brand)] transition-colors";
"w-full px-3.5 py-2.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-2 focus:ring-[var(--brand)]/20 transition-all";
function addCustomField(key = "", value = "") {
setCustomFields([...customFields, { key, value }]);
@@ -128,7 +167,7 @@ export default function DocForm({
setError(null);
if (!effectiveSlug || !title || !body) {
setError("Slug, title, and body are required.");
setError("Slug, title, and markdown body are required.");
setLoading(false);
return;
}
@@ -175,7 +214,7 @@ export default function DocForm({
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Save failed");
throw new Error(data.error ?? "Failed to save document. Verify ADMIN_PASSWORD or secret.");
}
const result = await res.json();
@@ -190,75 +229,73 @@ export default function DocForm({
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{error && (
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-sm">
<div className="p-4 rounded-xl border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs sm:text-sm font-medium animate-fade-in">
{error}
</div>
)}
{/* Title */}
{/* Document Title */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Document Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Database Architecture Overview"
className={`${baseInputCls} text-base`}
placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
className={`${baseInputCls} text-base sm:text-lg font-semibold`}
required
/>
</div>
{/* Section + Type + Status */}
{/* Section, Type, Status */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Section
</label>
<div className="relative">
<input
type="text"
list="section-list"
value={section}
onChange={(e) => setSection(e.target.value)}
placeholder="e.g. docs, writeups, ctf, guides"
className={baseInputCls}
required
/>
<datalist id="section-list">
{sectionOptions.map((s) => (
<option key={s} value={s}>
{s.toUpperCase()}
</option>
))}
</datalist>
</div>
<input
type="text"
list="section-list"
value={section}
onChange={(e) => setSection(e.target.value)}
placeholder="e.g. docs, writeups, research"
className={baseInputCls}
required
/>
<datalist id="section-list">
{sectionOptions.map((s) => (
<option key={s} value={s}>
{s.toUpperCase()}
</option>
))}
</datalist>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Type
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Document Type
</label>
<div className="relative">
<input
type="text"
list="type-list"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. documentation, writeup, note"
className={baseInputCls}
/>
<datalist id="type-list">
{DEFAULT_TYPE_LIST.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</datalist>
</div>
<input
type="text"
list="type-list"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. writeup, documentation, note"
className={baseInputCls}
/>
<datalist id="type-list">
{DEFAULT_TYPE_LIST.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</datalist>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Status
</label>
<select
@@ -267,7 +304,7 @@ export default function DocForm({
className={baseInputCls}
>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="draft">Draft (Unindexed)</option>
</select>
</div>
</div>
@@ -275,8 +312,8 @@ export default function DocForm({
{/* Folder + Slug */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Parent Folder
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Parent Folder / Chapter
</label>
<select
value={parentFolder}
@@ -292,16 +329,17 @@ export default function DocForm({
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
{isEdit ? "Full Slug" : "Slug (File name) *"}
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
{isEdit ? "Document Path" : "Slug (File name) *"}
</label>
{isEdit ? (
<input
type="text"
value={slug ?? ""}
readOnly
className="w-full px-3.5 py-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg text-sm text-[var(--text-muted)] font-mono"
className="w-full px-3.5 py-2.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-xl text-sm text-[var(--text-muted)] font-mono"
/>
) : (
<input
@@ -309,12 +347,12 @@ export default function DocForm({
value={slugInput}
onChange={(e) => setSlugInput(e.target.value)}
className={`${baseInputCls} font-mono`}
placeholder="my-new-post"
placeholder="ret2win-guide"
required
/>
)}
<p className="text-[11px] text-[var(--text-dim)] mt-1 font-mono">
Path: <span className="text-[var(--brand)] dark:text-[var(--accent)]">{effectiveSlug || section}</span>
<p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
Full Slug: <span className="text-[var(--brand)] dark:text-[var(--accent)] font-semibold">{effectiveSlug || section}</span>
</p>
</div>
</div>
@@ -322,7 +360,7 @@ export default function DocForm({
{/* Tags + Author */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Tags (comma separated)
</label>
<input
@@ -330,11 +368,11 @@ export default function DocForm({
value={tags}
onChange={(e) => setTags(e.target.value)}
className={baseInputCls}
placeholder="ctf, pwn, reverse, tutorial"
placeholder="pwn, x86_64, rop, ctf"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Author
</label>
<input
@@ -342,31 +380,32 @@ export default function DocForm({
value={author}
onChange={(e) => setAuthor(e.target.value)}
className={baseInputCls}
placeholder="asep"
placeholder="e.g. asepharyana"
/>
</div>
</div>
{/* Custom Metadata Fields */}
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="p-4 sm:p-5 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl space-y-4 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
Dynamic Custom Metadata
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
<Sliders className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
<span>Dynamic Technical Metadata</span>
</h3>
<p className="text-[11px] text-[var(--text-dim)] mt-0.5">
Add custom key-values (event, difficulty, points, etc.) rendered as styled badges.
Custom fields (points, difficulty, event, solved) will render as styled badges in the document hero.
</p>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-[var(--text-dim)]">Presets:</span>
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
{PRESET_FIELDS.map((preset) => (
<button
key={preset.key}
type="button"
onClick={() => addCustomField(preset.key, preset.value)}
className="px-2 py-0.5 text-[11px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors"
className="px-2 py-0.5 text-[11px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors font-mono cursor-pointer"
>
+{preset.key}
</button>
@@ -393,10 +432,10 @@ export default function DocForm({
<button
type="button"
onClick={() => removeCustomField(i)}
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors"
className="p-2 text-rose-500 hover:text-rose-600 rounded-lg hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
title="Remove field"
>
✕
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
@@ -404,105 +443,124 @@ export default function DocForm({
<button
type="button"
onClick={() => addCustomField()}
className="text-xs text-[var(--brand)] dark:text-[var(--accent)] hover:underline px-3 py-1.5 rounded-lg border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 bg-[var(--brand)]/5 transition-colors font-medium"
className="inline-flex items-center gap-1.5 text-xs text-[var(--brand)] dark:text-[var(--accent)] hover:underline px-3 py-1.5 rounded-lg border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 bg-[var(--brand)]/5 transition-colors font-medium cursor-pointer"
>
+ Add Custom Field
<Plus className="w-3.5 h-3.5" />
<span>Add Custom Field</span>
</button>
</div>
{/* Body with Write / Preview Tabs */}
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-sm">
{/* Markdown Body Editor */}
<div className="border border-[var(--border-color)] rounded-2xl overflow-hidden bg-[var(--bg-surface)] shadow-sm">
{/* Editor Toolbar */}
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)]/60 border-b border-[var(--border-color)] flex-wrap gap-2">
{/* Tabs */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setActiveTab("write")}
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-semibold transition-all cursor-pointer ${
activeTab === "write"
? "bg-[var(--brand)] text-white shadow-sm"
? "bg-[var(--brand)] text-white shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`}
>
Write
<Edit3 className="w-3.5 h-3.5" />
<span>Write</span>
</button>
<button
type="button"
onClick={() => setActiveTab("preview")}
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-semibold transition-all cursor-pointer ${
activeTab === "preview"
? "bg-[var(--brand)] text-white shadow-sm"
? "bg-[var(--brand)] text-white shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`}
>
Live Preview
<Eye className="w-3.5 h-3.5" />
<span>Live Preview</span>
</button>
</div>
{/* Markdown Action Shortcuts */}
{/* Quick Markdown Formatter Shortcuts */}
{activeTab === "write" && (
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
<button
type="button"
onClick={() => insertMarkdown("**", "**")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Bold"
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)]"
title="Bold (**text**)"
>
<b>B</b>
<Bold className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("*", "*")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Italic"
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)]"
title="Italic (*text*)"
>
<i>I</i>
<Italic className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("## ")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)]"
title="Heading 2"
>
H2
<Heading2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("### ")}
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)]"
title="Heading 3"
>
<Heading3 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("```\n", "\n```")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] font-mono"
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)]"
title="Code Block"
>
{"</>"}
<Code className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("[", "](https://)")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)]"
title="Link"
>
Link
<LinkIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("> [!NOTE]\n> ")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)] text-indigo-500"
title="Note Callout"
>
Note
<Info className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("> [!TIP]\n> ")}
className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md hover:text-[var(--text-primary)] text-emerald-500"
title="Tip Callout"
>
<Lightbulb className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Tab content */}
{/* Tab Content */}
{activeTab === "write" ? (
<textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
placeholder="# Write your markdown document here..."
className="w-full h-96 p-4 sm:p-5 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
placeholder="# Write your technical documentation or writeup here in Markdown..."
required
/>
) : (
@@ -510,28 +568,29 @@ export default function DocForm({
{body.trim() ? (
<Markdown source={body} />
) : (
<p className="text-xs text-[var(--text-dim)] italic">Nothing to preview yet.</p>
<p className="text-xs text-[var(--text-dim)] italic">No content to preview yet.</p>
)}
</div>
)}
</div>
{/* Actions */}
{/* Submit Action Buttons */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg font-medium text-sm transition-all shadow-md shadow-[var(--brand)]/20 disabled:opacity-50 flex items-center gap-2"
className="px-6 py-2.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-xl font-semibold text-sm transition-all shadow-md shadow-[var(--brand)]/20 hover:shadow-[var(--brand)]/35 disabled:opacity-50 flex items-center gap-2 cursor-pointer"
>
{loading && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
<span>{loading ? "Saving document..." : mode === "create" ? "Create Document" : "Save Changes"}</span>
<span>{loading ? "Persisting Document..." : mode === "create" ? "Publish to PostgreSQL" : "Save Changes"}</span>
</button>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-sm transition-colors shadow-sm"
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-xl text-sm transition-colors shadow-xs cursor-pointer"
>
Cancel
</button>
+87 -58
View File
@@ -6,6 +6,18 @@ import { usePathname } from "next/navigation";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
import ThemeToggle from "@/components/ThemeToggle";
import CommandMenu from "@/components/CommandMenu";
import {
Sparkles,
Plus,
Search,
Menu,
X,
Lock,
Layers,
FileText,
Activity,
Terminal,
} from "lucide-react";
export default function Header() {
const pathname = usePathname();
@@ -24,126 +36,143 @@ export default function Header() {
}, []);
return (
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav">
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
{/* Brand */}
<header className="sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-15 flex items-center justify-between gap-3 sm:gap-4">
{/* Left: Brand Identity */}
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2 group">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-[var(--brand)] to-[var(--accent)] flex items-center justify-center text-white font-bold text-xs shadow-md shadow-[var(--brand)]/20 group-hover:scale-105 transition-transform">
M
<Link href="/" className="flex items-center gap-2.5 group">
<div className="relative flex items-center justify-center w-8 h-8 rounded-xl bg-gradient-to-br from-indigo-500 via-indigo-600 to-violet-700 text-white shadow-lg shadow-indigo-500/25 group-hover:scale-105 group-hover:shadow-indigo-500/40 transition-all">
<span className="font-mono font-bold text-sm tracking-tighter">M</span>
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 ring-2 ring-[var(--bg-app)] animate-pulse" />
</div>
<div className="flex flex-col">
<span className="font-bold text-[var(--text-primary)] text-base tracking-tight leading-none group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors">
MCPedia
</span>
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase tracking-wider leading-tight mt-0.5 hidden sm:block">
AI Knowledge Hub
</span>
</div>
<span className="font-semibold text-[var(--text-primary)] text-base tracking-tight transition-colors">
MCPedia
</span>
</Link>
<span className="hidden sm:inline-block text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)]">
v0.1.0
</span>
<div className="hidden xl:flex items-center gap-1.5 ml-2 px-2 py-0.5 rounded-full bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[10px] font-mono text-[var(--text-dim)]">
<Activity className="w-3 h-3 text-emerald-500" />
<span>MCP :4021</span>
</div>
</div>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1">
{/* Center: Desktop Dynamic Sections Nav */}
<nav className="hidden md:flex items-center gap-1 bg-[var(--bg-elevated)]/60 p-1 rounded-xl border border-[var(--border-color)]">
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link
key={s.id}
href={`/${s.id}`}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5 ${
isActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface-hover)]"
? "text-[var(--text-primary)] bg-[var(--bg-surface)] shadow-xs font-semibold border border-[var(--border-color)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]/50"
}`}
>
<span className="mr-1.5 opacity-80">{s.icon}</span>
{s.label}
<span className="text-xs">{s.icon}</span>
<span>{s.label}</span>
</Link>
);
})}
</nav>
{/* Right tools */}
<div className="flex items-center gap-2.5">
{/* Right Tools & Navigation Deck */}
<div className="flex items-center gap-2">
<CommandMenu />
<Link
href="/search"
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg hover:bg-[var(--bg-elevated)] transition-colors"
title="Search"
className="md:hidden p-2 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg bg-[var(--bg-surface)] border border-[var(--border-color)] transition-colors"
title="Search knowledge base"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<Search className="w-4 h-4" />
</Link>
<Link
href="/create"
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1 bg-[var(--brand)] hover:bg-[var(--accent)] text-white text-xs font-medium rounded-lg transition-all shadow-sm"
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-medium rounded-lg shadow-md shadow-[var(--brand)]/20 hover:shadow-[var(--brand)]/35 transition-all cursor-pointer"
>
<span>+</span>
<Plus className="w-3.5 h-3.5" />
<span>New Doc</span>
</Link>
<Link
href="/login"
className={`px-2.5 py-1 text-xs rounded-lg transition-colors ${
className={`p-1.5 sm:px-2.5 sm:py-1.5 text-xs rounded-lg transition-colors flex items-center gap-1.5 ${
pathname === "/login"
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-medium"
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`}
title="Admin Login"
>
Login
<Lock className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Admin</span>
</Link>
<div className="border-l border-[var(--border-color)] pl-2.5 flex items-center">
<div className="border-l border-[var(--border-color)] pl-2 flex items-center">
<ThemeToggle />
</div>
{/* Mobile menu toggle */}
{/* Mobile menu hamburger toggle */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
className="md:hidden p-2 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg hover:bg-[var(--bg-elevated)] transition-colors"
aria-label="Toggle navigation"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile Drawer */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-3 space-y-1 animate-fade-in shadow-lg">
{sections.map((s) => (
<Link
key={s.id}
href={`/${s.id}`}
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
>
<span>{s.icon}</span>
<span>{s.label}</span>
</Link>
))}
<div className="pt-2 border-t border-[var(--border-color)] flex gap-2">
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-4 space-y-2 animate-fade-in shadow-xl">
<div className="text-[11px] font-mono uppercase tracking-wider text-[var(--text-dim)] px-2 mb-1">
Sections
</div>
<div className="grid grid-cols-2 gap-1.5">
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link
key={s.id}
href={`/${s.id}`}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
isActive
? "bg-[var(--brand)]/15 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30 font-semibold"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] border border-transparent"
}`}
>
<span>{s.icon}</span>
<span className="truncate">{s.label}</span>
</Link>
);
})}
</div>
<div className="pt-3 border-t border-[var(--border-color)] flex gap-2">
<Link
href="/search"
onClick={() => setMobileMenuOpen(false)}
className="flex-1 text-center py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] rounded-lg font-medium border border-[var(--border-color)]"
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg font-medium border border-[var(--border-color)]"
>
Search
<Search className="w-3.5 h-3.5" />
<span>Search</span>
</Link>
<Link
href="/create"
onClick={() => setMobileMenuOpen(false)}
className="flex-1 text-center py-2 text-xs bg-[var(--brand)] text-white rounded-lg font-medium"
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--brand)] text-white rounded-lg font-medium shadow-sm"
>
+ Create
<Plus className="w-3.5 h-3.5" />
<span>Create Doc</span>
</Link>
</div>
</div>
@@ -0,0 +1,379 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Cpu,
Database,
Layers,
Sparkles,
Search,
Globe,
Terminal,
Server,
ArrowRight,
CheckCircle2,
FileCode2,
ShieldCheck,
} from "lucide-react";
interface NodeDetail {
id: string;
title: string;
badge: string;
type: "client" | "protocol" | "core" | "storage";
icon: any;
desc: string;
metrics: { label: string; value: string }[];
features: string[];
docLink?: string;
}
const NODES: NodeDetail[] = [
{
id: "ai-agents",
title: "AI Coding Agents",
badge: "MCP Consumer",
type: "client",
icon: Sparkles,
desc: "Claude Desktop, Cursor, Antigravity, and Zed connect via Model Context Protocol to read and write technical knowledge directly.",
metrics: [
{ label: "Transport", value: "Stdio / HTTP SSE" },
{ label: "Tools Exposed", value: "13 Native Tools" },
{ label: "Authentication", value: "x-webhook-secret" },
],
features: [
"Semantic similarity search over chunks",
"Direct document CRUD into Postgres",
"Context retrieval for debugging & CTF writeups",
],
docLink: "/docs",
},
{
id: "web-ui",
title: "Human Web Interface",
badge: "Next.js 16 + React 19",
type: "client",
icon: Globe,
desc: "Fast, accessible, and responsive developer web interface with dark/light themes, keyboard shortcuts, and instant search.",
metrics: [
{ label: "Framework", value: "Next.js 16.3" },
{ label: "Styling", value: "Tailwind CSS v4" },
{ label: "Command Menu", value: "⌘K Omnisearch" },
],
features: [
"Hierarchical folder directory tree",
"Publication-grade PDF export generator",
"Live markdown editor with revision rollback",
],
docLink: "/docs",
},
{
id: "mcp-server",
title: "MCP Server Gateway",
badge: "Streamable HTTP :4021",
type: "protocol",
icon: Server,
desc: "High-throughput streamable Model Context Protocol server exposing tool endpoints and resource URIs.",
metrics: [
{ label: "Port", value: ":4021" },
{ label: "Protocol", value: "MCP 2024-11-05" },
{ label: "Runtime", value: "Bun Engine" },
],
features: [
"get_document / create_document tools",
"search_hybrid / search_semantic tools",
"list_sections & list_revisions resources",
],
docLink: "/api",
},
{
id: "core-engine",
title: "@mcpedia/core",
badge: "Single Source of Truth",
type: "core",
icon: Layers,
desc: "Unified business logic layer shared across Web, MCP Server, REST API, and Background Workers.",
metrics: [
{ label: "Architecture", value: "Monorepo Package" },
{ label: "Validation", value: "Strict Type Safety" },
{ label: "Ranking", value: "RRF Hybrid Fusion" },
],
features: [
"Path classification & folder hierarchy",
"Markdown document chunking & tokenization",
"Drizzle ORM transactional schema mapping",
],
docLink: "/research",
},
{
id: "search-engine",
title: "Multi-Modal Search Engine",
badge: "RRF Algorithm",
type: "core",
icon: Search,
desc: "Reciprocal Rank Fusion uniting Postgres Full-Text Search tsvectors with OpenAI/OpenRouter vector embeddings.",
metrics: [
{ label: "FTS Algorithm", value: "tsvector + GIN" },
{ label: "Vector Search", value: "Cosine Similarity" },
{ label: "Fusion Constant", value: "k = 60" },
],
features: [
"Sub-millisecond keyword retrieval",
"Conceptual similarity matching",
"Instant snippet generation with highlight markers",
],
docLink: "/search",
},
{
id: "postgres-db",
title: "PostgreSQL 16 + pgvector",
badge: "Primary Storage",
type: "storage",
icon: Database,
desc: "ACID-compliant relational database storing document bodies, revision trees, vector embeddings, and full-text indexes.",
metrics: [
{ label: "Extension", value: "vector (1536 dim)" },
{ label: "Backups", value: "Auto .md to disk" },
{ label: "Isolation", value: "Transactional" },
],
features: [
"HNSW vector index for embeddings",
"Complete revision history preservation",
"Instant document schema migrations",
],
docLink: "/docs",
},
];
export default function InteractiveTopology() {
const [activeNodeId, setActiveNodeId] = useState<string>("core-engine");
const activeNode = NODES.find((n) => n.id === activeNodeId) || NODES[0];
const ActiveIcon = activeNode.icon;
return (
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl overflow-hidden shadow-sm">
{/* Header Bar */}
<div className="p-5 sm:p-6 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]/50 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[11px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
Interactive System Topology
</span>
</div>
<h3 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">
How MCPedia Unifies Humans & AI Agents
</h3>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-2.5 py-1 rounded-md">
Click any node below to inspect
</span>
</div>
</div>
{/* Grid Layout: Visual Interactive Map & Inspector */}
<div className="grid grid-cols-1 lg:grid-cols-12">
{/* Visual Map Column (Left) */}
<div className="lg:col-span-7 p-6 border-b lg:border-b-0 lg:border-r border-[var(--border-color)] bg-[var(--bg-app)]/50 flex flex-col justify-between space-y-6">
{/* Layer 1: Clients */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-dim)] mb-2 block">
1. Ingestion & Access Layer
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{NODES.filter((n) => n.type === "client").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--brand)]/15 border-[var(--brand)] shadow-md shadow-[var(--brand)]/10 ring-1 ring-[var(--brand)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--brand)]/50 hover:bg-[var(--bg-elevated)]"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className={`p-2 rounded-lg ${isSelected ? "bg-[var(--brand)] text-white" : "bg-[var(--bg-elevated)] text-[var(--text-secondary)]"}`}>
<Icon className="w-4 h-4" />
</div>
<span className="text-[10px] font-mono px-2 py-0.5 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
{node.badge}
</span>
</div>
<div className="font-semibold text-sm text-[var(--text-primary)]">{node.title}</div>
</button>
);
})}
</div>
</div>
{/* Flow Connector Arrow */}
<div className="flex justify-center text-[var(--text-dim)]">
<span className="text-xs font-mono px-3 py-0.5 rounded-full bg-[var(--bg-surface)] border border-[var(--border-color)]">
↓ Secure Protocol & Gateway
</span>
</div>
{/* Layer 2: Protocol & Core Services */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-dim)] mb-2 block">
2. Gateway & Intelligence Core
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{NODES.filter((n) => n.type === "protocol" || n.type === "core").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--brand)]/15 border-[var(--brand)] shadow-md shadow-[var(--brand)]/10 ring-1 ring-[var(--brand)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--brand)]/50 hover:bg-[var(--bg-elevated)]"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className={`p-2 rounded-lg ${isSelected ? "bg-[var(--brand)] text-white" : "bg-[var(--bg-elevated)] text-[var(--text-secondary)]"}`}>
<Icon className="w-4 h-4" />
</div>
</div>
<div className="font-semibold text-xs text-[var(--text-primary)] leading-tight mb-1 truncate">
{node.title}
</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono truncate">
{node.badge}
</div>
</button>
);
})}
</div>
</div>
{/* Flow Connector Arrow */}
<div className="flex justify-center text-[var(--text-dim)]">
<span className="text-xs font-mono px-3 py-0.5 rounded-full bg-[var(--bg-surface)] border border-[var(--border-color)]">
↓ Persistent Layer (ACID + Vectors)
</span>
</div>
{/* Layer 3: Database & Vector Storage */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-dim)] mb-2 block">
3. Source of Truth Storage
</span>
{NODES.filter((n) => n.type === "storage").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`w-full p-3.5 rounded-xl border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--brand)]/15 border-[var(--brand)] shadow-md shadow-[var(--brand)]/10 ring-1 ring-[var(--brand)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--brand)]/50 hover:bg-[var(--bg-elevated)]"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isSelected ? "bg-[var(--brand)] text-white" : "bg-[var(--bg-elevated)] text-[var(--text-secondary)]"}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="font-semibold text-sm text-[var(--text-primary)]">{node.title}</div>
<div className="text-xs text-[var(--text-muted)]">Source of truth for all content & embeddings</div>
</div>
</div>
<span className="text-[10px] font-mono px-2 py-0.5 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
{node.badge}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Node Inspector Details Column (Right) */}
<div className="lg:col-span-5 p-6 bg-[var(--bg-surface)] flex flex-col justify-between space-y-6 animate-fade-in">
<div>
{/* Inspector Header */}
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-[var(--brand)]/15 border border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)] flex items-center justify-center">
<ActiveIcon className="w-5 h-5" />
</div>
<div>
<h4 className="text-lg font-bold text-[var(--text-primary)]">{activeNode.title}</h4>
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)]">
{activeNode.badge}
</span>
</div>
</div>
</div>
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed mb-6">
{activeNode.desc}
</p>
{/* Telemetry Metrics */}
<div className="mb-6">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-dim)] font-mono mb-2.5 block">
Telemetry & Architecture
</span>
<div className="space-y-2">
{activeNode.metrics.map((m) => (
<div
key={m.label}
className="flex items-center justify-between p-2.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg text-xs"
>
<span className="text-[var(--text-muted)]">{m.label}</span>
<span className="font-mono font-medium text-[var(--text-primary)]">{m.value}</span>
</div>
))}
</div>
</div>
{/* Key Capabilities */}
<div>
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-dim)] font-mono mb-2.5 block">
Key Capabilities
</span>
<ul className="space-y-2">
{activeNode.features.map((feat, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-[var(--text-secondary)]">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 shrink-0 mt-0.5" />
<span>{feat}</span>
</li>
))}
</ul>
</div>
</div>
{/* Link to Documentation */}
{activeNode.docLink && (
<div className="pt-4 border-t border-[var(--border-color)]">
<Link
href={activeNode.docLink}
className="inline-flex items-center justify-center gap-2 w-full py-2.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-xs font-semibold text-[var(--text-primary)] rounded-xl transition-all shadow-xs group"
>
<span>Read technical writeup in {activeNode.title}</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
)}
</div>
</div>
</div>
);
}
+60 -15
View File
@@ -5,6 +5,15 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeHighlight from "rehype-highlight";
import {
Copy,
Check,
Info,
Lightbulb,
AlertTriangle,
Flame,
ShieldAlert,
} from "lucide-react";
function extractText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") {
@@ -42,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
}
return (
<div className="code-block-wrapper relative group my-5 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-xs print:my-3 print:rounded-md print:border-slate-300 print:bg-slate-50">
<div className="code-block-wrapper relative group my-6 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-sm print:my-3 print:rounded-md print:border-slate-300 print:bg-slate-50">
{/* Code block header bar */}
<div className="code-block-header flex items-center justify-between px-4 py-2 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none print:px-3 print:py-1 print:bg-slate-100 print:border-slate-300">
<div className="code-block-header flex items-center justify-between px-4 py-2.5 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none print:px-3 print:py-1 print:bg-slate-100 print:border-slate-300">
<div className="flex items-center gap-2.5">
<div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
<span className="w-2.5 h-2.5 rounded-full bg-rose-400/80 dark:bg-rose-500/60 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-400/80 dark:bg-amber-500/60 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400/80 dark:bg-emerald-500/60 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
</div>
<span className="text-[11px] font-medium uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-secondary)] transition-colors">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
{language}
</span>
</div>
@@ -64,23 +73,19 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
>
{copied ? (
<>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-medium">Copied!</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
</div>
<pre className="!m-0 !bg-transparent p-4 print:p-3 overflow-x-auto text-[13.5px] print:text-[8.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed" {...props}>
<pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-3 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[8.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed" {...props}>
{children}
</pre>
</div>
@@ -91,7 +96,7 @@ export default function Markdown({ source }: { source: string }) {
return (
<div
className="prose max-w-none
prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:tracking-tight
prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)]
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)]
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6
@@ -130,8 +135,48 @@ export default function Markdown({ source }: { source: string }) {
</div>
),
blockquote: ({ children }) => {
const rawText = extractText(children).trim();
const alertMatch = rawText.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
if (alertMatch) {
const alertType = alertMatch[1].toUpperCase();
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200";
let Icon = Info;
let title = "Note";
if (alertType === "TIP") {
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200";
Icon = Lightbulb;
title = "Tip";
} else if (alertType === "IMPORTANT") {
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200";
Icon = Flame;
title = "Important";
} else if (alertType === "WARNING") {
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200";
Icon = AlertTriangle;
title = "Warning";
} else if (alertType === "CAUTION") {
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200";
Icon = ShieldAlert;
title = "Caution";
}
return (
<div className={`my-5 p-4 rounded-xl border-l-4 ${borderClass} shadow-xs`}>
<div className="flex items-center gap-2 font-bold text-xs uppercase tracking-wider mb-1.5">
<Icon className="w-4 h-4" />
<span>{title}</span>
</div>
<div className="text-xs sm:text-sm text-[var(--text-secondary)] [&>p]:mb-1 [&>p:last-child]:mb-0">
{children}
</div>
</div>
);
}
return (
<blockquote className="border-l-4 border-[var(--brand)] bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 pl-4 py-2.5 my-5 rounded-r-lg text-[var(--text-secondary)] not-italic">
<blockquote className="border-l-4 border-[var(--brand)] bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 pl-4 py-2.5 my-5 rounded-r-xl text-[var(--text-secondary)] not-italic">
{children}
</blockquote>
);
+105 -37
View File
@@ -2,11 +2,23 @@
import { useState } from "react";
import Markdown from "@/components/Markdown";
import {
Sparkles,
Terminal,
Server,
Code2,
Copy,
Check,
CheckCircle2,
Cpu,
Laptop,
} from "lucide-react";
const CONFIGS = [
{
id: "claude",
label: "Claude Desktop",
icon: Sparkles,
lang: "json",
filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
content: `{
@@ -15,7 +27,7 @@ const CONFIGS = [
"command": "bun",
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://...",
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia",
"OPENROUTER_API_KEY": "sk-or-v1-..."
}
}
@@ -23,10 +35,29 @@ const CONFIGS = [
}`,
},
{
id: "http",
label: "Remote HTTP (SSE)",
id: "cursor",
label: "Cursor AI",
icon: Laptop,
lang: "json",
filename: "Streamable HTTP Endpoint",
filename: ".cursor/mcp.json",
content: `{
"mcpServers": {
"mcpedia": {
"command": "bun",
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia"
}
}
}
}`,
},
{
id: "http",
label: "Streamable HTTP Endpoint",
icon: Server,
lang: "json",
filename: "Streamable HTTP (SSE) Client Config",
content: `{
"mcpServers": {
"mcpedia-remote": {
@@ -40,19 +71,21 @@ const CONFIGS = [
},
{
id: "cli",
label: "CLI Client",
label: "CLI & Scripts",
icon: Terminal,
lang: "bash",
filename: "Terminal One-Shot / REPL",
content: `# One-shot question
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work?"
filename: "Terminal / MCP Client Subprocess",
content: `# Ask questions over semantic knowledge
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work in x86_64?"
# Interactive REPL
# Run interactive REPL with native tool calls
bun run apps/mcp-client/src/index.ts`,
},
];
export default function McpConfigSnippet() {
const [activeTab, setActiveTab] = useState("claude");
const [copied, setCopied] = useState(false);
const current = CONFIGS.find((c) => c.id === activeTab) || CONFIGS[0];
@@ -60,45 +93,80 @@ export default function McpConfigSnippet() {
${current.content}
\`\`\``;
function handleCopyAll() {
navigator.clipboard.writeText(current.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-sm">
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl overflow-hidden shadow-sm">
{/* Header bar with tabs */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
<div className="flex items-center gap-1.5">
{CONFIGS.map((c) => (
<button
key={c.id}
onClick={() => setActiveTab(c.id)}
className={`px-3 py-1.5 text-xs rounded-lg font-medium transition-all cursor-pointer ${
activeTab === c.id
? "bg-[var(--brand)] text-white shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
}`}
>
{c.label}
</button>
))}
<div className="flex items-center justify-between px-4 sm:px-6 py-3 bg-[var(--bg-elevated)]/60 border-b border-[var(--border-color)] flex-wrap gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
{CONFIGS.map((c) => {
const Icon = c.icon;
const isSelected = activeTab === c.id;
return (
<button
key={c.id}
onClick={() => setActiveTab(c.id)}
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-xl font-semibold transition-all cursor-pointer ${
isSelected
? "bg-[var(--brand)] text-white shadow-md shadow-[var(--brand)]/20"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{c.label}</span>
</button>
);
})}
</div>
<div className="text-[11px] text-[var(--text-dim)] font-mono hidden sm:block">
{current.filename}
</div>
<button
onClick={handleCopyAll}
type="button"
className="flex items-center gap-1.5 px-3 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer font-medium"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500">Copied Config</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy Config</span>
</>
)}
</button>
</div>
{/* Code preview via Markdown syntax highlighter */}
<div className="p-4 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
<div className="p-4 sm:p-5 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
<Markdown source={markdownSnippet} />
</div>
<div className="px-4 py-2 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-[11px] text-[var(--text-dim)] flex items-center justify-between flex-wrap gap-2">
<span>
Config target: <span className="font-mono text-[var(--text-secondary)]">{current.filename}</span>
</span>
<span className="text-[10px] text-[var(--text-muted)]">
Format: <span className="uppercase font-mono font-semibold">{current.lang}</span>
</span>
{/* Footer Info */}
<div className="px-4 sm:px-6 py-3 bg-[var(--bg-elevated)]/40 border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-mono uppercase text-[10px] text-[var(--text-muted)] font-semibold">
Target File:
</span>
<span className="font-mono text-[var(--text-secondary)] text-[11px]">
{current.filename}
</span>
</div>
<div className="flex items-center gap-3 text-[11px]">
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400 font-medium">
<CheckCircle2 className="w-3.5 h-3.5" />
<span>13 MCP Tools Ready</span>
</span>
</div>
</div>
</div>
);
}
+50 -49
View File
@@ -14,6 +14,16 @@ import {
type ExportSortOption,
} from "@mcpedia/core/export-helpers";
import { getSectionMeta } from "@mcpedia/config/sections";
import {
Printer,
FileDown,
Copy,
Check,
ArrowLeft,
Filter,
ListOrdered,
BookOpen,
} from "lucide-react";
interface PdfExportViewProps {
exportData: ExportData;
@@ -113,23 +123,21 @@ export default function PdfExportView({
return (
<div className="pb-16 print:pb-0">
{/* =========================================================================
Export Control Toolbar (Screen Only — Hidden in Print)
========================================================================= */}
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 shadow-sm">
{/* Export Control Toolbar (Screen Only — Hidden in Print) */}
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl p-5 shadow-md">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
{/* Left info & Back button */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3.5">
<Link
href={backHref}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all shadow-xs"
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all shadow-xs"
>
<span>←</span>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back</span>
</Link>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--text-primary)] font-semibold uppercase">
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] font-bold uppercase">
{sectionMeta.label}
</span>
<span className="text-xs text-[var(--text-dim)]">·</span>
@@ -150,13 +158,19 @@ export default function PdfExportView({
<button
onClick={handleCopyMarkdown}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer"
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-xl transition-all shadow-xs cursor-pointer"
title="Copy compiled markdown document"
>
{copiedMd ? (
<span className="text-emerald-600 font-bold">✓ Copied</span>
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-bold">Copied</span>
</>
) : (
<span>Copy Markdown</span>
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy Markdown</span>
</>
)}
</button>
@@ -164,9 +178,10 @@ export default function PdfExportView({
<button
onClick={handleDownloadMarkdown}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer"
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-xl transition-all shadow-xs cursor-pointer"
title="Download compiled .md file"
>
<FileDown className="w-3.5 h-3.5" />
<span>Download .md</span>
</button>
@@ -174,22 +189,10 @@ export default function PdfExportView({
<button
onClick={handlePrint}
type="button"
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold bg-slate-900 hover:bg-slate-800 text-white rounded-md shadow-sm transition-all cursor-pointer hover:shadow"
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-bold bg-slate-900 hover:bg-slate-800 dark:bg-white dark:hover:bg-slate-100 text-white dark:text-slate-900 rounded-xl shadow-md transition-all cursor-pointer hover:shadow-lg"
title="Open browser print dialog to save as PDF"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
<Printer className="w-4 h-4" />
<span>Print / Save as PDF</span>
</button>
</div>
@@ -199,11 +202,11 @@ export default function PdfExportView({
<div className="mt-4 pt-3.5 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
{/* Sort Order Selector */}
<div className="flex items-center gap-2">
<span className="text-[var(--text-muted)] font-medium">Sort by:</span>
<span className="text-[var(--text-muted)] font-medium font-mono text-[11px] uppercase">Sort by:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as ExportSortOption)}
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-md px-2.5 py-1 text-xs text-[var(--text-primary)] focus:outline-none focus:border-slate-500"
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg px-3 py-1 text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--brand)]"
>
<option value="category_points">Category & Points (Standard)</option>
<option value="points_desc">Points (Highest First)</option>
@@ -217,23 +220,23 @@ export default function PdfExportView({
{/* Section toggles */}
<div className="flex items-center gap-3 flex-wrap text-[var(--text-secondary)]">
{cleanDocs.length > 1 && (
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={showTOC}
onChange={(e) => setShowTOC(e.target.checked)}
className="rounded border-[var(--border-color)] text-slate-800"
className="rounded border-[var(--border-color)] text-indigo-600"
/>
<span>Table of Contents</span>
</label>
)}
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={pageBreaks}
onChange={(e) => setPageBreaks(e.target.checked)}
className="rounded border-[var(--border-color)] text-slate-800"
className="rounded border-[var(--border-color)] text-indigo-600"
/>
<span>Page Break per Challenge</span>
</label>
@@ -243,13 +246,13 @@ export default function PdfExportView({
{/* Category filter pills */}
{allCategories.length > 1 && (
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap text-xs">
<span className="text-[var(--text-muted)] mr-1">Filter:</span>
<span className="text-[var(--text-muted)] font-mono text-[11px] uppercase mr-1">Filter:</span>
<button
onClick={() => setSelectedCategory("all")}
type="button"
className={`px-2.5 py-0.5 rounded-md text-xs font-mono transition-all cursor-pointer ${
className={`px-3 py-1 rounded-lg text-xs font-mono transition-all cursor-pointer ${
selectedCategory === "all"
? "bg-slate-900 text-white font-semibold"
? "bg-slate-900 dark:bg-white text-white dark:text-slate-900 font-bold"
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
}`}
>
@@ -264,9 +267,9 @@ export default function PdfExportView({
key={cat}
onClick={() => setSelectedCategory(cat)}
type="button"
className={`px-2.5 py-0.5 rounded-md text-xs font-mono transition-all cursor-pointer ${
className={`px-3 py-1 rounded-lg text-xs font-mono transition-all cursor-pointer ${
selectedCategory.toLowerCase() === cat.toLowerCase()
? "bg-slate-900 text-white font-semibold"
? "bg-slate-900 dark:bg-white text-white dark:text-slate-900 font-bold"
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
}`}
>
@@ -278,14 +281,11 @@ export default function PdfExportView({
)}
</aside>
{/* =========================================================================
Clean, Simple, Human-Written Writeup Document Flow (No Cards)
========================================================================= */}
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:max-w-none">
{/* Writeup Document Flow */}
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:max-w-none rounded-2xl border border-[var(--border-color)] print:border-none shadow-sm print:shadow-none">
{/* Document Header */}
<header className="mb-8 pb-3 border-b border-slate-300">
<h1 className="text-3xl sm:text-4xl font-bold text-slate-950 tracking-tight mb-2">
<header className="mb-8 pb-4 border-b border-slate-300">
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-950 tracking-tight mb-2">
{summary.title}
</h1>
<div className="text-xs text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1">
@@ -297,11 +297,12 @@ export default function PdfExportView({
</div>
</header>
{/* Table of Contents (Simple & Clean) */}
{/* Table of Contents */}
{showTOC && filteredAndSortedDocs.length > 1 && (
<section className="toc-section mb-10 pb-6 border-b border-slate-200">
<h2 className="text-sm font-bold uppercase font-mono text-slate-700 mb-3 tracking-wider">
Table of Contents
<h2 className="text-sm font-bold uppercase font-mono text-slate-700 mb-3 tracking-wider flex items-center gap-1.5">
<BookOpen className="w-4 h-4" />
<span>Table of Contents</span>
</h2>
<ol className="list-decimal list-inside space-y-1.5 text-xs text-slate-800">
{filteredAndSortedDocs.map((doc, idx) => {
@@ -326,7 +327,7 @@ export default function PdfExportView({
</section>
)}
{/* Challenges / Writeups Flow ("Mirip Writeup Manusia") */}
{/* Challenges / Writeups Flow */}
<div className="challenges-flow space-y-10 print:space-y-0">
{filteredAndSortedDocs.map((doc, idx) => {
const chNum = idx + 1;
@@ -353,7 +354,7 @@ export default function PdfExportView({
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
{diff && <span><strong>Difficulty:</strong> {diff}</span>}
{/* Extra fields (excluding author, slug, scope, etc.) */}
{/* Extra fields */}
{Object.entries(extra).map(([k, v]) => {
const lowerKey = k.toLowerCase();
if (
+53 -39
View File
@@ -4,6 +4,16 @@ import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { getSectionMeta } from "@mcpedia/config/sections";
import {
Folder,
FolderOpen,
FileText,
ChevronRight,
Search,
X,
Plus,
Compass,
} from "lucide-react";
interface Doc {
slug: string;
@@ -102,11 +112,11 @@ function TreeNodeItem({
const isCollapsed = collapsedMap[node.slug] ?? false;
return (
<li className={`select-none ${depth > 0 ? "ml-3 pl-2 border-l border-[var(--border-color)]" : ""}`}>
<li className={`select-none ${depth > 0 ? "ml-3.5 pl-2 border-l border-[var(--border-color)]" : ""}`}>
<div
className={`group flex items-center justify-between gap-1.5 text-xs py-1.5 px-2 rounded-md transition-all ${
className={`group flex items-center justify-between gap-1 text-xs py-1.5 px-2 rounded-lg transition-all ${
isActive
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-semibold border border-[var(--brand)]/30 shadow-sm"
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-semibold border border-[var(--brand)]/30 shadow-xs"
: isParentOfActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
@@ -117,15 +127,23 @@ function TreeNodeItem({
className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
title={node.title || node.name}
>
<span className="text-xs shrink-0 opacity-80 group-hover:opacity-100">
{isFolder ? "📁" : "📄"}
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors">
{isFolder ? (
isCollapsed ? (
<Folder className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
)
) : (
<FileText className="w-3.5 h-3.5" />
)}
</span>
<span className="truncate">{node.title || node.name}</span>
</Link>
{isFolder && (
<div className="flex items-center gap-1 shrink-0">
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1 py-0.2 rounded font-mono">
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
{node.docCount}
</span>
<button
@@ -135,24 +153,19 @@ function TreeNodeItem({
e.stopPropagation();
toggleCollapse(node.slug);
}}
className="p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform"
className="p-1 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform"
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
>
<svg
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : "rotate-0"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<ChevronRight
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "rotate-0" : "rotate-90"}`}
/>
</button>
</div>
)}
</div>
{isFolder && !isCollapsed && (
<ul className="mt-0.5 space-y-0.5 animate-fade-in">
<ul className="mt-1 space-y-0.5 animate-fade-in">
{node.children.map((child) => (
<TreeNodeItem
key={child.slug}
@@ -212,7 +225,7 @@ export default function Sidebar() {
}, [filteredDocs]);
return (
<nav className="h-full flex flex-col py-4 px-3">
<nav className="h-full flex flex-col py-4 px-3 select-none">
{/* Sidebar search filter */}
<div className="mb-3">
<div className="relative">
@@ -220,31 +233,25 @@ export default function Sidebar() {
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Filter docs..."
className="w-full pl-7 pr-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] transition-colors"
placeholder="Filter knowledge tree..."
className="w-full pl-8 pr-7 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[var(--brand)] transition-all"
/>
<svg
className="w-3.5 h-3.5 text-[var(--text-muted)] absolute left-2 top-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
{filterText && (
<button
onClick={() => setFilterText("")}
className="absolute right-2 top-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated)]"
aria-label="Clear filter"
>
✕
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Summary count */}
<div className="flex items-center justify-between px-1 mb-3 text-[11px] text-[var(--text-dim)]">
<span className="uppercase tracking-wider font-mono">Knowledge Base</span>
<div className="flex items-center justify-between px-1 mb-2 text-[10px] text-[var(--text-dim)] font-mono uppercase tracking-wider">
<span>Knowledge Index</span>
<span>{filteredDocs.length} items</span>
</div>
@@ -261,17 +268,17 @@ export default function Sidebar() {
<div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
<Link
href={`/${id}`}
className={`flex items-center justify-between gap-1.5 px-1 py-1 mb-1 rounded text-xs font-semibold uppercase tracking-wider transition-colors ${
className={`flex items-center justify-between gap-1.5 px-2 py-1.5 mb-1 rounded-lg text-xs font-semibold uppercase tracking-wider transition-colors ${
isSectionActive
? "text-[var(--brand)] dark:text-[var(--accent)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`}
>
<div className="flex items-center gap-1.5">
<span>{icon}</span>
<div className="flex items-center gap-2">
<span className="text-xs">{icon}</span>
<span>{label}</span>
</div>
<span className="text-[10px] text-[var(--text-dim)] font-normal font-mono">
<span className="text-[10px] text-[var(--text-dim)] font-normal font-mono bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]">
{sectionDocs.length}
</span>
</Link>
@@ -291,15 +298,22 @@ export default function Sidebar() {
</div>
);
})}
{filteredDocs.length === 0 && (
<div className="text-center py-8 text-xs text-[var(--text-dim)]">
No matching documents
</div>
)}
</div>
{/* Bottom quick links */}
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
<Link
href="/create"
className="flex items-center justify-center gap-1.5 w-full py-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors font-medium"
className="flex items-center justify-center gap-2 w-full py-2 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg transition-all font-medium shadow-xs group"
>
<span>+ Create New Document</span>
<Plus className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)] group-hover:scale-110 transition-transform" />
<span>Create New Document</span>
</Link>
</div>
</nav>
+22 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import GithubSlugger from "github-slugger";
import { AlignLeft, ArrowUp, Hash } from "lucide-react";
interface TOCEntry {
id: string;
@@ -58,18 +59,29 @@ export default function TOC({ source }: { source: string }) {
if (toc.length === 0) return null;
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<nav className="text-xs">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
<svg className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
</svg>
<span className="font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px]">
On this page
</span>
<nav className="text-xs space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-[var(--border-color)]">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px] font-mono">
<AlignLeft className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
<span>On This Page</span>
</div>
<button
onClick={scrollToTop}
type="button"
className="text-[10px] text-[var(--text-dim)] hover:text-[var(--text-primary)] flex items-center gap-0.5 hover:underline"
title="Scroll to top"
>
<ArrowUp className="w-3 h-3" />
<span>Top</span>
</button>
</div>
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-2.5">
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-3">
{toc.map((entry) => {
const isActive = activeId === entry.id;
return (
@@ -90,7 +102,7 @@ export default function TOC({ source }: { source: string }) {
history.pushState(null, "", `#${entry.id}`);
}
}}
className={`block py-0.5 transition-colors line-clamp-1 ${
className={`block py-0.5 transition-all line-clamp-1 ${
isActive
? "text-[var(--brand)] dark:text-[var(--accent)] font-semibold translate-x-0.5"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+5 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Moon } from "lucide-react";
const STORAGE_KEY = "mcpedia-theme";
@@ -36,7 +37,7 @@ export default function ThemeToggle() {
if (!mounted) {
return (
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-50" />
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-40" />
);
}
@@ -46,16 +47,12 @@ export default function ThemeToggle() {
type="button"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center"
className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center cursor-pointer"
>
{isDark ? (
<svg className="w-4 h-4 text-amber-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
) : (
<svg className="w-4 h-4 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<Moon className="w-4 h-4 text-indigo-600 hover:-rotate-12 transition-transform" />
)}
</button>
);