feat: revamp to dynamic database-first architecture and cleanup phase docs
CI / typecheck + build (turbo) (push) Canceled after 0s

- Migrate document fetching and CRUD to be PostgreSQL-authoritative
- Remove static section enums and add dynamic listSections query
- Support custom sections and metadata across API, MCP, and Web UI
- Add /api/sections endpoint and update Header, Sidebar, and Forms
- Remove obsolete phase planning docs and modernize README/AGENTS
This commit is contained in:
asepharyana
2026-08-21 11:35:42 +07:00
parent a937e8f51b
commit a9b67385c3
37 changed files with 720 additions and 2515 deletions
+13 -10
View File
@@ -6,6 +6,7 @@ import {
hybridSearch,
keywordSearch,
listDocuments,
listSections,
semanticSearch,
listRevisions,
getRevision,
@@ -17,10 +18,9 @@ import {
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
// restoreRevision is a state-changing action (it rewrites the live document row
// + rebuilds its chunks). It must NOT be callable anonymously over the network —
// only the Web UI (which calls @mcpedia/core directly) and an operator with the
// webhook secret may use it. Anything else is rejected.
// restoreRevision and CRUD operations are state-changing actions.
// They must NOT be callable anonymously over the network —
// only an operator with the webhook secret (or authorized UI) may execute them.
const requireWriteAuth = t.middleware(({ ctx, next }) => {
if (!ctx.expectedSecret) {
throw new Error("WEBHOOK_SECRET is not configured; writes are disabled");
@@ -52,11 +52,13 @@ export const appRouter = router({
.input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional())
.query(async ({ input }) => listDocuments(input ?? {})),
sections: publicProcedure.query(async () => listSections()),
related: publicProcedure
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) }))
.query(async ({ input }) => getRelated(input.slug, input.limit)),
// --- Phase 3: revisions ---
// --- Revisions ---
revisions: publicProcedure
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
.query(async ({ input }) => listRevisions(input.slug, input.limit)),
@@ -70,16 +72,16 @@ export const appRouter = router({
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => restoreRevision(input.id)),
// --- Phase 11: CRUD (gated by x-webhook-secret / admin auth) ---
// --- CRUD (gated by x-webhook-secret / admin auth) ---
createDocument: publicProcedure
.use(requireWriteAuth)
.input(
z.object({
slug: z.string().min(1),
title: z.string().min(1),
section: z.enum(["docs", "writeups", "research", "notes"]),
section: z.string().min(1),
body: z.string(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
type: z.string().optional(),
status: z.enum(["published", "draft"]).optional(),
author: z.string().optional(),
tags: z.array(z.string()).optional(),
@@ -95,7 +97,8 @@ export const appRouter = router({
slug: z.string().min(1),
title: z.string().min(1).optional(),
body: z.string().optional(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
section: z.string().min(1).optional(),
type: z.string().optional(),
status: z.enum(["published", "draft"]).optional(),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
@@ -112,7 +115,7 @@ export const appRouter = router({
.input(z.object({ slug: z.string().min(1) }))
.mutation(async ({ input }) => deleteDocument(input.slug)),
// --- Phase 3: async job status ---
// --- Async job status ---
jobStatus: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
+2
View File
@@ -36,6 +36,7 @@ mock.module("@mcpedia/core", () => ({
keywordSearch: () => Promise.resolve([]),
getDocument: () => Promise.resolve(null),
listDocuments: () => Promise.resolve([]),
listSections: () => Promise.resolve([]),
getRelated: () => Promise.resolve([]),
semanticSearch: () => Promise.resolve([]),
hybridSearch: () => Promise.resolve([]),
@@ -209,6 +210,7 @@ test("tool discovery works without auth (read tools present)", async () => {
expect(names).toContain("search_documents");
expect(names).toContain("get_document");
expect(names).toContain("list_documents");
expect(names).toContain("list_sections");
expect(names).toContain("semantic_search");
expect(names).toContain("hybrid_search");
expect(names).toContain("get_related_documents");
+28 -13
View File
@@ -4,6 +4,7 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
listDocuments,
listSections,
getDocument,
getRelated,
semanticSearch,
@@ -75,14 +76,26 @@ export function createMcpServer(authSecret?: string): McpServer {
},
);
server.registerTool(
"list_sections",
{
description: "List all active knowledge base sections and document counts from PostgreSQL.",
inputSchema: z.object({}),
},
async () => {
const sections = await listSections();
return {
content: [{ type: "text", text: JSON.stringify(sections, null, 2) }],
};
},
);
server.registerTool(
"list_documents",
{
description: "List documents, optionally filtered by section.",
inputSchema: z.object({
section: z
.enum(["docs", "writeups", "research", "notes"])
.optional(),
section: z.string().optional().describe("Section name to filter by (e.g. 'docs', 'writeups', 'ctf')"),
}),
},
async ({ section }) => {
@@ -146,7 +159,7 @@ export function createMcpServer(authSecret?: string): McpServer {
},
);
// --- Phase 7: mutating + admin tools (require x-webhook-secret) ---
// --- Mutating & admin tools (require x-webhook-secret) ---
server.registerTool(
"index_document",
{
@@ -198,18 +211,18 @@ export function createMcpServer(authSecret?: string): McpServer {
},
);
// --- Phase 11: CRUD write tools (require x-webhook-secret) ---
// --- CRUD write tools (require x-webhook-secret) ---
server.registerTool(
"create_document",
{
description:
"Create a new document (writes markdown file + DB row + revision + chunks). Requires the x-webhook-secret header.",
"Create a new document in PostgreSQL (writes DB row, revision, chunks, and disk backup). Requires the x-webhook-secret header.",
inputSchema: z.object({
slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc')"),
slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc' or 'ctf/challenge-1')"),
title: z.string().describe("Document title"),
section: z.enum(["docs", "writeups", "research", "notes"]).describe("Content section"),
section: z.string().describe("Content section (e.g. 'docs', 'writeups', 'research', 'notes', 'guides', 'ctf')"),
body: z.string().describe("Markdown body"),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
type: z.string().optional().describe("Document type (e.g. 'documentation', 'writeup', 'research', 'note')"),
status: z.enum(["published", "draft"]).optional(),
author: z.string().optional(),
tags: z.array(z.string()).optional(),
@@ -239,23 +252,25 @@ export function createMcpServer(authSecret?: string): McpServer {
"update_document",
{
description:
"Update an existing document (title, body, tags, status, etc.). Requires the x-webhook-secret header.",
"Update an existing document in PostgreSQL (title, body, section, tags, status, etc.). Requires the x-webhook-secret header.",
inputSchema: z.object({
slug: z.string().describe("Document slug to update"),
title: z.string().optional(),
body: z.string().optional(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
section: z.string().optional(),
type: z.string().optional(),
status: z.enum(["published", "draft"]).optional(),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
extraFields: z.record(z.string(), z.unknown()).optional().describe("Dynamic custom metadata key-value pairs"),
}),
},
async ({ slug, title, body, type, status, tags, author, extraFields }) => {
async ({ slug, title, body, section, type, status, tags, author, extraFields }) => {
requireMcpAuth(authSecret);
const doc = await updateDocument(slug, {
title,
body,
section,
type,
status,
tags,
@@ -316,7 +331,7 @@ export function createMcpServer(authSecret?: string): McpServer {
},
);
// --- Phase 3: MCP Resources (read-only knowledge base surfaced via URIs) ---
// --- MCP Resources (read-only knowledge base surfaced via URIs) ---
// mcpedia://docs -> list all published documents
// mcpedia://docs/{slug} -> full markdown body (from disk)
// mcpedia://docs/{slug}/chunks -> chunked preview (semantic slices)
+8 -17
View File
@@ -8,7 +8,7 @@ import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC";
import DocActions from "@/components/DocActions";
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
import { SECTIONS_BY_ID } from "@mcpedia/config/sections";
import { getSectionMeta } from "@mcpedia/config";
import type { DocumentMeta } from "@mcpedia/core";
export const dynamic = "force-dynamic";
@@ -19,17 +19,7 @@ interface DocPageProps {
}
function getSectionInfo(section: string) {
const info = SECTIONS_BY_ID.get(section);
if (!info) {
return {
label: section.charAt(0).toUpperCase() + section.slice(1),
icon: "📄",
};
}
return {
label: info.label,
icon: info.icon,
};
return getSectionMeta(section);
}
function calculateReadingTime(text: string): string {
@@ -128,9 +118,9 @@ function FolderIndexPage({
}) {
const prefix = `${section}/${slug}/`;
const folderDocPaths = allDocs
.filter((d) => d.path.startsWith(prefix))
.filter((d) => d.slug.startsWith(prefix))
.map((d) => ({
rel: d.path.slice(prefix.length).replace(/\.md$/, ""),
rel: d.slug.slice(prefix.length),
doc: d,
}))
.sort((a, b) => a.rel.localeCompare(b.rel));
@@ -292,8 +282,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const fullSlug = `${section}/${slug.join("/")}`;
const allDocs = await listDocuments();
const docPaths = allDocs.map((d) => d.path);
const classification = classifyPath(docPaths, fullSlug);
const docSlugs = allDocs.map((d) => d.slug);
const classification = classifyPath(docSlugs, fullSlug);
if (classification === "folder") {
return <FolderIndexPage section={section} slug={slug.join("/")} allDocs={allDocs} />;
@@ -312,7 +302,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
]);
if (edit === "1" && canEdit) {
const existingFolders = extractFoldersForSection(docPaths, doc.section);
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
return (
<div>
<Link
@@ -328,6 +318,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
mode="edit"
slug={fullSlug}
secret={WEBHOOK_SECRET}
existingSections={Array.from(new Set(allDocs.map((d) => d.section)))}
existingFolders={{
[doc.section]: existingFolders,
}}
+8 -18
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import { SECTIONS } from "@mcpedia/config/sections";
import { getSectionMeta } from "@mcpedia/config";
export const dynamic = "force-dynamic";
@@ -24,12 +24,11 @@ interface TreeNode {
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
const root: TreeNode[] = [];
for (const doc of docs.filter((d) => d.section === section)) {
const rel = doc.path.startsWith(`${section}/`)
? doc.path.slice(section.length + 1)
: doc.path;
const cleanPath = rel.replace(/\.md$/, "");
const parts = cleanPath.split("/");
for (const doc of docs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase())) {
const rel = doc.slug.startsWith(`${section}/`)
? doc.slug.slice(section.length + 1)
: doc.slug;
const parts = rel.split("/");
let current = root;
let currentPath = section;
@@ -118,19 +117,10 @@ export default async function SectionIndexPage({
params: Promise<{ section: string }>;
}) {
const { section } = await params;
const sectionInfo = SECTIONS.find((s) => s.id === section);
if (!sectionInfo) {
return (
<div className="py-12 text-center">
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Unknown section</h1>
<p className="text-sm text-[var(--text-muted)] mt-2">Section &quot;{section}&quot; does not exist.</p>
</div>
);
}
const sectionInfo = getSectionMeta(section);
const allDocs = await listDocuments();
const sectionDocs = allDocs.filter((d) => d.section === section);
const sectionDocs = allDocs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase());
const tree = buildFolderTree(allDocs, section);
return (
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { listSections } from "@mcpedia/core";
export const dynamic = "force-dynamic";
// GET /api/sections — list all active sections with document counts dynamically from PostgreSQL.
export async function GET() {
try {
const sections = await listSections();
return NextResponse.json(sections);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+47 -29
View File
@@ -9,20 +9,21 @@ interface DocFormProps {
slug?: string;
secret: string;
existingFolders?: Record<string, string[]>;
existingSections?: string[];
initial?: {
title?: string;
body?: string;
section?: "docs" | "writeups" | "research" | "notes";
type?: "documentation" | "writeup" | "research" | "note";
status?: "published" | "draft";
section?: string;
type?: string;
status?: "published" | "draft" | string;
tags?: string[];
author?: string;
extraFields?: Record<string, unknown>;
};
}
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] as const;
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" },
@@ -37,6 +38,7 @@ export default function DocForm({
slug,
secret,
existingFolders,
existingSections,
initial,
}: DocFormProps) {
const router = useRouter();
@@ -56,6 +58,10 @@ export default function DocForm({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sectionOptions = Array.from(
new Set([...(existingSections ?? []), ...DEFAULT_SECTION_LIST, section].filter(Boolean)),
);
// Extract existing custom fields
const [customFields, setCustomFields] = useState<Array<{ key: string; value: string }>>(() => {
if (initial?.extraFields) {
@@ -198,7 +204,7 @@ export default function DocForm({
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. DEF CON Quals 2024 — pwn-100 Writeup"
placeholder="e.g. Database Architecture Overview"
className={`${baseInputCls} text-base`}
required
/>
@@ -210,34 +216,46 @@ export default function DocForm({
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Section
</label>
<select
value={section}
onChange={(e) => setSection(e.target.value as typeof section)}
className={baseInputCls}
disabled={isEdit}
>
{SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>
{s.toUpperCase()}
</option>
))}
</select>
<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>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Type
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
className={baseInputCls}
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<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>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
+16 -4
View File
@@ -1,15 +1,27 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { SECTIONS } from "@mcpedia/config/sections";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
import ThemeToggle from "@/components/ThemeToggle";
import CommandMenu from "@/components/CommandMenu";
export default function Header() {
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
useEffect(() => {
fetch("/api/sections")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setSections(data);
}
})
.catch(() => {});
}, []);
return (
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav">
@@ -31,7 +43,7 @@ export default function Header() {
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1">
{SECTIONS.map((s) => {
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link
@@ -107,7 +119,7 @@ export default function Header() {
{/* 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) => (
{sections.map((s) => (
<Link
key={s.id}
href={`/${s.id}`}
+15 -4
View File
@@ -3,7 +3,7 @@
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { SECTIONS } from "@mcpedia/config/sections";
import { getSectionMeta } from "@mcpedia/config/sections";
interface Doc {
slug: string;
@@ -200,6 +200,17 @@ export default function Sidebar() {
);
}, [docs, filterText]);
const distinctSections = useMemo(() => {
const map = new Map<string, { id: string; label: string; icon: string }>();
for (const doc of filteredDocs) {
const s = (doc.section || "docs").toLowerCase();
if (!map.has(s)) {
map.set(s, getSectionMeta(s));
}
}
return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label));
}, [filteredDocs]);
return (
<nav className="h-full flex flex-col py-4 px-3">
{/* Sidebar search filter */}
@@ -233,14 +244,14 @@ export default function Sidebar() {
{/* 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">Documentation</span>
<span className="uppercase tracking-wider font-mono">Knowledge Base</span>
<span>{filteredDocs.length} items</span>
</div>
{/* Section Trees */}
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
{SECTIONS.map(({ id, label, icon }) => {
const sectionDocs = filteredDocs.filter((d) => d.section === id);
{distinctSections.map(({ id, label, icon }) => {
const sectionDocs = filteredDocs.filter((d) => (d.section || "docs").toLowerCase() === id);
if (sectionDocs.length === 0) return null;
const tree = buildFolderTree(filteredDocs, id);
+11 -6
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { listDocuments, extractFoldersForSection } from "@mcpedia/core";
import { SECTIONS } from "@mcpedia/config/sections";
import { listDocuments, extractFoldersForSection, listSections } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
@@ -13,8 +12,13 @@ export default async function CreatePage() {
const isAdmin = cookieStore.get("mcpedia_admin")?.value != null;
if (!isAdmin) redirect("/login");
const all = await listDocuments();
const docPaths = all.map((d) => d.path);
const [all, sections] = await Promise.all([
listDocuments(),
listSections(),
]);
const docSlugs = all.map((d) => d.slug);
const sectionIds = Array.from(new Set([...sections.map((s) => s.id), ...all.map((d) => d.section)]));
return (
<div className="space-y-6">
@@ -36,7 +40,7 @@ export default async function CreatePage() {
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Create Document</h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
Writes markdown file to disk, records revision, and computes 2048-dim vector embeddings.
Writes directly to PostgreSQL, records revision history, and computes vector embeddings.
</p>
</div>
</div>
@@ -45,8 +49,9 @@ export default async function CreatePage() {
<DocForm
mode="create"
secret={WEBHOOK_SECRET}
existingSections={sectionIds}
existingFolders={Object.fromEntries(
SECTIONS.map((s) => [s.id, extractFoldersForSection(docPaths, s.id)])
sectionIds.map((sid) => [sid, extractFoldersForSection(docSlugs, sid)])
)}
/>
</div>
+16 -15
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import { SECTIONS } from "@mcpedia/config/sections";
import { listDocuments, listSections } from "@mcpedia/core";
import { getSectionMeta, type SectionConfig } from "@mcpedia/config";
import McpConfigSnippet from "@/components/McpConfigSnippet";
export const dynamic = "force-dynamic";
@@ -116,8 +116,6 @@ function SectionTree({ section, docs, pathname }: SectionTreeProps) {
docs.filter((d) => d.slug.startsWith(`${section}/`)),
section,
);
const sectionInfo = SECTIONS.find((s) => s.id === section);
if (!sectionInfo) return null;
return (
<div>
@@ -131,7 +129,10 @@ function SectionTree({ section, docs, pathname }: SectionTreeProps) {
}
export default async function HomePage() {
const all = await listDocuments();
const [all, sections] = await Promise.all([
listDocuments(),
listSections(),
]);
const recent = [...all]
.sort(
@@ -146,7 +147,7 @@ export default async function HomePage() {
<section className="relative pt-4 pb-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#5e6ad2]/10 border border-[#5e6ad2]/25 text-[#5e6ad2] dark:text-[#7170ff] text-xs font-medium mb-6">
<span className="w-2 h-2 rounded-full bg-[#5e6ad2] animate-pulse" />
<span>Model Context Protocol + Unified Knowledge Base</span>
<span>Model Context Protocol + PostgreSQL Core</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-[var(--text-primary)] tracking-tight mb-5 leading-tight">
@@ -157,7 +158,7 @@ export default async function HomePage() {
</h1>
<p className="text-base sm:text-lg text-[var(--text-muted)] max-w-2xl leading-relaxed mb-8">
A high-performance, content-first documentation system. Browse hierarchical notes and CTF writeups in your browser, or connect AI coding assistants directly via native MCP tools.
A high-performance, database-backed documentation system. Browse hierarchical notes and technical writeups in your browser, or connect AI coding assistants directly via native MCP tools.
</p>
{/* Quick action buttons & stats */}
@@ -190,18 +191,18 @@ export default async function HomePage() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl max-w-3xl shadow-sm">
<div>
<div className="text-xl font-bold text-[var(--text-primary)]">{all.length}</div>
<div className="text-xs text-[var(--text-muted)]">Documents indexed</div>
<div className="text-xs text-[var(--text-muted)]">Documents in DB</div>
</div>
<div>
<div className="text-xl font-bold text-[var(--text-primary)]">{SECTIONS.length}</div>
<div className="text-xs text-[var(--text-muted)]">Core sections</div>
<div className="text-xl font-bold text-[var(--text-primary)]">{sections.length}</div>
<div className="text-xs text-[var(--text-muted)]">Active sections</div>
</div>
<div>
<div className="text-xl font-bold text-[#5e6ad2] dark:text-[#7170ff]">RRF Hybrid</div>
<div className="text-xs text-[var(--text-muted)]">FTS + 2048-dim vectors</div>
<div className="text-xs text-[var(--text-muted)]">FTS + Cosine Vectors</div>
</div>
<div>
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">10 MCP Tools</div>
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">13 MCP Tools</div>
<div className="text-xs text-[var(--text-muted)]">Streamable HTTP :4021</div>
</div>
</div>
@@ -216,8 +217,8 @@ export default async function HomePage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{SECTIONS.map((s) => {
const sectionDocs = all.filter((d) => d.section === s.id);
{sections.map((s) => {
const sectionDocs = all.filter((d) => (d.section || "docs").toLowerCase() === s.id);
return (
<div
key={s.id}
@@ -272,7 +273,7 @@ export default async function HomePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((d) => {
const sInfo = SECTIONS.find((s) => s.id === d.section);
const sInfo = getSectionMeta(d.section);
return (
<Link
key={d.slug}
+14 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SECTIONS } from "@mcpedia/config/sections";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
interface DocHit {
slug: string;
@@ -24,10 +24,22 @@ function SearchContent() {
const [mode, setMode] = useState<SearchMode>(
["hybrid", "keyword", "semantic"].includes(initialMode) ? initialMode : "hybrid",
);
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
const [selectedSection, setSelectedSection] = useState<string>("all");
const [results, setResults] = useState<DocHit[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/sections")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setSections(data);
}
})
.catch(() => {});
}, []);
const handleSearch = useCallback(async (q: string, searchMode: SearchMode) => {
if (!q.trim()) {
setResults([]);
@@ -154,7 +166,7 @@ function SearchContent() {
>
All Sections
</button>
{SECTIONS.map((s) => (
{sections.map((s) => (
<button
key={s.id}
type="button"