diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a433fbb..0a25718 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,6 +47,7 @@ jobs: git pull origin main /home/code/.bun/bin/bun install --frozen-lockfile /home/code/.bun/bin/bun --cwd apps/web run build + /home/code/.bun/bin/bun --cwd packages/db run db:migrate sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker sleep 3 systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend diff --git a/apps/web/app/[section]/[...slug]/page.tsx b/apps/web/app/[section]/[...slug]/page.tsx index 259d753..ee475a3 100644 --- a/apps/web/app/[section]/[...slug]/page.tsx +++ b/apps/web/app/[section]/[...slug]/page.tsx @@ -29,6 +29,71 @@ const SECTION_ICON: Record = { notes: "๐Ÿ“Œ", }; +/** + * Render any extra/custom frontmatter fields as badges. + * This makes the system dynamic โ€” the content creator decides what metadata + * to include, not the UI template. Standard fields (title, author, tags, etc.) + * are handled explicitly; any OTHER keys in frontmatter become badges here. + */ +function CustomFieldBadges({ + doc, + standardKeys, +}: { + doc: Record; + standardKeys: Set; +}) { + // Convert doc to a plain record to get index signature + const docRecord: Record = { ...doc } as Record; + const customEntries = Object.entries(docRecord).filter( + ([k, v]) => !standardKeys.has(k) && v !== undefined && v !== null && v !== "" && !k.startsWith("_"), + ); + + if (customEntries.length === 0) return null; + + return ( +
+ {customEntries.map(([key, value]) => { + const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "); + let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]"; + let prefix = ""; + + // Auto-style common CTF fields based on key name + if (key === "points") { + prefix = "pts"; + colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; + } else if (key === "difficulty" || key === "level") { + const v = String(value).toLowerCase(); + if (v === "easy") + colorClass = "bg-green-500/10 border-green-500/30 text-green-400"; + else if (v === "medium") + colorClass = "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"; + else if (v === "hard") + colorClass = "bg-red-500/10 border-red-500/30 text-red-400"; + else + colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]"; + } else if (key === "event" || key === "category") { + colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]"; + } + + const displayValue = Array.isArray(value) + ? value.join(", ") + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + + return ( + + {prefix ? `${displayValue} ${prefix}` : `${label}: ${displayValue}`} + + ); + })} +
+ ); +} + export default async function DocPage({ params, searchParams }: DocPageProps) { const { section, slug } = await params; const { edit } = await searchParams; @@ -39,6 +104,13 @@ export default async function DocPage({ params, searchParams }: DocPageProps) { const cookieStore = await cookies(); const canEdit = cookieStore.get("mcpedia_admin")?.value != null; + // Standard frontmatter keys that are rendered explicitly in the template. + // Any other key in the document metadata becomes a dynamic badge. + const STANDARD_KEYS = new Set([ + "id", "slug", "title", "type", "section", "status", + "author", "tags", "path", "createdAt", "updatedAt", + ]); + // Edit mode: inline form if (edit === "1" && canEdit) { return ( @@ -106,7 +178,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) { {doc.title} -
+
{doc.author || "unknown"} ยท
+ + {/* Dynamic custom field badges โ€” content creator controls what shows */} +
{/* Content + TOC */} diff --git a/apps/web/app/api/docs/[...slug]/route.ts b/apps/web/app/api/docs/[...slug]/route.ts index 3c6ca8c..daf0ecd 100644 --- a/apps/web/app/api/docs/[...slug]/route.ts +++ b/apps/web/app/api/docs/[...slug]/route.ts @@ -17,6 +17,27 @@ function unauthorized() { return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } +const STANDARD_FIELDS = new Set([ + "title", "body", "type", "status", + "author", "tags", "createdAt", "updatedAt", "id", "path", "slug", +]); + +function splitPayload(body: Record): { + standard: Record; + extraFields: Record; +} { + const standard: Record = {}; + const extraFields: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (STANDARD_FIELDS.has(key)) { + standard[key] = value; + } else if (value !== undefined && value !== null) { + extraFields[key] = typeof value === "string" ? value : JSON.stringify(value); + } + } + return { standard, extraFields }; +} + // PUT /api/docs/{slug...} โ€” Update an existing document. export async function PUT( req: NextRequest, @@ -35,7 +56,8 @@ export async function PUT( } try { - const doc = await updateDocument(fullSlug, body); + const { standard, extraFields } = splitPayload(body); + const doc = await updateDocument(fullSlug, { ...standard, extraFields } as any); return NextResponse.json({ ok: true, slug: doc.slug, doc }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/apps/web/app/api/docs/route.ts b/apps/web/app/api/docs/route.ts index 7dd611d..024d669 100644 --- a/apps/web/app/api/docs/route.ts +++ b/apps/web/app/api/docs/route.ts @@ -24,6 +24,33 @@ function unauthorized() { return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } +// Standard DocumentMeta field names โ€” NOT custom fields. +const STANDARD_FIELDS = new Set([ + "slug", "title", "body", "section", "type", "status", + "author", "tags", "createdAt", "updatedAt", "id", "path", +]); + +/** + * Separate a flat payload into standard CRUD fields + extraFields (custom metadata). + * The DocForm sends all fields flat โ€” any key not in STANDARD_FIELDS becomes an + * entry in extraFields, which is stored as JSONB in the documents table. + */ +function splitPayload(body: Record): { + standard: Record; + extraFields: Record; +} { + const standard: Record = {}; + const extraFields: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (STANDARD_FIELDS.has(key)) { + standard[key] = value; + } else { + extraFields[key] = value; + } + } + return { standard, extraFields }; +} + // GET /api/docs โ€” list all documents (for sidebar navigation). export async function GET() { const docs = await listDocuments(); @@ -36,11 +63,19 @@ export async function POST(req: NextRequest) { const body = await req.json().catch(() => null); if (!body) { - return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status:400 }); + return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } try { - const doc = await createDocument(body); + const { standard, extraFields } = splitPayload(body); + // Coerce all extra field values to strings (form sends strings; API/MCP may send objects). + const stringExtra: Record = {}; + for (const [k, v] of Object.entries(extraFields)) { + if (v !== undefined && v !== null) { + stringExtra[k] = typeof v === "string" ? v : JSON.stringify(v); + } + } + const doc = await createDocument({ ...standard, extraFields: stringExtra } as any); return NextResponse.json({ ok: true, slug: doc.slug, doc }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/apps/web/app/components/DocForm.tsx b/apps/web/app/components/DocForm.tsx index e9097d6..97e20c6 100644 --- a/apps/web/app/components/DocForm.tsx +++ b/apps/web/app/components/DocForm.tsx @@ -34,6 +34,12 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) { const [tags, setTags] = useState(initial?.tags?.join(", ") ?? ""); const [author, setAuthor] = useState(initial?.author ?? ""); + // Dynamic custom fields โ€” content creators can add any metadata they want + // (e.g. CTF: event, challenge, category, difficulty, points, team_name, ...) + const [customFields, setCustomFields] = useState< + Array<{ key: string; value: string }> + >([]); + const tagList = tags .split(",") .map((t) => t.trim()) @@ -42,27 +48,68 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) { const baseInputCls = "w-full px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]"; + function addCustomField() { + setCustomFields([...customFields, { key: "", value: "" }]); + } + + function updateCustomField(index: number, field: "key" | "value", value: string) { + const updated = [...customFields]; + updated[index][field] = value; + setCustomFields(updated); + } + + function removeCustomField(index: number) { + setCustomFields(customFields.filter((_, i) => i !== index)); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); setError(null); - const payload = { title, body, section, type, status, author, tags: tagList }; + // Build payload with custom fields flattened at top level + const payload: Record = { + title, + body, + section, + type, + status, + author, + tags: tagList, + }; + + // Merge dynamic custom fields + for (const field of customFields) { + if (field.key.trim() && field.value.trim()) { + payload[field.key.trim()] = field.value.trim(); + } + } try { let res: Response; if (mode === "create") { - const slugVal = slug ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const slugVal = + slug ?? + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); res = await fetch("/api/docs", { method: "POST", - headers: { "Content-Type": "application/json", "x-webhook-secret": secret }, + headers: { + "Content-Type": "application/json", + "x-webhook-secret": secret, + }, body: JSON.stringify({ slug: slugVal, ...payload }), }); } else { const editSlug = slug ?? ""; res = await fetch(`/api/docs/${editSlug}`, { method: "PUT", - headers: { "Content-Type": "application/json", "x-webhook-secret": secret }, + headers: { + "Content-Type": "application/json", + "x-webhook-secret": secret, + }, body: JSON.stringify(payload), }); } @@ -90,12 +137,22 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) { )}
- - setTitle(e.target.value)} className={baseInputCls} required /> + + setTitle(e.target.value)} + className={baseInputCls} + required + />
- + -

URL-safe path under the section.

+

+ URL-safe path under the section. +

- - setSection(e.target.value as typeof section)} + className={baseInputCls} + > {SECTION_OPTIONS.map((s) => ( - + ))}
- - setType(e.target.value as typeof type)} + className={baseInputCls} + > {TYPE_OPTIONS.map((t) => ( - + ))}
- - setStatus(e.target.value as typeof status)} + className={baseInputCls} + > @@ -133,17 +214,88 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
- - setTags(e.target.value)} className={baseInputCls} placeholder="comma, separated, tags" /> + + setTags(e.target.value)} + className={baseInputCls} + placeholder="comma, separated, tags" + />
- - setAuthor(e.target.value)} className={baseInputCls} /> + + setAuthor(e.target.value)} + className={baseInputCls} + /> +
+ + {/* Dynamic custom fields โ€” creators add whatever metadata they need */} +
+ +

+ Add extra frontmatter fields. These will be stored in the document's + YAML frontmatter and rendered as badges on the doc page. Examples for + CTF writeups: event,{" "} + challenge,{" "} + category,{" "} + difficulty,{" "} + points. +

+ {customFields.length > 0 && ( +
+ {customFields.map((field, i) => ( +
+ updateCustomField(i, "key", e.target.value)} + className="flex-1 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]" + /> + + updateCustomField(i, "value", e.target.value) + } + className="flex-1 px-3 py-2 bg-[#0f1011] border border-[#23252a] rounded text-[#f7f8f8] placeholder-[#62666d] focus:outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2]" + /> + +
+ ))} +
+ )} +
- +