From 1dd16ebcba202828d51fe331c933fb1483525bb3 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 20 Aug 2026 13:21:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20Phase=2011=20UI/UX=20=E2=80=94=20T?= =?UTF-8?q?OC,=20dark=20mode=20toggle,=20/docs=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install rehype-slug (proper heading anchors) + github-slugger (matching TOC) - TOC component: auto-generated from h2/h3 headings, clickable anchors - Dark mode toggle: ThemeToggle (localStorage + system default), class-based - /docs index page (lists all docs by section) - Markdown.tsx uses rehype-slug for stable heading ids - globals.css: @custom-variant dark (.dark class) for class-based dark mode - layout.tsx: nav includes ThemeToggle Note: per user instruction, installed real deps (rehype-slug, github-slugger, @types/hast) instead of hacky inline any-cast hacks. --- .hermes/plans/phase11-crud-auth-ui.md | 13 ++-- PHASES.md | 79 +++++++++++++++++++++++ apps/web/app/[section]/[...slug]/page.tsx | 8 +-- apps/web/app/components/Markdown.tsx | 5 +- apps/web/app/components/TOC.tsx | 55 ++++++++++++++++ apps/web/app/components/ThemeToggle.tsx | 41 ++++++++++++ apps/web/app/docs/page.tsx | 41 ++++++++++++ apps/web/app/globals.css | 10 +-- apps/web/app/layout.tsx | 17 +++-- apps/web/package.json | 5 +- bun.lock | 11 ++++ 11 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 apps/web/app/components/TOC.tsx create mode 100644 apps/web/app/components/ThemeToggle.tsx create mode 100644 apps/web/app/docs/page.tsx diff --git a/.hermes/plans/phase11-crud-auth-ui.md b/.hermes/plans/phase11-crud-auth-ui.md index c6eee64..0835736 100644 --- a/.hermes/plans/phase11-crud-auth-ui.md +++ b/.hermes/plans/phase11-crud-auth-ui.md @@ -8,11 +8,14 @@ 3. **UI/UX**: Polish doc page, edit forms, search UX, dark mode 4. **Agent integration**: MCP tools for CRUD with auth -### Files to touch: -- `packages/db/` — migrations (users table, if needed) -- `apps/api/src/` — auth routes, CRUD tRPC routers -- `apps/mcp/src/` — add create/update/delete tools -- `apps/web/app/` — edit/create pages, auth UI +## Requirements: +1. **Source of truth is the filesystem** — each doc is a markdown file under `content/{section}/{slug}.md` with YAML frontmatter (parsed/striped by gray-matter). +2. **DB mirrors disk** — `documents` table has slug/title/section/body/status/tags/etc; `document_chunks` stores embeddings; `document_revisions` snapshots body+meta. +3. **Single indexing path** — `indexContentFile` in `@mcpedia/core` writes DB + revisions + chunks; CRUD ops call it (or its pieces) after writing the file. +4. **Auth** — MCP/API writes require `x-webhook-secret` matching `WEBHOOK_SECRET` (Phase 4 pattern). Web CRUD uses cookie-based auth (Phase 11 addition: `ADMIN_PASSWORD`). +5. **Slug rules** — `[a-z0-9][a-z0-9/_-]*`, no `//`, no path traversal (`..`). The slug in the URL is `
/`. +6. **No breaking existing features** — read tools/routes unchanged; existing 32 tests still green. +7. **UI/UX** — edit button on doc pages (auth-gated), `?edit=1` inline form, `/create` page, login page, table of contents auto-generated from h2, dark mode toggle. ### Schema changes: - Optional: `users` table if auth is user-based diff --git a/PHASES.md b/PHASES.md index 761dd1b..501b6af 100644 --- a/PHASES.md +++ b/PHASES.md @@ -344,6 +344,85 @@ The VPS services were configured manually (systemd units in `deploy/`). Added a `/metrics`, `/trpc/*`, `mcp.asepharyana.my.id/mcp`. - Doc pages render clean markdown (no frontmatter); History panel + Restore work. +## Phase 11 — CRUD + Auth + Web UI ✅ DONE + +> User requested: "perbagus agar jadi CRUD, pastikan ada autentikasi dan bisa +> manual dari web atau lewat agent melalui MCP, dan perbaui UI/UXnya." + +### Backend (Core + API + MCP) + +- [x] **`packages/parser` — `stringifyFile()`** — serialize `DocumentMeta` + body + back to a markdown file with YAML frontmatter (gray-matter). Round-trip stable + with `parseFile`. +- [x] **`@mcpedia/core` — CRUD functions:** + - `createDocument({slug, title, section, body, type?, status?, author?, tags?})` + — writes file to `content/{section}/{slug}.md`, upserts `documents` row, + snapshots revision, indexes chunks. + - `updateDocument(slug, {...})` — writes file, updates DB row, snapshots + revision (if body changed), reindexes chunks. + - `deleteDocument(slug)` — removes file + `documents`/`document_chunks`/ + `document_revisions` rows. + - Slug validation: `[a-z0-9][a-z0-9/_-]*`, no `//`, no `..` traversal. +- [x] **`apps/api` — tRPC CRUD routers** — `createDocument`, `updateDocument`, + `deleteDocument` (all `.use(requireWriteAuth)`). Fixed `requireWriteAuth` to + compare against `ctx.expectedSecret` (injected from deps) instead of the + module-level `WEBHOOK_SECRET` env constant — latent bug that made the middleware + untestable without env manipulation. +- [x] **`apps/mcp` — 3 new write tools** — `create_document`, `update_document`, + `delete_document` (all require `x-webhook-secret`). Tools: 10 → 13. +- [x] **Auth** — MCP/API writes reuse the existing `WEBHOOK_SECRET` / + `x-webhook-secret` pattern. Web CRUD adds cookie-based auth: `ADMIN_PASSWORD` + env + `/api/auth/login` (HMAC-signed `mcpedia_admin` cookie, HttpOnly). + +### Web UI + +- [x] **`/create` page** — form (section/type/status/title/slug/tags/author/body), + POSTs to `/api/docs` with `x-webhook-secret`. +- [x] **`?edit=1` on doc pages** — inline edit form (`DocForm` component), + PUTs to `/api/docs/{slug}`. +- [x] **`/login` page** — password → `/api/auth/login` → cookie → redirect `/create`. +- [x] **Edit buttons** — homepage "+ Create Document" + per-doc "✎" (auth-gated); + doc page "Edit" button (auth-gated). +- [x] **TOC** — doc page auto-generates a table of contents from `h2` headings. +- [x] **Dark mode** — toggle persisted in `localStorage`, defaults to system. +- [x] **`/api/docs` REST routes** — POST (create), PUT (update), DELETE (delete), + all `x-webhook-secret` gated. + +### Files changed +``` +new: apps/web/app/api/auth/login/route.ts # cookie-based login + verify +new: apps/web/app/api/docs/route.ts # REST CRUD +new: apps/web/app/components/DocForm.tsx # shared create/edit form +new: apps/web/app/create/page.tsx # create UI +new: apps/web/app/login/page.tsx # login UI +new: apps/web/app/components/TOC.tsx # auto-generated TOC +mod: apps/web/app/page.tsx # edit/create buttons (auth-gated) +mod: apps/web/app/[section]/[...slug]/page.tsx # ?edit=1 + TOC + dark mode +mod: packages/core/src/document.service.ts # createDocument/updateDocument/deleteDocument +mod: packages/core/src/index.service.ts # export snapshotRevision +mod: packages/core/src/index.ts # re-export CRUD + types +mod: packages/parser/src/index.ts # stringifyFile +mod: packages/config/src/index.ts # ADMIN_PASSWORD +mod: apps/api/src/router.ts # CRUD routers + fix requireWriteAuth +mod: apps/api/src/app.ts # createContext passes expectedSecret +mod: apps/api/src/trpc.ts # Context.expectedSecret +mod: apps/mcp/src/index.ts # 3 new CRUD write tools +mod: apps/mcp/src/auth.test.ts # +4 CRUD auth tests +mod: apps/api/src/app.test.ts # +5 tRPC CRUD auth tests +mod: .env.example # ADMIN_PASSWORD +``` + +### Gotchas / lessons +1. **tRPC fetch adapter** expects input directly as JSON body, NOT JSON-RPC + envelope (`{"slug":...}` not `{"jsonrpc":"2.0","method":...,"params":{...}}`). +2. **`requireWriteAuth` env-constant bug** — comparing `ctx.webhookSecret !== WEBHOOK_SECRET` + (module-level env constant) is untestable. Fix: thread `expectedSecret` through + `Context` from `createApp(deps)`. +3. **Next.js catch-all routes** — `[...slug]/edit/` is invalid (catch-all must be + last). Used `?edit=1` query param instead. +4. **`stringifyFile` YAML** — quote string values with `JSON.stringify` for + special-char safety; arrays use `[...]` syntax. + ## Decisions locked (from initial planning) diff --git a/apps/web/app/[section]/[...slug]/page.tsx b/apps/web/app/[section]/[...slug]/page.tsx index 2edea1d..f88a15b 100644 --- a/apps/web/app/[section]/[...slug]/page.tsx +++ b/apps/web/app/[section]/[...slug]/page.tsx @@ -5,6 +5,7 @@ import { getDocument, getRelated, listRevisions } from "@mcpedia/core"; import { WEBHOOK_SECRET } from "@mcpedia/config"; import Markdown from "@/components/Markdown"; import DocForm from "@/components/DocForm"; +import TOC from "@/components/TOC"; // Render at request time. The content lives in Postgres (populated by the // indexer/worker), which is not available at build time (CI has no DB), so we @@ -76,6 +77,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) { )} + + {related.length > 0 && ( @@ -84,10 +87,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
    {related.map((r) => (
  • - + {r.title}
  • diff --git a/apps/web/app/components/Markdown.tsx b/apps/web/app/components/Markdown.tsx index f50df02..87ca802 100644 --- a/apps/web/app/components/Markdown.tsx +++ b/apps/web/app/components/Markdown.tsx @@ -1,9 +1,12 @@ import ReactMarkdown from "react-markdown"; +import rehypeSlug from "rehype-slug"; export default function Markdown({ source }: { source: string }) { return (
    - {source} + + {source} +
    ); } diff --git a/apps/web/app/components/TOC.tsx b/apps/web/app/components/TOC.tsx new file mode 100644 index 0000000..01afb48 --- /dev/null +++ b/apps/web/app/components/TOC.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import GithubSlugger from "github-slugger"; + +interface TOCEntry { + id: string; + text: string; + level: number; +} + +/** Extract h2/h3 headings from markdown and render as a clickable TOC. The + * heading `id`s are generated by rehype-slug (via github-slugger) in Markdown.tsx, + * so the same slugger is used here to produce matching anchors. */ +export default function TOC({ source }: { source: string }) { + const [toc, setToc] = useState([]); + + useEffect(() => { + const slugger = new GithubSlugger(); + const lines = source.split("\n"); + const entries: TOCEntry[] = []; + for (const line of lines) { + const m = line.match(/^(#{2,3})\s+(.+?)\s*#*\s*$/); + if (m) { + const level = m[1].length; + const text = m[2].trim(); + entries.push({ id: slugger.slug(text), text, level }); + } + } + setToc(entries); + }, [source]); + + if (toc.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/app/components/ThemeToggle.tsx b/apps/web/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..707ca6a --- /dev/null +++ b/apps/web/app/components/ThemeToggle.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "mcpedia-theme"; + +/** Dark mode toggle. Defaults to system preference, persisted in localStorage. */ +export default function ThemeToggle() { + const [dark, setDark] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + const initial = + stored === "dark" || + (stored === null && + window.matchMedia("(prefers-color-scheme: dark)").matches); + setDark(initial); + apply(initial); + }, []); + + function apply(isDark: boolean) { + document.documentElement.classList.toggle("dark", isDark); + } + + function toggle() { + const next = !dark; + setDark(next); + localStorage.setItem(STORAGE_KEY, next ? "dark" : "light"); + apply(next); + } + + return ( + + ); +} diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx new file mode 100644 index 0000000..20fdafa --- /dev/null +++ b/apps/web/app/docs/page.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; +import { listDocuments } from "@mcpedia/core"; + +export const dynamic = "force-dynamic"; + +const SECTIONS = ["docs", "writeups", "research", "notes"] as const; + +export default async function DocsIndexPage() { + const all = await listDocuments(); + const bySection = SECTIONS.map((section) => ({ + section, + docs: all.filter((d) => d.section === section), + })); + + return ( +
    +

    All Documents

    + {bySection.map(({ section, docs }) => ( +
    +

    {section}

    + {docs.length === 0 ? ( +

    No documents in this section.

    + ) : ( +
      + {docs.map((d) => ( +
    • + + {d.title} + + + {d.author || "unknown"} + +
    • + ))} +
    + )} +
    + ))} +
    + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a2dc41e..1d47ae4 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,5 +1,8 @@ @import "tailwindcss"; +/* Enable class-based dark mode (toggled by ThemeToggle via .dark on ). */ +@custom-variant dark (&:where(.dark, .dark *)); + :root { --background: #ffffff; --foreground: #171717; @@ -12,13 +15,6 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 7511a64..84e81b2 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import "./globals.css"; +import ThemeToggle from "@/components/ThemeToggle"; export const metadata: Metadata = { title: "MCPedia", @@ -20,15 +21,17 @@ export default function RootLayout({ MCPedia -