feat: revamp to dynamic database-first architecture and cleanup phase docs
CI / typecheck + build (turbo) (push) Canceled after 0s
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:
+13
-10
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
}}
|
||||
|
||||
@@ -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 "{section}" 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 (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user