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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user