46 Commits
Author SHA1 Message Date
mytheclipsebotreview[bot] 3dadbf7946 Auto-merge PR #17
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps): bump the production group with 3 updates
2026-09-14 18:06:35 +00:00
mytheclipsebotreview ea6532c703 fix: auto-fix code quality [skip ci] 2026-09-15 01:06:24 +07:00
mytheclipsebotreview 84d8139cca chore: sync bun.lock after dependabot bump 2026-09-15 01:01:07 +07:00
dependabot[bot] 6c5cf6d3e6 chore(deps): bump the production group with 3 updates
Bumps the production group with 3 updates: [next](https://github.com/vercel/next.js), [react](https://github.com/react/react/tree/HEAD/packages/react) and [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom).


Updates `next` from 16.3.4 to 16.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.4...v16.3.5)

Updates `react` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react)

Updates `react-dom` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 17:56:17 +00:00
mytheclipsebotreviewandinternal-model 4330f47c65 feat(web): FE overhaul — SWR centralized data layer + dashboard + islands
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
- Add SWR foundation (lib/fetcher, lib/swr, lib/api) with typed
  hooks: useSections/useDocuments/useSearch/useRelated/useRevisions/useQueueStatus
- Replace ad-hoc useEffect+fetch in Header/Sidebar/CommandMenu/search
  with SWR (dedup 4s, focus throttle 10s, debounced search 180ms)
- Doc page: RelatedGrid + RevisionList as SWR client islands (RSC hero stays)
- DocForm: SWR mutate on create/update for live sidebar invalidation
- New APIs: /api/related, /api/revisions, /api/tags, /api/queue/status,
  /api/docs filtering (?section=&status=)
- New UI: DocumentCard, SectionCard, SearchBar, TagCloud, QueueBadge,
  EmptyState, dashboard (/dashboard with QueueStrip live + section stats)
- Layout wraps SWRProvider; no TanStack code (SWR-only per spec)
- Verified: next build passes (16.3.4 Turbopack), no manual fetch sprawl remains

Co-Authored-By: internal-model
2026-09-08 19:37:27 +07:00
mytheclipsebotreview[bot] 1025454d87 Auto-merge PR #15
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps): bump next from 16.3.3 to 16.3.4 in the production group
2026-09-08 09:38:15 +00:00
mytheclipsebotreview 66af4193f1 chore: sync bun.lock after dependabot bump 2026-09-08 16:28:02 +07:00
mytheclipsebotreview a1e550da41 Merge remote-tracking branch 'origin/main' into dependabot/npm_and_yarn/production-aafae73067 2026-09-08 16:27:58 +07:00
mytheclipsebotreview[bot] c77fa5e15c Auto-merge PR #16
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps-dev): bump eslint-config-next from 16.3.3 to 16.3.4 in the development group
2026-09-08 09:27:53 +00:00
mytheclipsebotreview 9ce8ad190f chore: sync bun.lock after dependabot bump 2026-09-08 16:17:42 +07:00
dependabot[bot] 229cb15632 chore(deps-dev): bump eslint-config-next in the development group
Bumps the development group with 1 update: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-04 17:55:06 +00:00
dependabot[bot] f6ab0c38a0 chore(deps): bump next from 16.3.3 to 16.3.4 in the production group
Bumps the production group with 1 update: [next](https://github.com/vercel/next.js).


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-04 17:54:49 +00:00
mytheclipsebotreview a0da49e2f8 revert(config): redis default kembali ke imrnes 100.121.180.82 (outage usai)
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-09-02 16:02:42 +07:00
mytheclipsebotreview 7186741258 fix(config): redis default ke local 127.0.0.1:6380 (imrnes offline)
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-09-02 12:15:22 +07:00
mytheclipsebotreview[bot] 05056cf31d Auto-merge PR #14
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps): bump zod from 3.25.76 to 4.5.4
2026-09-01 18:53:57 +00:00
mytheclipsebotreview 1014014145 chore: sync bun.lock after dependabot bump 2026-09-02 01:53:50 +07:00
dependabot[bot] dd98201d79 chore(deps): bump zod from 3.25.76 to 4.5.4
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.5.4.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.5.4)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.5.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 18:01:35 +00:00
mytheclipsebotreview 2d26877b71 chore: upgrade runtime to Bun 1.4.0
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
- Update Nix flake.nix overlay to use Bun 1.4.0 (overrideAttrs)
- Update CI (setup-bun) to pin bun-version: 1.4.0
- Update package.json packageManager/Dockerfile/vercel.json
- sha256: sha256:Poy0vf7yJ/hzk33QiQj5gnshI5Q7dfbaMD7xgwiyDKw=
2026-08-29 11:05:14 +07:00
mytheclipsebotreview[bot] 12b99c76a6 Auto-merge PR #13
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps-dev): bump eslint from 9.39.5 to 10.9.1
2026-08-29 03:37:51 +00:00
mytheclipsebotreview b4bbf6892c fix: auto-fix code quality [skip ci] 2026-08-29 10:37:19 +07:00
mytheclipsebotreview 464164d8a7 chore: sync bun.lock after dependabot bump 2026-08-29 11:36:07 +08:00
dependabot[bot] ba320380a0 chore(deps-dev): bump eslint from 9.39.5 to 10.9.1
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.5 to 10.9.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.5...v10.9.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-29 03:33:10 +00:00
mytheclipsebotreview[bot] 48566081f0 Auto-merge PR #10
chore(deps): bump next from 16.3.2 to 16.3.3 in the production group
2026-08-29 03:31:14 +00:00
mytheclipsebotreview 10a15a133c fix: auto-fix code quality [skip ci] 2026-08-29 10:30:56 +07:00
mytheclipsebotreview 5b1345cf9e chore: sync bun.lock after dependabot bump [skip ci] 2026-08-29 10:27:30 +07:00
mytheclipsebotreview 4ca5f0af39 Merge remote-tracking branch 'origin/main' into dependabot/npm_and_yarn/production-2c8a7f9a5b 2026-08-29 10:27:29 +07:00
mytheclipsebotreview 283d66b5e0 chore: sync bun.lock after @types/node@26 bump [skip ci] 2026-08-29 11:21:52 +08:00
mytheclipsebotreview[bot] b311c363c5 Auto-merge PR #12
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps-dev): bump @types/node from 20.19.43 to 26.3.0
2026-08-29 02:57:10 +00:00
mytheclipsebotreview 006bf79655 fix: auto-fix code quality [skip ci] 2026-08-29 09:56:57 +07:00
dependabot[bot] 1a45ba9fdb chore(deps-dev): bump @types/node from 20.19.43 to 26.3.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.43 to 26.3.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:55:21 +00:00
dependabot[bot] 4d424e67ad chore(deps): bump next from 16.3.2 to 16.3.3 in the production group
Bumps the production group with 1 update: [next](https://github.com/vercel/next.js).


Updates `next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.2...v16.3.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 17:54:52 +00:00
asepharyana 263a92f368 Merge pull request #8 from asepharyana/dependabot/npm_and_yarn/production-7059fa7707
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
chore(deps): bump the production group across 1 directory with 2 updates
2026-08-26 09:12:50 +07:00
Asep Hariyana f44e66bcd5 chore: sync lockfile for dependabot update 2026-08-26 10:12:44 +08:00
asepharyana 66e6fad876 Merge pull request #9 from asepharyana/dependabot/npm_and_yarn/development-9df26a4104
chore(deps-dev): bump the development group across 1 directory with 2 updates
2026-08-26 09:12:39 +07:00
Asep Hariyana befb898c45 chore: sync lockfile for dependabot update 2026-08-26 10:12:33 +08:00
dependabot[bot] 0638c4a82d chore(deps): bump the production group across 1 directory with 2 updates
Bumps the production group with 2 updates in the / directory: [next](https://github.com/vercel/next.js) and [drizzle-orm](https://github.com/drizzle-team/drizzle-orm).


Updates `next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.1...v16.3.2)

Updates `drizzle-orm` from 0.38.4 to 0.45.2
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.38.4...0.45.2)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.45.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 01:55:57 +00:00
dependabot[bot] e412767847 chore(deps-dev): bump the development group across 1 directory with 2 updates
Bumps the development group with 2 updates in the / directory: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [drizzle-kit](https://github.com/drizzle-team/drizzle-orm).


Updates `eslint-config-next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.2/packages/eslint-config-next)

Updates `drizzle-kit` from 0.30.6 to 0.31.10
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.30.6...drizzle-kit@0.31.10)

---
updated-dependencies:
- dependency-name: drizzle-kit
  dependency-version: 0.31.10
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: eslint-config-next
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 01:55:46 +00:00
asepharyana ea4f34271c chore: update dependabot-auto-merge.yml
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-26 08:53:44 +07:00
asepharyana caabe05c68 chore: update dependabot.yml 2026-08-26 08:53:43 +07:00
asepharyana 9d8d876ba7 feat: add dependabot auto-merge workflow
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-25 20:32:58 +07:00
asepharyana 8f98bed7dd feat: update dependabot config
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-25 18:16:53 +07:00
asepharyana bd3a0894b4 feat: update dependabot config 2026-08-25 18:13:06 +07:00
asepharyana 03616b4e60 feat: add dependabot config (npm auto-deps)
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-25 18:08:45 +07:00
asepharyana 5ccba958d2 feat: add Word (.docx) export, enhance print density, and fix auth security guards
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 17:59:32 +07:00
asepharyana 067115ba13 style(web): refine aesthetic to archival precision, remove generic gradients and glowing blobs
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 15:12:00 +07:00
asepharyana 011571e12d feat(web): overhaul frontend with cyber-intelligence design and interactive topology
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
2026-08-22 14:55:19 +07:00
69 changed files with 4560 additions and 1321 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18
View File
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development:
dependency-type: "development"
update-types:
- "minor"
- "patch"
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: with:
bun-version: "1.3.14" bun-version: "1.4.0"
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -207,7 +207,7 @@ jobs:
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: with:
bun-version: "1.3.14" bun-version: "1.4.0"
- name: Install Nix - name: Install Nix
uses: DeterminateSystems/nix-installer-action@v16 uses: DeterminateSystems/nix-installer-action@v16
+227
View File
@@ -0,0 +1,227 @@
# MCPedia Frontend Redesign — Spec
Status: DRAFT — minta approval sebelum coding
Date: 2026-09-08 WIB
Author: Hermes
## 1. Tujuan
Merombak `apps/web` agar:
- Satu sumber kebenaran: semua data berasal dari `@mcpedia/core` types & `@mcpedia/config` section presets; tidak ada shape duplikat di FE.
- Selaras dengan gateway/BE data flow: tRPC `apps/api` (`/trpc/*`) + REST `/api/*` + MCP `:4021` semua mengembalikan shape yang sama (DocumentMeta, SectionInfo, SearchHit, ExportData).
- Ganti pola fetch ad-hoc (`fetch("/api/...")` tersebar) dengan **SWR** terpusat — cache, revalidate, mutate, optimistic UI, error boundary konsisten.
- Hapus sisa TanStack (saat ini tidak ada deps TanStack — audit `apps/web/package.json` clean; tapi pola akan dibakukan supaya tidak masuk lagi).
- UI: Archival Precision tetap (monochrome, hairline, mono telemetry), tapi lebih live dan terisi data real (bukan placeholder).
## 2. Audit Status Quo
### FE sekarang
- Next.js 16.3.4, App Router, `force-dynamic` di semua page yang baca DB.
- RSC langsung import `@mcpedia/core` (`listDocuments`, `getDocument`, `listSections`, `getRelated`, `listRevisions`, `getExportDocuments`). SEO bagus, tapi client components (Sidebar, Header, Search, CommandMenu) fetch ulang via REST tanpa cache sharing.
- REST: `/api/search?q&mode&limit`, `/api/docs` (list), `/api/sections`, `/api/export?path&sort`, `/api/revisions/restore`, `/api/auth/login`.
- tRPC `apps/api` (Hono) expose: search/semanticSearch/hybridSearch, getDocument, listDocuments, sections, related, revisions, CRUD (gated `x-webhook-secret`), jobStatus, reindex hooks, `/health`, `/metrics`.
- MCP `:4021` Streamable HTTP `/mcp` expose 13 tools (tidak dipakai FE, tapi shape-nya sama).
- Tidak ada `swr` / `@tanstack/react-query` di `apps/web/package.json` — jadi migrasi = greenfield SWR layer, bukan replace.
- Design tokens sudah rapi di `globals.css` (CSS vars light/dark + `--brand`, `--bg-*`, `--text-*`, `--code-*`). Header/Sidebar/Markdown/TOC/DocActions sudah pakai tokens.
### Masalah yang diselesaikan redesign
1. Sidebar & Header fetch `/api/docs` + `/api/sections` tanpa deduplikasi — tiap navigasi fetch ulang, tidak ada stale-while-revalidate.
2. Search page client-side `fetch` manual tanpa debounce terpusat, tanpa cache antar-mode (hybrid/keyword/semantic saling overwrite).
3. Doc page RSC tidak live: setelah create/update/delete atau reindex, harus hard refresh. Tidak ada `mutate` cross-component.
4. Shape drift: Search `snippet` vs `SearchHit` vs `ChunkHit` tidak konsisten antara `/api/search` dan `@mcpedia/search` — perlu typed fetcher.
5. Tidak ada surface untuk queue/embeddings status (BE punya `jobStatus`, `queueStatus`, `INDEX_QUEUE` BullMQ) — FE buta terhadap indexing.
## 3. Arsitektur Target
```
apps/web/app/
lib/
fetcher.ts — typed fetcher (fetch JSON + error envelope)
swr.ts — SWRConfig global (dedupingInterval, focusThrottle, errorRetry)
api.ts — typed client untuk /api/* (listDocuments, search, sections, export, revisions)
hooks/
useSections.ts — SWR '/api/sections' -> SectionInfo[]
useDocuments.ts — SWR '/api/docs?section&status' -> DocumentMeta[]
useDocument.ts — SWR '/api/docs/:slug' (baru, GET single) -> Document
useSearch.ts — SWR key [q, mode, limit] -> SearchHit[] + debounced
useRelated.ts — SWR '/api/related?slug'
useRevisions.ts — SWR '/api/revisions?slug'
useQueueStatus.ts — SWR '/api/queue/status' poll 5s (opsional, admin only)
components/
(existing: Header, Sidebar, CommandMenu, Markdown, TOC, DocActions, ...)
SearchBar.tsx — reusable, dipakai Header + Search page + CommandMenu
SectionCard.tsx — extract dari page.tsx
DocumentCard.tsx — reusable card (recent, related, folder list)
TagCloud.tsx — baru: agregasi tags dari docs
QueueBadge.tsx — baru: show indexing status (waiting/active/failed)
EmptyState.tsx / ErrorState.tsx
app/
layout.tsx — tambah <SWRProvider> wrapper (client boundary)
page.tsx — refactor: RSC shell + client islands via SWR
[section]/page.tsx — sama
[section]/[...slug]/page.tsx — RSC hero + Markdown, client islands (related/revisions via SWR)
search/page.tsx — fully SWR, hapus useState fetch manual
create/page.tsx — mutate after create
dashboard/page.tsx — BARU (optional phase 2): queue, sections, recent, tag stats
```
### Prinsip SWR
- Satu `fetcher` generic: `fetcher<T>(url: string): Promise<T>` — throw on !ok dengan `info` + `status` untuk error boundary.
- `SWRConfig` di `layout.tsx` client wrapper: `dedupingInterval: 4000`, `focusThrottleInterval: 10000`, `shouldRetryOnError: false` untuk search, `refreshInterval` hanya untuk queue.
- Semua hooks pakai key array/string yang deterministic supaya `mutate` cross-page bisa: `mutate('/api/docs')`, `mutate('/api/sections')`.
- Debounce search di hook, bukan di component: `useDebouncedValue(q, 180ms)` internal ke `useSearch`.
- Optimistic mutate untuk create/update/delete: `mutate('/api/docs', optimisticList, false)` lalu revalidate.
## 4. Kontrak Data (FE ↔ BE)
Semua types import dari `@mcpedia/types` — tidak ada re-declare shape di FE.
| FE Hook | BE Source | Endpoint | Shape |
|---------|-----------|----------|-------|
| useSections | `listSections()` | `GET /api/sections` | `SectionInfo[]` |
| useDocuments | `listDocuments({section,status})` | `GET /api/docs?section=&status=` | `DocumentMeta[]` |
| useDocument | `getDocument(slug)` | `GET /api/docs/:slug` (baru) | `Document` |
| useSearch | `keywordSearch/hybridSearch/semanticSearch` | `GET /api/search?q&mode&limit` | `{results: {slug,title,section,score,snippet}[]}` |
| useRelated | `getRelated(slug)` | `GET /api/related?slug=` (baru, atau reuse via core) | `DocumentMeta[]` |
| useRevisions | `listRevisions(slug)` | `GET /api/revisions?slug=` (baru) | `Revision[]` |
| useQueueStatus | BullMQ `getQueue()` | `GET /api/queue/status` (baru, proxy ke apps/api /metrics atau queue) | `{waiting,active,completed,failed,delayed}` |
**Tambahan endpoint yang perlu dibuat (tipis, proxy ke core):**
- `GET /api/docs/[slug]` — single doc (hindari RSC-only fetch, enable SWR).
- `GET /api/related?slug=&limit=` — wrapper `getRelated`.
- `GET /api/revisions?slug=&limit=` — wrapper `listRevisions`.
- `GET /api/queue/status` — wrapper `getQueue().get*Count()` (admin-only atau public read-only).
- (Opsional) `GET /api/tags` — agregasi tags dari `listDocuments` untuk TagCloud.
Semua endpoint return envelope sama: `{data}` atau `{results}` + `{error}` on 500, dengan `Content-Type: application/json`. Auth untuk mutasi tetap `x-webhook-secret` atau cookie `mcpedia_admin`.
## 5. Perubahan Per-File (Rincian)
### Phase 0 — Foundation (wajib)
- `apps/web/package.json`: tambah `swr: ^2.3.x` (latest 2.x). Tidak tambah TanStack. `bun install` + cek `typecheck`.
- `apps/web/app/lib/fetcher.ts` (baru): `export const fetcher = <T>(url: string): Promise<T> => fetch(url).then(r => { if(!r.ok) throw ...; return r.json() })`
- `apps/web/app/lib/swr.tsx` (baru): `SWRProvider` client component wrapping `SWRConfig`.
- `apps/web/app/lib/api.ts` (baru): typed helpers `searchDocs(q,mode,limit)`, `listDocs()`, `listSections()` — dipakai hooks.
- `apps/web/app/layout.tsx`: bungkus children dengan `<SWRProvider>` (client boundary minimal).
### Phase 1 — Hooks + Refactor Existing Pages
- `hooks/useSections.ts`, `useDocuments.ts`, `useSearch.ts`: ganti `useEffect+fetch` di `Header.tsx`, `Sidebar.tsx`, `search/page.tsx`, `CommandMenu.tsx`, `page.tsx` (home).
- `Header.tsx`: `useSections()` alih-alih `useState+useEffect fetch /api/sections`.
- `Sidebar.tsx`: `useDocuments()` + `useSections()` + filter memo — hilangkan `useEffect fetch /api/docs`.
- `search/page.tsx`: `useSearch(q, mode)` — hapus `handleSearch` manual, pakai `isLoading`, `isValidating`, `error` dari SWR. Mode & section filter tetap local state, tapi key SWR mencakup keduanya.
- `CommandMenu.tsx`: `useSearch(query)` debounced — hapus timer manual.
- `app/page.tsx`: tetap RSC untuk SEO (listDocuments/listSections server), tapi `SectionTree` dan `Recently Updated` bisa jadi client islands yang pakai SWR untuk live update (opsional: keep RSC + `mutate` on focus).
- `[section]/page.tsx` & `[section]/[...slug]/page.tsx`: RSC hero + Markdown tetap, `related` & `revisions` pindah ke client islands `RelatedGrid` & `RevisionList` pakai `useRelated`/`useRevisions`.
### Phase 2 — Fitur Baru (detail)
1. **Dashboard `/dashboard` (baru, admin-aware):**
- Cards: total docs, sections, queue (waiting/active/failed), embedding provider status.
- Section breakdown bar (docCount per section).
- Tag cloud (top 20 tags dengan count).
- Recent activity (last 10 updated docs).
- Queue status poll 5s via `useQueueStatus` (hanya jika admin cookie ada, else hide).
2. **Tag Explorer `/tags` atau sidebar widget:**
- `GET /api/tags` agregasi, klik tag → `/search?tag=...` atau filter docs.
3. **Doc page enhancements:**
- `QueueBadge` di hero jika doc sedang di-index (query queue by slug).
- Optimistic revision restore: `mutate('/api/revisions?slug=...')` setelah POST restore.
- Related docs pakai SWR + `keepPreviousData`.
4. **Search enhancements:**
- Persist `mode` di URL (`?q=&mode=hybrid`) — sudah ada, pertahankan + SWR key sync.
- Highlight snippet `mark` sudah ada — pertahankan.
- Empty & error states pakai `EmptyState`/`ErrorState` components.
5. **Create/Edit:**
- Setelah `POST /api/docs`, `mutate('/api/docs')` + `mutate('/api/sections')` + `router.push('/${slug}')`.
- Custom fields (extraFields) tetap flat → `splitPayload` di BE tidak berubah.
6. **Export:**
- `PdfExportView` tetap, tapi tambah SWR preload `getExportDocuments` untuk preview count sebelum export.
### Phase 3 — Polish & Cleanup
- Hapus semua `fetch` ad-hoc yang tersisa — semua lewat hooks.
- Pastikan `globals.css` tokens dipakai konsisten (tidak ada hard-coded hex baru).
- `CommandMenu` jadi satu-satunya omnisearch entry — `Header` search icon di mobile buka CommandMenu, bukan link `/search`.
- A11y: `aria-live` untuk search results count, `role="status"` untuk queue badge.
- Print/PDF styles sudah ada — tidak diubah.
## 6. File yang Disentuh (Checklist)
Baru:
- `apps/web/app/lib/fetcher.ts`
- `apps/web/app/lib/swr.tsx`
- `apps/web/app/lib/api.ts`
- `apps/web/app/hooks/useSections.ts`
- `apps/web/app/hooks/useDocuments.ts`
- `apps/web/app/hooks/useSearch.ts`
- `apps/web/app/hooks/useRelated.ts`
- `apps/web/app/hooks/useRevisions.ts`
- `apps/web/app/hooks/useQueueStatus.ts`
- `apps/web/app/hooks/useTags.ts` (opsional)
- `apps/web/app/components/SearchBar.tsx`
- `apps/web/app/components/SectionCard.tsx`
- `apps/web/app/components/DocumentCard.tsx`
- `apps/web/app/components/TagCloud.tsx`
- `apps/web/app/components/QueueBadge.tsx`
- `apps/web/app/components/EmptyState.tsx`
- `apps/web/app/dashboard/page.tsx` (baru)
- `apps/web/app/api/docs/[slug]/route.ts` (baru)
- `apps/web/app/api/related/route.ts` (baru)
- `apps/web/app/api/revisions/route.ts` (baru GET)
- `apps/web/app/api/queue/status/route.ts` (baru)
- `apps/web/app/api/tags/route.ts` (baru, opsional)
Edit:
- `apps/web/package.json` (+ swr)
- `apps/web/app/layout.tsx` (+ SWRProvider)
- `apps/web/app/page.tsx` (extract SectionCard, optional SWR islands)
- `apps/web/app/[section]/page.tsx` (DocumentCard)
- `apps/web/app/[section]/[...slug]/page.tsx` (related/revisions islands)
- `apps/web/app/search/page.tsx` (full SWR)
- `apps/web/app/components/Header.tsx` (useSections)
- `apps/web/app/components/Sidebar.tsx` (useDocuments/useSections)
- `apps/web/app/components/CommandMenu.tsx` (useSearch)
- `apps/web/app/create/page.tsx` (mutate)
- `apps/web/app/components/DocForm.tsx` (mutate setelah submit)
Tidak disentuh:
- `packages/core`, `packages/types`, `packages/config`, `packages/db` (hanya dibaca)
- `apps/api`, `apps/mcp`, `apps/worker` (hanya diproxy)
- `globals.css`, `next.config.ts`, `tailwind` config (kecuali perlu token baru)
## 7. Urutan Implementasi
1. Phase 0 foundation → `bun install && bun run typecheck` green.
2. Phase 1 hooks + Header/Sidebar/Search/CommandMenu → manual test + `next build` smoke.
3. Phase 2 doc page islands + create mutate → test CRUD flow dengan `x-webhook-secret` / admin cookie.
4. Phase 2 dashboard + tags → behind `canEdit` guard jika perlu.
5. Phase 3 cleanup, hapus fetch manual, final `bun run lint && typecheck && build`.
## 8. Verifikasi
- `bun run typecheck` — tidak ada error (SWR types + @mcpedia/types).
- `bun run lint` — rules Next 16 ok.
- `bun --cwd apps/web run build` — semua route `force-dynamic` OK, tidak ada import-time DB throw.
- Manual:
- Sidebar filter + section count live setelah create doc.
- Search mode switch (hybrid/keyword/semantic) cache terpisah, tidak flicker.
- CommandMenu ⌘K debounce 180ms, Enter navigasi benar.
- Doc related & revisions muncul via SWR tanpa hard refresh.
- Queue badge (jika ada) poll 5s, tidak spam (dedupingInterval).
- Dark/light toggle tetap work (SWRProvider tidak break `ThemeToggle`).
- Staging: `curl /api/search?q=test&mode=hybrid`, `/api/sections`, `/api/docs` return shape konsisten dengan tRPC.
## 9. Risiko & Mitigasi
- RSC + SWR double fetch: mitigasi dengan `fallbackData` dari RSC props ke SWR hook di islands (opsi, tidak wajib v1).
- Peningkatan request ke DB: SWR deduping 4s + `revalidateOnFocus: false` untuk search, `true` untuk sections/docs.
- Auth untuk `/api/queue/status`: jadikan public read-only (hanya counts, bukan detail job) — aman.
## 10. Keputusan yang Perlu Kamu Konfirmasi
1. **Dashboard `/dashboard` perlu tidak?** Atau cukup queue badge di existing pages? Rekomendasi: bikin, tapi Phase 2 — bisa ditunda.
2. **Endpoint baru (`/api/docs/[slug]`, `/api/related`, `/api/revisions` GET, `/api/queue/status`, `/api/tags`) setuju?** Alternatif: FE langsung pakai tRPC client (`@trpc/client`) — tapi spec ini pilih REST `/api/*` supaya Next cache & SWR fetcher simpel.
3. **SWR vs tRPC+SWR?** Spec ini pakai SWR+REST (paling simpel, tidak tambah `@trpc/client` di FE). Jika mau tRPC, tambah `createTRPCClient` + `httpBatchLink` — tradeoff: lebih typed tapi lebih berat. Default: SWR+REST.
4. **Keep RSC server fetch untuk SEO atau full client SWR?** Rekomendasi: keep RSC untuk initial HTML (SEO), SWR untuk live revalidation di client islands — hybrid, bukan full SWR.
---
Jawab: `Lanjut Phase 0+1` untuk eksekusi, atau beri koreksi poin 10.
+49
View File
@@ -0,0 +1,49 @@
# MCPedia — Nix-based CI/CD Migration Plan
## Goal
Migrate from tarball+SSH deploy to Nix-native build-and-deploy (like GMW).
CI builds with Nix in GitHub Actions → `nix copy` closure to VPS →
`nix-env --set` + `systemctl restart` on VPS. VPS never builds.
## Services to migrate
| Service | Build | Runtime | systemd unit |
|---------|-------|---------|-------------|
| web (Next.js) | `bun run build` → `.next` | `next start` | `mcpedia-web` |
| api (Hono) | `bun build src/index.ts` → `dist/index.js` | `bun run src/index.ts` | `mcpedia-api` |
| mcp (MCP server) | `bun build src/http.ts` → `dist/http.js` | `bun run src/http.ts` | `mcpedia-mcp` |
| worker (BullMQ) | `bun build src/index.ts` → `dist/index.js` | `bun run src/index.ts` | `mcpedia-worker` |
## Changes
### 1. flake.nix (new)
- `nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"` (Bun >=1.3)
- 4 packages: `web`, `api`, `mcp`, `worker`
- Each: `mkDerivation` with `bun` as nativeBuildInput, `sandbox=false`
- Filter source to exclude `.next`, `node_modules`, `.git`
### 2. CI workflow (.github/workflows/ci.yml — rewrite)
- `test` job: typecheck + test (Bun, as before)
- `build-and-deploy` job: matrix [web, api, mcp, worker]
- `DeterminateSystems/nix-installer-action@v16` with `determinate: false`, `sandbox=false`
- `DeterminateSystems/magic-nix-cache-action@v14` with `use-flakehub: false`
- `nix build .#<service>` → store path
- SSH setup (key sanitization)
- `nix copy --to ssh://...`
- `nix-env --profile /nix/var/nix/profiles/mcpedia-<service> --set <path>`
- `systemctl restart mcpedia-<service>`
### 3. systemd units (update on VPS)
- Change `ExecStart` from `bun run` to Nix profile binary path
- Profile path: `/nix/var/nix/profiles/mcpedia-web/bin/mcpedia-web`
- Keep `EnvironmentFile` pointing to `.env`
- Keep `Restart=always`
### 4. Deploy secrets
- `SSH_PRIVATE_KEY` (already exists as `SSH_DEPLOY_KEY`)
- `VPS_HOST`, `VPS_USER`, `VPS_SSH_PORT`
## Verification
- `nix build .#web --impure` builds
- `gh run list` shows all 4 services `completed success`
- `curl https://wiki.asepharyana.my.id/` returns 200
- `journalctl -u mcpedia-web` shows Next.js started
+1 -1
View File
@@ -18,7 +18,7 @@
"@mcpedia/queue": "workspace:*", "@mcpedia/queue": "workspace:*",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"hono": "^4.6.0", "hono": "^4.6.0",
"zod": "^3.23.8" "zod": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.6.0" "typescript": "^5.6.0"
+2 -2
View File
@@ -10,7 +10,7 @@
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"chat": "bun run src/index.ts", "chat": "bun run src/index.ts",
"ask": "bun run src/ask.ts", "ask": "bun run src/ask.ts",
"lint": "bun run lint", "lint": "tsc --noEmit",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "bun build src/index.ts --outdir=dist --format=esm", "build": "bun build src/index.ts --outdir=dist --format=esm",
"test": "bun test" "test": "bun test"
@@ -20,7 +20,7 @@
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^26",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^26",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
} }
+110 -164
View File
@@ -1,16 +1,31 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getDocument, getRelated, listRevisions, listDocuments, getExportDocuments } from "@mcpedia/core"; import { getDocument, listDocuments, getExportDocuments } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config"; import { WEBHOOK_SECRET } from "@mcpedia/config";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import DocForm from "@/components/DocForm"; import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC"; import TOC from "@/components/TOC";
import DocActions from "@/components/DocActions"; import DocActions from "@/components/DocActions";
import PdfExportView from "@/components/PdfExportView"; import PdfExportView from "@/components/PdfExportView";
import RelatedGrid from "@/components/RelatedGrid";
import RevisionList from "@/components/RevisionList";
import { classifyPath, extractFoldersForSection } from "@mcpedia/core"; import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
import { getSectionMeta } from "@mcpedia/config"; import { getSectionMeta } from "@mcpedia/config";
import type { DocumentMeta } from "@mcpedia/core"; import type { DocumentMeta } from "@mcpedia/core";
import {
Folder,
FileText,
Clock,
FileDown,
ArrowLeft,
CheckCircle2,
XCircle,
Target,
Trophy,
Zap,
Tag,
} from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -49,44 +64,41 @@ function CustomFieldBadges({
{customEntries.map(([key, value]) => { {customEntries.map(([key, value]) => {
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "); const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
let colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]"; let colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
let icon = "🏷️";
let displayValue: string; let displayValue: string;
let IconComponent = Tag;
if (typeof value === "number") { if (typeof value === "number") {
colorClass = "bg-[var(--brand)]/10 border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)]"; colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-semibold";
icon = "🎯"; IconComponent = Target;
displayValue = `${value} pts`; displayValue = `${value} pts`;
} else if (typeof value === "boolean") { } else if (typeof value === "boolean") {
colorClass = value colorClass = value
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400" ? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold"
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400"; : "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
icon = value ? "✓" : "✕"; IconComponent = value ? CheckCircle2 : XCircle;
displayValue = value ? "Solved" : "Pending"; displayValue = value ? "Solved" : "Pending";
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
colorClass = "bg-[var(--brand)]/10 border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)]"; colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
icon = "🗂️"; IconComponent = Folder;
displayValue = value.join(", "); displayValue = value.join(", ");
} else { } else {
const v = String(value).toLowerCase(); const v = String(value).toLowerCase();
if (/^(easy|simple|beginner)/i.test(v)) { if (/^(easy|simple|beginner)/i.test(v)) {
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"; colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold";
icon = "🟢";
displayValue = "Easy"; displayValue = "Easy";
} else if (/^(medium|intermediate)/i.test(v)) { } else if (/^(medium|intermediate)/i.test(v)) {
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400"; colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 font-semibold";
icon = "🟡";
displayValue = "Medium"; displayValue = "Medium";
} else if (/^(hard|expert|advanced)/i.test(v)) { } else if (/^(hard|expert|advanced)/i.test(v)) {
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400"; colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
icon = "🔴";
displayValue = "Hard"; displayValue = "Hard";
} else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) { } else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) {
colorClass = "bg-indigo-500/10 border-indigo-500/30 text-indigo-600 dark:text-indigo-300"; colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-bold";
icon = "⚡"; IconComponent = Zap;
displayValue = v.toUpperCase(); displayValue = v.toUpperCase();
} else if (/ctf|def.?con|event/i.test(v)) { } else if (/ctf|def.?con|event/i.test(v)) {
colorClass = "bg-[var(--brand)]/10 border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)]"; colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)]";
icon = "🏆"; IconComponent = Trophy;
displayValue = String(value); displayValue = String(value);
} else { } else {
displayValue = String(value); displayValue = String(value);
@@ -98,9 +110,9 @@ function CustomFieldBadges({
key={key} key={key}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-md text-xs font-mono transition-colors ${colorClass}`} className={`inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-md text-xs font-mono transition-colors ${colorClass}`}
> >
<span className="text-[10px] opacity-70">{icon}</span> <IconComponent className="w-3.5 h-3.5 opacity-80" />
<span className="text-[var(--text-muted)] font-sans">{label}:</span> <span className="text-[var(--text-muted)] font-sans">{label}:</span>
<span className="font-medium">{displayValue}</span> <span>{displayValue}</span>
</span> </span>
); );
})} })}
@@ -146,9 +158,9 @@ function FolderIndexPage({
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
return ( return (
<div> <div className="space-y-8">
{/* Breadcrumb */} {/* Breadcrumb Navigation */}
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-6 flex-wrap"> <nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] flex-wrap">
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors"> <Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
MCPedia MCPedia
</Link> </Link>
@@ -171,58 +183,59 @@ function FolderIndexPage({
})} })}
</nav> </nav>
{/* Header card */} {/* Header Card */}
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 mb-8 shadow-sm"> <div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3.5">
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-xl"> <div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-primary)]">
📁 <Folder className="w-5 h-5" />
</div> </div>
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight"> <h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
{folderName} {folderName}
</h1> </h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5"> <p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
Directory path: <code className="text-[var(--brand)] dark:text-[var(--accent)] font-mono">{section}/{slug}</code> Directory: <code>{section}/{slug}</code>
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded-md"> <span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""} {immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
</span> </span>
<Link <Link
href={`/${section}/${slug}/export`} href={`/${section}/${slug}/export`}
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white px-3 py-1.5 rounded-lg font-medium transition-all shadow-xs" className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1.5 rounded font-semibold transition-all shadow-xs"
title={`Export all ${folderName} documents as a single PDF report`} title={`Export all ${folderName} documents as a single PDF report`}
> >
<span>📄</span> <FileDown className="w-3.5 h-3.5" />
<span>Export PDF Report</span> <span>Export PDF</span>
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
{/* Subfolders */} {/* Subfolders Grid */}
{folders.length > 0 && ( {folders.length > 0 && (
<div className="mb-8"> <div>
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2"> <h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
<span>Subfolders</span> <Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span className="text-[10px] text-[var(--text-dim)]">({folders.length})</span> <span>Subfolders ({folders.length})</span>
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{folders.map(([folder, info]) => ( {folders.map(([folder, info]) => (
<Link <Link
key={folder} key={folder}
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")} href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg transition-all shadow-sm" className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
> >
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span> <Folder className="w-4 h-4 text-[var(--text-muted)]" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors truncate"> <div className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} {folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
</div> </div>
<div className="text-xs text-[var(--text-muted)] mt-0.5 font-mono"> <div className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
{info.docCount} document{info.docCount !== 1 ? "s" : ""} {info.docCount} document{info.docCount !== 1 ? "s" : ""}
</div> </div>
</div> </div>
@@ -232,32 +245,32 @@ function FolderIndexPage({
</div> </div>
)} )}
{/* Documents */} {/* Documents List */}
{immediateDocs.length > 0 && ( {immediateDocs.length > 0 && (
<div> <div>
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2"> <h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
<span>Documents</span> <FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span className="text-[10px] text-[var(--text-dim)]">({immediateDocs.length})</span> <span>Documents ({immediateDocs.length})</span>
</h2> </h2>
<div className="space-y-2"> <div className="space-y-2">
{immediateDocs.map((doc) => ( {immediateDocs.map((doc) => (
<Link <Link
key={doc.slug} key={doc.slug}
href={`/${doc.slug}`} href={`/${doc.slug}`}
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg transition-all shadow-sm" className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<span className="text-xl opacity-70 group-hover:opacity-100">📄</span> <FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors truncate"> <div className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{doc.title} {doc.title}
</div> </div>
{doc.tags.length > 0 && ( {doc.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5"> <div className="flex flex-wrap gap-1 mt-1.5">
{doc.tags.slice(0, 4).map((t) => ( {doc.tags.slice(0, 4).map((t) => (
<span <span
key={t} key={t}
className="text-[11px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]" className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
> >
#{t} #{t}
</span> </span>
@@ -266,11 +279,15 @@ function FolderIndexPage({
)} )}
</div> </div>
</div> </div>
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono">
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>
{new Date(doc.updatedAt).toLocaleDateString(undefined, { {new Date(doc.updatedAt).toLocaleDateString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
</span>
</time> </time>
</Link> </Link>
))} ))}
@@ -279,8 +296,8 @@ function FolderIndexPage({
)} )}
{folders.length === 0 && immediateDocs.length === 0 && ( {folders.length === 0 && immediateDocs.length === 0 && (
<div className="text-center py-12 border border-[var(--border-color)] rounded-xl bg-[var(--bg-surface)]"> <div className="text-center py-12 border border-[var(--border-color)] rounded-lg bg-[var(--bg-surface)]">
<p className="text-sm text-[var(--text-muted)]">No documents found in this directory.</p> <p className="text-sm text-[var(--text-muted)]">No documents in this directory.</p>
</div> </div>
)} )}
</div> </div>
@@ -292,7 +309,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const searchParamsObj = await searchParams; const searchParamsObj = await searchParams;
const { edit, path: queryPath, sort } = searchParamsObj; const { edit, path: queryPath, sort } = searchParamsObj;
// Handle direct export views: /[section]/export, /[section]/[...folder]/export, or /export/pdf // Handle direct export views
if (section === "export" || (slug && slug[slug.length - 1] === "export")) { if (section === "export" || (slug && slug[slug.length - 1] === "export")) {
const isTrailingExport = slug && slug[slug.length - 1] === "export"; const isTrailingExport = slug && slug[slug.length - 1] === "export";
const targetSlug = isTrailingExport ? slug.slice(0, -1) : slug; const targetSlug = isTrailingExport ? slug.slice(0, -1) : slug;
@@ -331,14 +348,15 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
if (edit === "1" && canEdit) { if (edit === "1" && canEdit) {
const existingFolders = extractFoldersForSection(docSlugs, doc.section); const existingFolders = extractFoldersForSection(docSlugs, doc.section);
return ( return (
<div> <div className="space-y-6">
<Link <Link
href={`/${doc.slug}`} href={`/${doc.slug}`}
className="inline-flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] mb-4 transition-colors" className="inline-flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
> >
<span>← Back to document</span> <ArrowLeft className="w-3.5 h-3.5" />
<span>Back to document</span>
</Link> </Link>
<h1 className="text-2xl font-bold text-[var(--text-primary)] mb-6"> <h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
Edit: {doc.title} Edit: {doc.title}
</h1> </h1>
<DocForm <DocForm
@@ -364,8 +382,6 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
); );
} }
const related = await getRelated(fullSlug, 4);
const revisions = await listRevisions(fullSlug, 10);
const sectionInfo = getSectionInfo(doc.section); const sectionInfo = getSectionInfo(doc.section);
const readingTime = calculateReadingTime(doc.body); const readingTime = calculateReadingTime(doc.body);
@@ -373,8 +389,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
<div className="flex items-start gap-8"> <div className="flex items-start gap-8">
{/* Main Content Column */} {/* Main Content Column */}
<article className="flex-1 min-w-0"> <article className="flex-1 min-w-0">
{/* Breadcrumb */} {/* Breadcrumbs Navigation */}
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap"> <nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap font-mono">
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors"> <Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
MCPedia MCPedia
</Link> </Link>
@@ -389,7 +405,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
<span>/</span> <span>/</span>
<Link <Link
href={`/${crumbPath}`} href={`/${crumbPath}`}
className={isLast ? "text-[var(--text-primary)] font-semibold" : "hover:text-[var(--text-primary)] transition-colors"} className={isLast ? "text-[var(--text-primary)] font-semibold font-sans" : "hover:text-[var(--text-primary)] transition-colors font-sans"}
> >
{label} {label}
</Link> </Link>
@@ -399,24 +415,26 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
</nav> </nav>
{/* Hero Metadata Card */} {/* Hero Metadata Card */}
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 mb-8 shadow-sm"> <div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-7 mb-8 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap text-xs">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium bg-[var(--brand)]/15 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] uppercase">
<span>{sectionInfo.icon}</span> <span>{sectionInfo.icon}</span>
<span className="uppercase">{sectionInfo.label}</span> <span>{sectionInfo.label}</span>
</span>
<span className="text-xs text-[var(--text-dim)]">·</span>
<span className="text-xs text-[var(--text-muted)]">
{doc.type.charAt(0).toUpperCase() + doc.type.slice(1)}
</span> </span>
<span className="text-xs text-[var(--text-dim)]">·</span> <span className="text-xs text-[var(--text-dim)]">·</span>
<span className="text-xs text-[var(--text-muted)] font-mono"> <span className="text-xs text-[var(--text-muted)] font-mono">
{readingTime} {doc.type}
</span>
<span className="text-xs text-[var(--text-dim)]">·</span>
<span className="text-xs text-[var(--text-muted)] font-mono flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{readingTime}</span>
</span> </span>
</div> </div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight leading-tight">
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
{doc.title} {doc.title}
</h1> </h1>
</div> </div>
@@ -425,24 +443,27 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
<DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} /> <DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
</div> </div>
{/* Author + Date + Tags */} {/* Author, Date, Tags */}
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3 border-t border-[var(--border-color)]"> <div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3.5 border-t border-[var(--border-color)]">
{doc.author && ( {doc.author && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded-full bg-[var(--brand)] text-white flex items-center justify-center text-[10px] font-bold"> <div className="w-4 h-4 rounded bg-[var(--brand)] text-[var(--brand-fg)] flex items-center justify-center text-[10px] font-bold font-mono">
{doc.author.charAt(0).toUpperCase()} {doc.author.charAt(0).toUpperCase()}
</div> </div>
<span className="text-[var(--text-secondary)]">{doc.author}</span> <span className="text-[var(--text-secondary)] font-medium">{doc.author}</span>
<span className="text-[var(--text-dim)]">·</span> <span className="text-[var(--text-dim)]">·</span>
</div> </div>
)} )}
<span className="flex items-center gap-1 font-mono text-[11px]">
<span> <span>
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, { Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
month: "long", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
})} })}
</span> </span>
</span>
{doc.tags.length > 0 && ( {doc.tags.length > 0 && (
<> <>
<span className="text-[var(--text-dim)]">·</span> <span className="text-[var(--text-dim)]">·</span>
@@ -450,7 +471,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
{doc.tags.map((t) => ( {doc.tags.map((t) => (
<span <span
key={t} key={t}
className="px-2 py-0.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[11px]" className="px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[10px] font-mono"
> >
#{t} #{t}
</span> </span>
@@ -464,93 +485,18 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
<CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} /> <CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
</div> </div>
{/* Mobile TOC (rendered inline on smaller screens) */} {/* Mobile TOC */}
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg shadow-sm"> <div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
<TOC source={doc.body} /> <TOC source={doc.body} />
</div> </div>
{/* Markdown content */} {/* Markdown Content */}
<div className="mb-14"> <div className="mb-14">
<Markdown source={doc.body} /> <Markdown source={doc.body} />
</div> </div>
{/* Related Documents */} <RelatedGrid slug={fullSlug} />
{related.length > 0 && ( <RevisionList slug={fullSlug} canEdit={canEdit} />
<aside className="mt-12 pt-8 border-t border-[var(--border-color)]">
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2">
<span>Related Knowledge</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{related.map((r) => {
const rInfo = getSectionInfo(r.section);
return (
<Link
key={r.slug}
href={`/${r.slug}`}
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg p-4 transition-all shadow-sm"
>
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs text-[var(--brand)] dark:text-[var(--accent)] font-mono uppercase">
{rInfo.icon} {r.section}
</span>
<time className="text-[11px] text-[var(--text-dim)] font-mono">
{new Date(r.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors line-clamp-1 mb-1">
{r.title}
</h3>
</Link>
);
})}
</div>
</aside>
)}
{/* Revision History */}
{revisions.length > 0 && (
<aside className="mt-10 pt-8 border-t border-[var(--border-color)]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Revision History ({revisions.length})
</h2>
</div>
<div className="space-y-2">
{revisions.map((rev) => (
<div
key={rev.id}
className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 px-1.5 py-0.5 rounded border border-[var(--brand)]/30 font-semibold">
v{rev.revisionNo}
</span>
<span className="text-[var(--text-primary)] truncate">
{rev.reason || "Updated document"}
</span>
<span className="text-[var(--text-dim)] hidden sm:inline">
· {new Date(rev.createdAt).toLocaleString()}
</span>
</div>
{canEdit && (
<form action="/api/revisions/restore" method="post">
<input type="hidden" name="id" value={rev.id} />
<button
type="submit"
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] hover:border-[var(--brand)] transition-colors font-medium shadow-sm"
>
Restore
</button>
</form>
)}
</div>
))}
</div>
</aside>
)}
</article> </article>
{/* Desktop Sticky Table of Contents Column */} {/* Desktop Sticky Table of Contents Column */}
+55 -32
View File
@@ -1,6 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { listDocuments } from "@mcpedia/core"; import { listDocuments } from "@mcpedia/core";
import { getSectionMeta } from "@mcpedia/config"; import { getSectionMeta } from "@mcpedia/config";
import {
Folder,
FileText,
FileDown,
Plus,
Clock,
} from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -81,26 +88,28 @@ function renderTree(nodes: TreeNode[], pathname: string) {
<li key={node.slug}> <li key={node.slug}>
<Link <Link
href={`/${node.slug}`} href={`/${node.slug}`}
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-lg transition-all ${ className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-md transition-colors ${
isActive isActive
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-semibold border border-[var(--brand)]/30" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
: hasActiveChild : hasActiveChild
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`} }`}
> >
<span className="flex items-center gap-2 truncate"> <span className="flex items-center gap-2 truncate">
<span>{isFolder ? "📁" : "📄"}</span> <span className="text-[var(--text-dim)]">
<span className="truncate">{node.doc?.title || node.name}</span> {isFolder ? <Folder className="w-3.5 h-3.5" /> : <FileText className="w-3.5 h-3.5" />}
</span>
<span className="truncate font-medium">{node.doc?.title || node.name}</span>
</span> </span>
{isFolder && ( {isFolder && (
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.5 rounded font-mono"> <span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
{node.docCount} docs {node.docCount} docs
</span> </span>
)} )}
</Link> </Link>
{node.children.length > 0 && ( {node.children.length > 0 && (
<div className="ml-4 border-l border-[var(--border-color)] pl-2 mt-1"> <div className="ml-3.5 border-l border-[var(--border-color)] pl-2.5 mt-0.5 space-y-0.5">
{renderTree(node.children, pathname)} {renderTree(node.children, pathname)}
</div> </div>
)} )}
@@ -124,67 +133,73 @@ export default async function SectionIndexPage({
const tree = buildFolderTree(allDocs, section); const tree = buildFolderTree(allDocs, section);
return ( return (
<div className="space-y-10"> <div className="space-y-8">
{/* Breadcrumb */} {/* Breadcrumb Navigation */}
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)]"> <nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] font-mono">
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors"> <Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
MCPedia MCPedia
</Link> </Link>
<span>/</span> <span>/</span>
<span className="text-[var(--text-muted)] font-medium">{sectionInfo.label}</span> <span className="text-[var(--text-muted)] font-semibold font-sans">{sectionInfo.label}</span>
</nav> </nav>
{/* Hero Card */} {/* Hero Card */}
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm"> <div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3.5">
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl shadow-inner"> <div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl">
{sectionInfo.icon} {sectionInfo.icon}
</div> </div>
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight"> <h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
{sectionInfo.label} {sectionInfo.label}
</h1> </h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono uppercase"> <p className="text-xs text-[var(--text-muted)] font-mono uppercase tracking-wider mt-0.5">
Section: {section} Section: <code>{section}</code>
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap"> <div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 border border-[var(--brand)]/25 px-2.5 py-1 rounded-md"> <span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""} {sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
</span> </span>
{sectionDocs.length > 0 && ( {sectionDocs.length > 0 && (
<Link <Link
href={`/${section}/export`} href={`/${section}/export`}
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)]/10 hover:bg-[var(--brand)]/20 border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 text-[var(--brand)] dark:text-[var(--accent)] px-3 py-1 rounded-md font-medium transition-all shadow-xs" className="inline-flex items-center gap-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
title={`Export all ${sectionInfo.label} documents to PDF`} title={`Export all ${sectionInfo.label} documents to publication-grade PDF`}
> >
<span>📄</span> <FileDown className="w-3.5 h-3.5" />
<span>Export PDF</span> <span>Export PDF</span>
</Link> </Link>
)} )}
<Link <Link
href="/create" href="/create"
className="inline-flex items-center gap-1 text-xs bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white px-3 py-1 rounded-md font-medium transition-all" className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
> >
<span>+ Create</span> <Plus className="w-3.5 h-3.5" />
<span>Create Doc</span>
</Link> </Link>
</div> </div>
</div> </div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2"> <p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
{sectionInfo.desc} {sectionInfo.desc}
</p> </p>
</div> </div>
{/* Hierarchical Folder Directory */} {/* Hierarchical Folder Directory */}
{tree.length > 0 && ( {tree.length > 0 && (
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm"> <section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2"> <div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
<span>Folder Structure</span> <Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
Folder & Chapter Structure
</h2> </h2>
</div>
{renderTree(tree, section)} {renderTree(tree, section)}
</section> </section>
)} )}
@@ -192,9 +207,13 @@ export default async function SectionIndexPage({
{/* Document List */} {/* Document List */}
{sectionDocs.length > 0 ? ( {sectionDocs.length > 0 ? (
<section> <section>
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4"> <div className="flex items-center gap-2 mb-3.5">
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
All Documents in {sectionInfo.label} All Documents in {sectionInfo.label}
</h2> </h2>
</div>
<div className="space-y-2"> <div className="space-y-2">
{[...sectionDocs] {[...sectionDocs]
.sort( .sort(
@@ -205,12 +224,12 @@ export default async function SectionIndexPage({
<Link <Link
key={d.slug} key={d.slug}
href={`/${d.slug}`} href={`/${d.slug}`}
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl transition-all shadow-sm" className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl transition-all shadow-xs"
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<span className="text-lg opacity-70 group-hover:opacity-100">📄</span> <FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-medium text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors truncate"> <h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{d.title} {d.title}
</h3> </h3>
{d.tags.length > 0 && ( {d.tags.length > 0 && (
@@ -218,7 +237,7 @@ export default async function SectionIndexPage({
{d.tags.slice(0, 4).map((t) => ( {d.tags.slice(0, 4).map((t) => (
<span <span
key={t} key={t}
className="text-[10px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]" className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
> >
#{t} #{t}
</span> </span>
@@ -227,12 +246,16 @@ export default async function SectionIndexPage({
)} )}
</div> </div>
</div> </div>
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0">
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0 flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>
{new Date(d.updatedAt).toLocaleDateString(undefined, { {new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
})} })}
</span>
</time> </time>
</Link> </Link>
))} ))}
+13 -8
View File
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
return `${value}.${sig}`; return `${value}.${sig}`;
} }
function safeTimingEqual(a: string, b: string): boolean {
try {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
} catch {
return false;
}
}
function verifyCookie(cookieValue: string | undefined): boolean { function verifyCookie(cookieValue: string | undefined): boolean {
if (!cookieValue) return false; if (!cookieValue) return false;
const [value, sig] = cookieValue.split("."); const [value, sig] = cookieValue.split(".");
if (!value || !sig) return false; if (!value || !sig) return false;
const expected = signCookie(value); const expected = signCookie(value);
return timingSafeEqual( return safeTimingEqual(cookieValue, expected);
Buffer.from(cookieValue),
Buffer.from(expected),
);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -32,10 +40,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
const ok = timingSafeEqual( const ok = safeTimingEqual(password, ADMIN_PASSWORD);
Buffer.from(password),
Buffer.from(ADMIN_PASSWORD),
);
if (!ok) { if (!ok) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
+5 -1
View File
@@ -6,9 +6,13 @@ import { timingSafeEqual } from "node:crypto";
function isAuthorized(req: NextRequest): boolean { function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false; if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? ""; const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true; return true;
} }
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin."); return cookie.startsWith("admin.");
} }
+14 -3
View File
@@ -13,9 +13,13 @@ import { timingSafeEqual } from "node:crypto";
function isAuthorized(req: NextRequest): boolean { function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false; if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? ""; const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true; return true;
} }
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin."); return cookie.startsWith("admin.");
} }
@@ -74,8 +78,15 @@ function splitPayload(body: Record<string, unknown>): {
} }
// GET /api/docs — list all documents (for sidebar navigation). // GET /api/docs — list all documents (for sidebar navigation).
export async function GET() { // Supports ?section=&status= filters.
const docs = await listDocuments(); export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const section = searchParams.get("section") ?? undefined;
const status = searchParams.get("status") ?? undefined;
const docs = await listDocuments({
...(section ? { section } : {}),
...(status ? { status } : {}),
});
return NextResponse.json(docs); return NextResponse.json(docs);
} }
+23 -4
View File
@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getExportDocuments, compileExportMarkdown, type ExportSortOption } from "@mcpedia/core"; import {
getExportDocuments,
compileExportMarkdown,
generateExportDocx,
type ExportSortOption,
} from "@mcpedia/core";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,6 +16,8 @@ export async function GET(req: NextRequest) {
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points"; const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
const slugsParam = searchParams.get("slugs"); const slugsParam = searchParams.get("slugs");
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined; const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
const pageBreaksParam = searchParams.get("pageBreaks");
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
try { try {
const data = await getExportDocuments({ const data = await getExportDocuments({
@@ -20,13 +27,25 @@ export async function GET(req: NextRequest) {
sortBy, sortBy,
}); });
if (format === "markdown" || format === "md") {
const md = compileExportMarkdown(data);
const safeFilename = data.summary.title const safeFilename = data.summary.title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, ""); .replace(/^-|-$/g, "") || "mcpedia-export";
if (format === "docx" || format === "word") {
const buffer = await generateExportDocx(data, { pageBreaks });
const uint8 = new Uint8Array(buffer);
return new NextResponse(uint8, {
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Disposition": `attachment; filename="${safeFilename}.docx"`,
},
});
}
if (format === "markdown" || format === "md") {
const md = compileExportMarkdown(data);
return new NextResponse(md, { return new NextResponse(md, {
headers: { headers: {
"Content-Type": "text/markdown; charset=utf-8", "Content-Type": "text/markdown; charset=utf-8",
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
// GET /api/queue/status — BullMQ queue counts (read-only, public)
// Falls back gracefully when Redis is unavailable so the UI never breaks.
export async function GET() {
try {
const { getQueue } = await import("@mcpedia/queue");
const q = getQueue();
const [waiting, active, completed, failed, delayed] = await Promise.all([
q.getWaitingCount(),
q.getActiveCount(),
q.getCompletedCount(),
q.getFailedCount(),
q.getDelayedCount(),
]);
return NextResponse.json({ waiting, active, completed, failed, delayed });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Return zeroed status so the FE can render gracefully
return NextResponse.json(
{ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0, error: msg },
{ status: 200 },
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getRelated } from "@mcpedia/core";
export const dynamic = "force-dynamic";
// GET /api/related?slug=...&limit=5
export async function GET(req: NextRequest) {
const slug = new URL(req.url).searchParams.get("slug") ?? "";
const limit = Math.min(Math.max(Number(new URL(req.url).searchParams.get("limit") ?? 5), 1), 20);
if (!slug.trim()) {
return NextResponse.json({ results: [] });
}
try {
const results = await getRelated(slug, limit);
return NextResponse.json({ results });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json({ results: [], error: msg }, { status: 500 });
}
}
@@ -1,12 +1,32 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { restoreRevision, getRevision } from "@mcpedia/core"; import { restoreRevision, getRevision } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { timingSafeEqual } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
function isAuthorized(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return false;
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
if (headerSecret) {
const bufA = Buffer.from(headerSecret);
const bufB = Buffer.from(WEBHOOK_SECRET);
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
return true;
}
}
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
return cookie.startsWith("admin.");
}
// POST /api/revisions/restore — restore a document to a past revision. // POST /api/revisions/restore — restore a document to a past revision.
// Body (form-urlencoded): id=<revision uuid> // Body (form-urlencoded): id=<revision uuid>
// After restoring, we rebuild semantic chunks (handled inside restoreRevision) // After restoring, we rebuild semantic chunks (handled inside restoreRevision)
// and revalidate the doc page so the Web UI reflects the restored body. // and revalidate the doc page so the Web UI reflects the restored body.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData().catch(() => null); const form = await req.formData().catch(() => null);
const id = form?.get("id"); const id = form?.get("id");
if (typeof id !== "string" || id.length === 0) { if (typeof id !== "string" || id.length === 0) {
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { listRevisions } from "@mcpedia/core";
export const dynamic = "force-dynamic";
// GET /api/revisions?slug=...&limit=10
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const slug = searchParams.get("slug") ?? "";
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? 10), 1), 50);
if (!slug.trim()) {
return NextResponse.json({ revisions: [] });
}
try {
const revisions = await listRevisions(slug, limit);
return NextResponse.json({ revisions });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json({ revisions: [], error: msg }, { status: 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { listDocuments } from "@mcpedia/core";
export const dynamic = "force-dynamic";
// GET /api/tags?limit=30 — aggregated tag counts
export async function GET(req: Request) {
const limit = Math.min(Math.max(Number(new URL(req.url).searchParams.get("limit") ?? 30), 1), 100);
try {
const docs = await listDocuments();
const counts = new Map<string, number>();
for (const d of docs) {
for (const t of d.tags ?? []) {
const tag = String(t).trim();
if (!tag) continue;
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
const tags = [...counts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
.slice(0, limit);
return NextResponse.json({ tags });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json({ tags: [], error: msg }, { status: 500 });
}
}
+90 -102
View File
@@ -3,28 +3,46 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSearch } from "@/hooks/useSearch";
import {
Search,
Plus,
ArrowRight,
BookOpen,
Flag,
Sparkles,
} from "lucide-react";
interface Hit { interface QuickAction {
slug: string; id: string;
title: string; title: string;
section: string; subtitle: string;
snippet?: string; icon: React.ComponentType<{ className?: string }>;
score: number; href?: string;
} }
export default function CommandMenu() { export default function CommandMenu() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<Hit[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const router = useRouter(); const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Global shortcut ⌘K or / const { hits, isSearching, isValidating } = useSearch(query, "hybrid", 10);
const results = query.trim() ? hits : [];
const loading = isSearching || isValidating;
const quickActions: QuickAction[] = [
{ id: "create-doc", title: "Create New Document", subtitle: "Write technical doc, writeup, or note", icon: Plus, href: "/create" },
{ id: "hybrid-search", title: "Multi-Modal Hybrid Search", subtitle: "Full-text + Semantic vector search console", icon: Search, href: "/search" },
{ id: "browse-docs", title: "Documentation Hub", subtitle: "System setup, API references, protocol specs", icon: BookOpen, href: "/docs" },
{ id: "browse-writeups", title: "CTF & Writeups Directory", subtitle: "Pwn, Reverse, Web, Crypto writeups and solutions", icon: Flag, href: "/writeups" },
{ id: "browse-research", title: "Research & Benchmarks", subtitle: "Architecture notes, benchmarks, experiments", icon: Sparkles, href: "/research" },
];
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault(); e.preventDefault();
setOpen((prev) => !prev); setOpen((prev) => !prev);
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) { } else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
@@ -42,48 +60,35 @@ export default function CommandMenu() {
if (open) { if (open) {
setTimeout(() => inputRef.current?.focus(), 50); setTimeout(() => inputRef.current?.focus(), 50);
setQuery(""); setQuery("");
setResults([]);
setSelectedIndex(0); setSelectedIndex(0);
} }
}, [open]); }, [open]);
// Debounced search
useEffect(() => { useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=8`);
const data = await res.json();
setResults(data.results || []);
setSelectedIndex(0); setSelectedIndex(0);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 150);
return () => clearTimeout(timer);
}, [query]); }, [query]);
function handleNavigate(slug: string) { function handleNavigate(href: string) {
setOpen(false); setOpen(false);
router.push(`/${slug}`); router.push(href);
} }
const totalItems = query.trim() ? results.length : quickActions.length;
function handleKeyNavigation(e: React.KeyboardEvent) { function handleKeyNavigation(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => (results.length > 0 ? (prev + 1) % results.length : 0)); setSelectedIndex((prev) => (totalItems > 0 ? (prev + 1) % totalItems : 0));
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => (results.length > 0 ? (prev - 1 + results.length) % results.length : 0)); setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
} else if (e.key === "Enter" && results[selectedIndex]) { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
handleNavigate(results[selectedIndex].slug); if (query.trim() && results[selectedIndex]) {
handleNavigate(`/${results[selectedIndex].slug}`);
} else if (!query.trim() && quickActions[selectedIndex]?.href) {
handleNavigate(quickActions[selectedIndex].href!);
}
} }
} }
@@ -91,102 +96,92 @@ export default function CommandMenu() {
return ( return (
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-sm" type="button"
className="hidden md:flex items-center gap-2.5 px-3 py-1.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer group"
> >
<svg className="w-3.5 h-3.5 text-[var(--text-dim)]" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <span className="font-sans">Search...</span>
</svg> <kbd className="ml-2 font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-muted)]">⌘K</kbd>
<span>Search docs...</span>
<kbd className="ml-2 font-mono bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-dim)]">
⌘K
</kbd>
</button> </button>
); );
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4"> <div className="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
{/* Backdrop */} <div className="fixed inset-0 bg-black/60 backdrop-blur-xs transition-opacity" onClick={() => setOpen(false)} />
<div
className="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm transition-opacity"
onClick={() => setOpen(false)}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in"> <div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in">
{/* Search input bar */} <div className="flex items-center gap-3 px-4 sm:px-5 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]"> <Search className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<svg className="w-5 h-5 text-[var(--brand)] dark:text-[var(--accent)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyNavigation} onKeyDown={handleKeyNavigation}
placeholder="Type to search (titles, keywords, concepts)..." placeholder="Search documents, writeups, concepts, or tools..."
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none" className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
/> />
{loading && ( {loading && <div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin shrink-0" />}
<div className="w-4 h-4 border-2 border-[var(--brand)] border-t-transparent rounded-full animate-spin shrink-0" /> <button onClick={() => setOpen(false)} type="button" className="text-[10px] font-mono text-[var(--text-dim)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]">ESC</button>
)}
<button
onClick={() => setOpen(false)}
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
>
ESC
</button>
</div> </div>
{/* Results / Suggestions */} <div className="max-h-[22rem] overflow-y-auto p-2">
<div className="max-h-96 overflow-y-auto p-2">
{query.trim() === "" ? ( {query.trim() === "" ? (
<div className="px-3 py-6 text-center"> <div className="p-1 space-y-0.5">
<p className="text-xs text-[var(--text-muted)]">Search full-text, semantic embeddings, and CTF writeups.</p> <div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Quick Shortcuts</div>
<div className="flex justify-center gap-2 mt-3 text-[11px] text-[var(--text-dim)]"> {quickActions.map((action, idx) => {
<span>Navigation: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↑</kbd> <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↓</kbd></span> const Icon = action.icon;
<span>Select: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↵</kbd></span> const isSelected = idx === selectedIndex;
return (
<div
key={action.id}
onClick={() => action.href && handleNavigate(action.href)}
onMouseEnter={() => setSelectedIndex(idx)}
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${isSelected ? "bg-[var(--bg-elevated)] text-[var(--text-primary)]" : "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)]"}`}
>
<div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-3.5 h-3.5" />
</div> </div>
<div>
<div className="text-xs font-semibold text-[var(--text-primary)]">{action.title}</div>
<div className="text-[11px] text-[var(--text-muted)]">{action.subtitle}</div>
</div>
</div>
<ArrowRight className={`w-3.5 h-3.5 text-[var(--text-dim)] ${isSelected ? "text-[var(--text-primary)]" : ""}`} />
</div>
);
})}
</div> </div>
) : results.length === 0 && !loading ? ( ) : results.length === 0 && !loading ? (
<div className="px-4 py-8 text-center text-xs text-[var(--text-muted)]"> <div className="px-4 py-10 text-center text-xs text-[var(--text-muted)] space-y-1">
No documents matching &quot;{query}&quot; <p>No knowledge base items matched &quot;{query}&quot;</p>
</div> </div>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1 p-1">
{results.map((hit, index) => { {results.map((hit, index) => {
const isSelected = index === selectedIndex; const isSelected = index === selectedIndex;
return ( return (
<li <li
key={hit.slug} key={hit.slug}
onClick={() => handleNavigate(hit.slug)} onClick={() => handleNavigate(`/${hit.slug}`)}
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all ${ className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${isSelected ? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]" : "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"}`}
isSelected
? "bg-[var(--brand)]/15 border border-[var(--brand)]/40 text-[var(--text-primary)]"
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
}`}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 mb-0.5">
<span className="text-xs px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--brand)] dark:text-[var(--accent)] font-mono"> <span className="text-[10px] font-mono px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] uppercase font-semibold">{hit.section}</span>
{hit.section} <span className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] truncate">{hit.title}</span>
</span>
<span className={`text-sm font-medium ${isSelected ? "text-[var(--brand)] dark:text-[var(--accent)]" : "text-[var(--text-primary)]"} truncate`}>
{hit.title}
</span>
</div> </div>
{hit.snippet && ( {hit.snippet && (
<p <p
className="text-xs text-[var(--text-muted)] mt-1 line-clamp-1 [&>mark]:bg-[var(--brand)]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:rounded [&>mark]:px-0.5" className="text-xs text-[var(--text-muted)] line-clamp-1 mt-1 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
// snippet is server-sanitized ts_headline output
dangerouslySetInnerHTML={{ __html: hit.snippet }} dangerouslySetInnerHTML={{ __html: hit.snippet }}
/> />
)} )}
</div> </div>
<span className="text-[11px] text-[var(--text-dim)] shrink-0 self-center"> <span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">/{hit.slug}</span>
{hit.slug}
</span>
</li> </li>
); );
})} })}
@@ -194,16 +189,9 @@ export default function CommandMenu() {
)} )}
</div> </div>
{/* Footer */} <div className="flex items-center justify-between px-4 py-2 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[10px] text-[var(--text-muted)] font-mono">
<div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[11px] text-[var(--text-dim)]"> <div className="flex items-center gap-2"><span>[ ↑↓ NAVIGATE ]</span><span>[ ↵ SELECT ]</span></div>
<span>Tip: Press <kbd className="bg-[var(--bg-surface)] px-1 py-0.5 rounded border border-[var(--border-color)]">Hybrid</kbd> search is enabled</span> <Link href={`/search?q=${encodeURIComponent(query)}`} onClick={() => setOpen(false)} className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1"><span>Full Search Console</span><ArrowRight className="w-3 h-3" /></Link>
<Link
href={`/search?q=${encodeURIComponent(query)}`}
onClick={() => setOpen(false)}
className="hover:text-[var(--brand)] dark:hover:text-[var(--accent)] transition-colors"
>
Open full search page →
</Link>
</div> </div>
</div> </div>
</div> </div>
+33 -27
View File
@@ -2,6 +2,14 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import {
Copy,
Check,
Share2,
FileDown,
FileText,
Edit3,
} from "lucide-react";
interface DocActionsProps { interface DocActionsProps {
slug: string; slug: string;
@@ -27,49 +35,41 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
return ( return (
<div className="flex items-center gap-1.5 shrink-0 flex-wrap"> <div className="flex items-center gap-1.5 shrink-0 flex-wrap">
{/* Copy Markdown */} {/* Copy Raw Markdown */}
<button <button
onClick={handleCopyMarkdown} onClick={handleCopyMarkdown}
type="button" type="button"
title="Copy raw markdown content" title="Copy raw markdown content"
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm" className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
> >
{copiedMd ? ( {copiedMd ? (
<> <>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Check className="w-3.5 h-3.5 text-emerald-500" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <span className="text-emerald-500 font-medium">Copied</span>
</svg>
<span className="text-emerald-500 font-medium">Copied MD</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Copy className="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
<span>Raw MD</span> <span>Raw MD</span>
</> </>
)} )}
</button> </button>
{/* Copy Link */} {/* Copy Share Link */}
<button <button
onClick={handleCopyLink} onClick={handleCopyLink}
type="button" type="button"
title="Copy link to document" title="Copy link to document"
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm" className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
> >
{copiedLink ? ( {copiedLink ? (
<> <>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Check className="w-3.5 h-3.5 text-emerald-500" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <span className="text-emerald-500 font-medium">Copied</span>
</svg>
<span className="text-emerald-500 font-medium">Copied Link</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Share2 className="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span>Share</span> <span>Share</span>
</> </>
)} )}
@@ -78,23 +78,29 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
{/* Export PDF */} {/* Export PDF */}
<Link <Link
href={`/${slug}/export`} href={`/${slug}/export`}
title="Export document or parent folder to PDF" title="Export document to publication-grade PDF"
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm" className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs"
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <FileDown className="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
<span>PDF</span> <span>PDF</span>
</Link> </Link>
{/* Export Word (.docx) */}
<a
href={`/api/export?path=${encodeURIComponent(slug)}&format=docx`}
title="Download editable Microsoft Word (.docx) file"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
>
<FileText className="w-3.5 h-3.5" />
<span>Word (.docx)</span>
</a>
{/* Edit Link */} {/* Edit Link */}
<Link <Link
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`} href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--brand)]/10 hover:bg-[var(--brand)]/20 border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 text-[var(--brand)] dark:text-[var(--accent)] rounded-md transition-all font-medium" className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors font-medium shadow-xs"
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Edit3 className="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit</span> <span>Edit</span>
</Link> </Link>
</div> </div>
+121 -80
View File
@@ -2,7 +2,21 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { mutate } from "swr";
import Markdown from "./Markdown"; import Markdown from "./Markdown";
import {
Bold,
Italic,
Heading2,
Heading3,
Code,
Link as LinkIcon,
Plus,
Trash2,
Eye,
Edit3,
Sliders,
} from "lucide-react";
interface DocFormProps { interface DocFormProps {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -22,8 +36,26 @@ interface DocFormProps {
}; };
} }
const DEFAULT_SECTION_LIST = ["docs", "writeups", "research", "notes", "guides", "tutorials", "ctf", "api", "projects"]; const DEFAULT_SECTION_LIST = [
const DEFAULT_TYPE_LIST = ["documentation", "writeup", "research", "note", "guide", "tutorial", "spec"]; "docs",
"writeups",
"research",
"notes",
"guides",
"tutorials",
"ctf",
"api",
"projects",
];
const DEFAULT_TYPE_LIST = [
"documentation",
"writeup",
"research",
"note",
"guide",
"tutorial",
"spec",
];
const PRESET_FIELDS = [ const PRESET_FIELDS = [
{ key: "difficulty", value: "medium" }, { key: "difficulty", value: "medium" },
@@ -88,7 +120,7 @@ export default function DocForm({
const availableFolders = existingFolders?.[section] ?? []; const availableFolders = existingFolders?.[section] ?? [];
const baseInputCls = const baseInputCls =
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[var(--brand)] transition-colors"; "w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors";
function addCustomField(key = "", value = "") { function addCustomField(key = "", value = "") {
setCustomFields([...customFields, { key, value }]); setCustomFields([...customFields, { key, value }]);
@@ -128,7 +160,7 @@ export default function DocForm({
setError(null); setError(null);
if (!effectiveSlug || !title || !body) { if (!effectiveSlug || !title || !body) {
setError("Slug, title, and body are required."); setError("Slug, title, and markdown body are required.");
setLoading(false); setLoading(false);
return; return;
} }
@@ -175,11 +207,16 @@ export default function DocForm({
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Save failed"); throw new Error(data.error ?? "Failed to save document. Verify ADMIN_PASSWORD or secret.");
} }
const result = await res.json(); const result = await res.json();
// Invalidate SWR caches so Sidebar/Header/search reflect the change immediately
mutate("/api/docs");
mutate("/api/sections");
mutate((key) => typeof key === "string" && key.startsWith("/api/tags"));
router.push(result.doc?.slug ? `/${result.doc.slug}` : "/"); router.push(result.doc?.slug ? `/${result.doc.slug}` : "/");
router.refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -190,39 +227,38 @@ export default function DocForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl"> <form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{error && ( {error && (
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-sm"> <div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
{error} {error}
</div> </div>
)} )}
{/* Title */} {/* Document Title */}
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Document Title * Document Title *
</label> </label>
<input <input
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Database Architecture Overview" placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
className={`${baseInputCls} text-base`} className={`${baseInputCls} text-base font-semibold`}
required required
/> />
</div> </div>
{/* Section + Type + Status */} {/* Section, Type, Status */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Section Section
</label> </label>
<div className="relative">
<input <input
type="text" type="text"
list="section-list" list="section-list"
value={section} value={section}
onChange={(e) => setSection(e.target.value)} onChange={(e) => setSection(e.target.value)}
placeholder="e.g. docs, writeups, ctf, guides" placeholder="e.g. docs, writeups, research"
className={baseInputCls} className={baseInputCls}
required required
/> />
@@ -234,18 +270,17 @@ export default function DocForm({
))} ))}
</datalist> </datalist>
</div> </div>
</div>
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Type Document Type
</label> </label>
<div className="relative">
<input <input
type="text" type="text"
list="type-list" list="type-list"
value={type} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
placeholder="e.g. documentation, writeup, note" placeholder="e.g. writeup, documentation, note"
className={baseInputCls} className={baseInputCls}
/> />
<datalist id="type-list"> <datalist id="type-list">
@@ -256,9 +291,9 @@ export default function DocForm({
))} ))}
</datalist> </datalist>
</div> </div>
</div>
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Status Status
</label> </label>
<select <select
@@ -267,7 +302,7 @@ export default function DocForm({
className={baseInputCls} className={baseInputCls}
> >
<option value="published">Published</option> <option value="published">Published</option>
<option value="draft">Draft</option> <option value="draft">Draft (Unindexed)</option>
</select> </select>
</div> </div>
</div> </div>
@@ -275,8 +310,8 @@ export default function DocForm({
{/* Folder + Slug */} {/* Folder + Slug */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Parent Folder Parent Folder / Chapter
</label> </label>
<select <select
value={parentFolder} value={parentFolder}
@@ -292,9 +327,10 @@ export default function DocForm({
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
{isEdit ? "Full Slug" : "Slug (File name) *"} {isEdit ? "Document Path" : "Slug (File name) *"}
</label> </label>
{isEdit ? ( {isEdit ? (
<input <input
@@ -309,12 +345,12 @@ export default function DocForm({
value={slugInput} value={slugInput}
onChange={(e) => setSlugInput(e.target.value)} onChange={(e) => setSlugInput(e.target.value)}
className={`${baseInputCls} font-mono`} className={`${baseInputCls} font-mono`}
placeholder="my-new-post" placeholder="ret2win-guide"
required required
/> />
)} )}
<p className="text-[11px] text-[var(--text-dim)] mt-1 font-mono"> <p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
Path: <span className="text-[var(--brand)] dark:text-[var(--accent)]">{effectiveSlug || section}</span> Full Slug: <span className="text-[var(--text-primary)] font-semibold">{effectiveSlug || section}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -322,7 +358,7 @@ export default function DocForm({
{/* Tags + Author */} {/* Tags + Author */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Tags (comma separated) Tags (comma separated)
</label> </label>
<input <input
@@ -330,11 +366,11 @@ export default function DocForm({
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
className={baseInputCls} className={baseInputCls}
placeholder="ctf, pwn, reverse, tutorial" placeholder="pwn, x86_64, rop, ctf"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
Author Author
</label> </label>
<input <input
@@ -342,31 +378,32 @@ export default function DocForm({
value={author} value={author}
onChange={(e) => setAuthor(e.target.value)} onChange={(e) => setAuthor(e.target.value)}
className={baseInputCls} className={baseInputCls}
placeholder="asep" placeholder="e.g. asepharyana"
/> />
</div> </div>
</div> </div>
{/* Custom Metadata Fields */} {/* Custom Metadata Fields */}
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-sm"> <div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3.5 shadow-xs">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div> <div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]"> <h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
Dynamic Custom Metadata <Sliders className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span>Technical Metadata</span>
</h3> </h3>
<p className="text-[11px] text-[var(--text-dim)] mt-0.5"> <p className="text-[11px] text-[var(--text-dim)] mt-0.5">
Add custom key-values (event, difficulty, points, etc.) rendered as styled badges. Custom fields (points, difficulty, event, solved) will render as styled badges in the document hero.
</p> </p>
</div> </div>
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<span className="text-[11px] text-[var(--text-dim)]">Presets:</span> <span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
{PRESET_FIELDS.map((preset) => ( {PRESET_FIELDS.map((preset) => (
<button <button
key={preset.key} key={preset.key}
type="button" type="button"
onClick={() => addCustomField(preset.key, preset.value)} onClick={() => addCustomField(preset.key, preset.value)}
className="px-2 py-0.5 text-[11px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors" className="px-2 py-0.5 text-[10px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors font-mono cursor-pointer"
> >
+{preset.key} +{preset.key}
</button> </button>
@@ -393,10 +430,10 @@ export default function DocForm({
<button <button
type="button" type="button"
onClick={() => removeCustomField(i)} onClick={() => removeCustomField(i)}
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors" className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
title="Remove field" title="Remove field"
> >
✕ <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
))} ))}
@@ -404,14 +441,15 @@ export default function DocForm({
<button <button
type="button" type="button"
onClick={() => addCustomField()} onClick={() => addCustomField()}
className="text-xs text-[var(--brand)] dark:text-[var(--accent)] hover:underline px-3 py-1.5 rounded-lg border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 bg-[var(--brand)]/5 transition-colors font-medium" className="inline-flex items-center gap-1.5 text-xs text-[var(--text-primary)] hover:underline px-3 py-1 rounded-md border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] transition-colors font-medium cursor-pointer"
> >
+ Add Custom Field <Plus className="w-3.5 h-3.5" />
<span>Add Custom Field</span>
</button> </button>
</div> </div>
{/* Body with Write / Preview Tabs */} {/* Markdown Body Editor */}
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-sm"> <div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-xs">
{/* Editor Toolbar */} {/* Editor Toolbar */}
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2"> <div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
{/* Tabs */} {/* Tabs */}
@@ -419,90 +457,92 @@ export default function DocForm({
<button <button
type="button" type="button"
onClick={() => setActiveTab("write")} onClick={() => setActiveTab("write")}
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
activeTab === "write" activeTab === "write"
? "bg-[var(--brand)] text-white shadow-sm" ? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`} }`}
> >
Write <Edit3 className="w-3.5 h-3.5" />
<span>Write</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab("preview")} onClick={() => setActiveTab("preview")}
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
activeTab === "preview" activeTab === "preview"
? "bg-[var(--brand)] text-white shadow-sm" ? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
}`} }`}
> >
Live Preview <Eye className="w-3.5 h-3.5" />
<span>Preview</span>
</button> </button>
</div> </div>
{/* Markdown Action Shortcuts */} {/* Quick Markdown Formatter Shortcuts */}
{activeTab === "write" && ( {activeTab === "write" && (
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]"> <div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
<button <button
type="button" type="button"
onClick={() => insertMarkdown("**", "**")} onClick={() => insertMarkdown("**", "**")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Bold" title="Bold"
> >
<b>B</b> <Bold className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown("*", "*")} onClick={() => insertMarkdown("*", "*")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Italic" title="Italic"
> >
<i>I</i> <Italic className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown("## ")} onClick={() => insertMarkdown("## ")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Heading 2" title="Heading 2"
> >
H2 <Heading2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("### ")}
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Heading 3"
>
<Heading3 className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown("```\n", "\n```")} onClick={() => insertMarkdown("```\n", "\n```")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] font-mono" className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Code Block" title="Code Block"
> >
{"</>"} <Code className="w-3.5 h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => insertMarkdown("[", "](https://)")} onClick={() => insertMarkdown("[", "](https://)")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
title="Link" title="Link"
> >
Link <LinkIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown("> [!NOTE]\n> ")}
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Note Callout"
>
Note
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* Tab content */} {/* Tab Content */}
{activeTab === "write" ? ( {activeTab === "write" ? (
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed" className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
placeholder="# Write your markdown document here..." placeholder="# Write your technical documentation or writeup here in Markdown..."
required required
/> />
) : ( ) : (
@@ -510,28 +550,29 @@ export default function DocForm({
{body.trim() ? ( {body.trim() ? (
<Markdown source={body} /> <Markdown source={body} />
) : ( ) : (
<p className="text-xs text-[var(--text-dim)] italic">Nothing to preview yet.</p> <p className="text-xs text-[var(--text-dim)] italic font-mono">No content to preview yet.</p>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Actions */} {/* Submit Action Buttons */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-6 py-2.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg font-medium text-sm transition-all shadow-md shadow-[var(--brand)]/20 disabled:opacity-50 flex items-center gap-2" className="px-6 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-xs transition-all shadow-xs disabled:opacity-50 flex items-center gap-2 cursor-pointer font-mono uppercase tracking-wider"
> >
{loading && ( {loading && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> <div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
)} )}
<span>{loading ? "Saving document..." : mode === "create" ? "Create Document" : "Save Changes"}</span> <span>{loading ? "PERSISTING..." : mode === "create" ? "PUBLISH_DOCUMENT" : "SAVE_CHANGES"}</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => router.back()} onClick={() => router.back()}
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-sm transition-colors shadow-sm" className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-xs transition-colors shadow-xs cursor-pointer font-mono"
> >
Cancel Cancel
</button> </button>
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { Clock, FileText } from "lucide-react";
import type { DocumentMeta } from "@mcpedia/types";
import { getSectionMeta } from "@mcpedia/config/sections";
export function DocumentCard({
doc,
showSection = false,
score,
}: {
doc: DocumentMeta;
showSection?: boolean;
score?: number;
}) {
const sInfo = getSectionMeta(doc.section);
return (
<Link
href={`/${doc.slug}`}
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl transition-all shadow-xs"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
<div className="min-w-0">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
{doc.title}
</h3>
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{showSection && (
<span className="text-[10px] font-mono px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] uppercase font-semibold">
{sInfo.icon} {doc.section}
</span>
)}
{doc.tags.slice(0, 4).map((t: string) => (
<span
key={t}
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
>
#{t}
</span>
))}
{typeof score === "number" && (
<span className="text-[10px] font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-1.5 py-0.2 rounded">
{score.toFixed(3)}
</span>
)}
</div>
</div>
</div>
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0 flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>
{new Date(doc.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</span>
</time>
</Link>
);
}
export function CompactDocCard({ doc, snippet, score }: { doc: DocumentMeta; snippet?: string; score?: number }) {
const sInfo = getSectionMeta(doc.section);
return (
<Link
href={`/${doc.slug}`}
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-4.5 transition-all shadow-xs"
>
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] uppercase font-semibold">
{sInfo.icon} {doc.section}
</span>
<span className="text-xs text-[var(--text-dim)] font-mono truncate">/{doc.slug}</span>
</div>
{typeof score === "number" && (
<span className="text-xs font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">
{score.toFixed(3)}
</span>
)}
</div>
<h2 className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline mb-1.5">{doc.title}</h2>
{snippet && (
<p
className="text-xs text-[var(--text-muted)] leading-relaxed line-clamp-2 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
dangerouslySetInnerHTML={{ __html: snippet }}
/>
)}
</Link>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { FileQuestion, SearchX, AlertCircle, Inbox } from "lucide-react";
import Link from "next/link";
export function EmptyState({
title = "No results",
description,
icon: Icon = Inbox,
action,
}: {
title?: string;
description?: string;
icon?: typeof Inbox;
action?: { label: string; href: string };
}) {
return (
<div className="text-center py-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs space-y-3">
<div className="w-10 h-10 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center mx-auto text-[var(--text-muted)]">
<Icon className="w-5 h-5" />
</div>
<h3 className="text-sm font-bold text-[var(--text-primary)]">{title}</h3>
{description && <p className="text-xs text-[var(--text-muted)] max-w-sm mx-auto leading-relaxed">{description}</p>}
{action && (
<Link
href={action.href}
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] text-[var(--brand-fg)] px-3 py-1.5 rounded-md font-semibold mt-2"
>
{action.label}
</Link>
)}
</div>
);
}
export function ErrorState({ message, onRetry }: { message?: string; onRetry?: () => void }) {
return (
<div className="p-4 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200 text-xs flex items-start gap-2.5">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="font-semibold">Something went wrong</div>
{message && <div className="text-[11px] opacity-80 mt-1 break-words">{message}</div>}
</div>
{onRetry && (
<button
onClick={onRetry}
type="button"
className="shrink-0 px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-primary)] font-semibold hover:bg-[var(--bg-elevated)]"
>
Retry
</button>
)}
</div>
);
}
+68 -59
View File
@@ -1,47 +1,47 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections"; import { DEFAULT_SECTIONS } from "@mcpedia/config/sections";
import { useSections } from "@/hooks/useSections";
import ThemeToggle from "@/components/ThemeToggle"; import ThemeToggle from "@/components/ThemeToggle";
import CommandMenu from "@/components/CommandMenu"; import CommandMenu from "@/components/CommandMenu";
import {
Plus,
Search,
Menu,
X,
Lock,
} from "lucide-react";
export default function Header() { export default function Header() {
const pathname = usePathname(); const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS); const { data: liveSections } = useSections();
const sections = liveSections && liveSections.length > 0 ? liveSections : DEFAULT_SECTIONS;
useEffect(() => {
fetch("/api/sections")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setSections(data);
}
})
.catch(() => {});
}, []);
return ( return (
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav"> <header className="site-header no-print sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-4"> <div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-3 sm:gap-4">
{/* Brand */} {/* Brand */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2 group"> <Link href="/" className="flex items-center gap-2.5 group">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-[var(--brand)] to-[var(--accent)] flex items-center justify-center text-white font-bold text-xs shadow-md shadow-[var(--brand)]/20 group-hover:scale-105 transition-transform"> <div className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--brand)] text-[var(--brand-fg)] font-mono font-bold text-xs shadow-xs group-hover:opacity-90 transition-opacity">
M M
</div> </div>
<span className="font-semibold text-[var(--text-primary)] text-base tracking-tight transition-colors"> <div className="flex items-center gap-2">
<span className="font-bold text-[var(--text-primary)] text-sm tracking-tight leading-none">
MCPedia MCPedia
</span> </span>
</Link> <span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hidden sm:inline-block">
<span className="hidden sm:inline-block text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)]"> CORE::DB
v0.1.0
</span> </span>
</div> </div>
</Link>
</div>
{/* Desktop Nav */} {/* Center: Desktop Sections Nav */}
<nav className="hidden md:flex items-center gap-1"> <nav className="hidden md:flex items-center gap-1">
{sections.map((s) => { {sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`); const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
@@ -49,101 +49,110 @@ export default function Header() {
<Link <Link
key={s.id} key={s.id}
href={`/${s.id}`} href={`/${s.id}`}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1.5 ${
isActive isActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)]" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)] font-semibold"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface-hover)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`} }`}
> >
<span className="mr-1.5 opacity-80">{s.icon}</span> <span>{s.icon}</span>
{s.label} <span>{s.label}</span>
</Link> </Link>
); );
})} })}
</nav> </nav>
{/* Right tools */} {/* Right Tools & Navigation Deck */}
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2">
<CommandMenu /> <CommandMenu />
<Link <Link
href="/search" href="/search"
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg hover:bg-[var(--bg-elevated)] transition-colors" className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md bg-[var(--bg-surface)] border border-[var(--border-color)] transition-colors"
title="Search" title="Search knowledge base"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Search className="w-4 h-4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</Link> </Link>
<Link <Link
href="/create" href="/create"
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1 bg-[var(--brand)] hover:bg-[var(--accent)] text-white text-xs font-medium rounded-lg transition-all shadow-sm" className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] text-xs font-semibold rounded-md shadow-xs transition-all cursor-pointer"
> >
<span>+</span> <Plus className="w-3.5 h-3.5" />
<span>New Doc</span> <span>New Doc</span>
</Link> </Link>
<Link <Link
href="/login" href="/login"
className={`px-2.5 py-1 text-xs rounded-lg transition-colors ${ className={`p-1.5 sm:px-2.5 sm:py-1.5 text-xs rounded-md transition-colors flex items-center gap-1.5 ${
pathname === "/login" pathname === "/login"
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-medium" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`} }`}
title="Admin Login"
> >
Login <Lock className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Admin</span>
</Link> </Link>
<div className="border-l border-[var(--border-color)] pl-2.5 flex items-center"> <div className="border-l border-[var(--border-color)] pl-2 flex items-center">
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/* Mobile menu toggle */} {/* Mobile menu toggle */}
<button <button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]" className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md hover:bg-[var(--bg-elevated)] transition-colors"
aria-label="Toggle navigation" aria-label="Toggle navigation"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button> </button>
</div> </div>
</div> </div>
{/* Mobile Drawer */} {/* Mobile Drawer */}
{mobileMenuOpen && ( {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"> <div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-4 space-y-2 animate-fade-in shadow-lg">
{sections.map((s) => ( <div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)] px-1 mb-1">
Knowledge Sections
</div>
<div className="grid grid-cols-2 gap-1.5">
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link <Link
key={s.id} key={s.id}
href={`/${s.id}`} href={`/${s.id}`}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]" className={`flex items-center gap-2 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
isActive
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)] font-semibold"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
}`}
> >
<span>{s.icon}</span> <span>{s.icon}</span>
<span>{s.label}</span> <span className="truncate">{s.label}</span>
</Link> </Link>
))} );
<div className="pt-2 border-t border-[var(--border-color)] flex gap-2"> })}
</div>
<div className="pt-3 border-t border-[var(--border-color)] flex gap-2">
<Link <Link
href="/search" href="/search"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className="flex-1 text-center py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] rounded-lg font-medium border border-[var(--border-color)]" className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md font-medium border border-[var(--border-color)]"
> >
Search <Search className="w-3.5 h-3.5" />
<span>Search</span>
</Link> </Link>
<Link <Link
href="/create" href="/create"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className="flex-1 text-center py-2 text-xs bg-[var(--brand)] text-white rounded-lg font-medium" className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--brand)] text-[var(--brand-fg)] rounded-md font-semibold shadow-xs"
> >
+ Create <Plus className="w-3.5 h-3.5" />
<span>Create Doc</span>
</Link> </Link>
</div> </div>
</div> </div>
@@ -0,0 +1,376 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Cpu,
Database,
Layers,
Sparkles,
Search,
Globe,
Server,
ArrowRight,
CheckCircle2,
} from "lucide-react";
interface NodeDetail {
id: string;
title: string;
badge: string;
type: "client" | "protocol" | "core" | "storage";
icon: any;
desc: string;
metrics: { label: string; value: string }[];
features: string[];
docLink?: string;
}
const NODES: NodeDetail[] = [
{
id: "ai-agents",
title: "AI Coding Agents",
badge: "MCP::CLIENT",
type: "client",
icon: Sparkles,
desc: "Claude Desktop, Cursor, Antigravity, and Zed connect via Model Context Protocol to read and write technical knowledge directly.",
metrics: [
{ label: "Transport", value: "Stdio / HTTP SSE" },
{ label: "Tools Exposed", value: "13 Native Tools" },
{ label: "Authentication", value: "x-webhook-secret" },
],
features: [
"Semantic similarity search over chunks",
"Direct document CRUD into Postgres",
"Context retrieval for debugging & CTF writeups",
],
docLink: "/docs",
},
{
id: "web-ui",
title: "Human Web Interface",
badge: "UI::NEXTJS",
type: "client",
icon: Globe,
desc: "Fast, accessible developer web interface with dark/light themes, keyboard shortcuts, and instant search.",
metrics: [
{ label: "Framework", value: "Next.js 16.3" },
{ label: "Styling", value: "Tailwind CSS v4" },
{ label: "Command Menu", value: "⌘K Omnisearch" },
],
features: [
"Hierarchical folder directory tree",
"Publication-grade PDF export generator",
"Live markdown editor with revision rollback",
],
docLink: "/docs",
},
{
id: "mcp-server",
title: "MCP Server Gateway",
badge: "PORT::4021",
type: "protocol",
icon: Server,
desc: "Streamable Model Context Protocol server exposing tool endpoints and resource URIs.",
metrics: [
{ label: "Port", value: ":4021" },
{ label: "Protocol", value: "MCP 2024-11-05" },
{ label: "Runtime", value: "Bun Engine" },
],
features: [
"get_document / create_document tools",
"search_hybrid / search_semantic tools",
"list_sections & list_revisions resources",
],
docLink: "/api",
},
{
id: "core-engine",
title: "@mcpedia/core",
badge: "CORE::PACKAGE",
type: "core",
icon: Layers,
desc: "Unified business logic layer shared across Web, MCP Server, REST API, and Background Workers.",
metrics: [
{ label: "Architecture", value: "Monorepo Package" },
{ label: "Validation", value: "Strict Type Safety" },
{ label: "Ranking", value: "RRF Hybrid Fusion" },
],
features: [
"Path classification & folder hierarchy",
"Markdown document chunking & tokenization",
"Drizzle ORM transactional schema mapping",
],
docLink: "/research",
},
{
id: "search-engine",
title: "Multi-Modal Search Engine",
badge: "SEARCH::RRF",
type: "core",
icon: Search,
desc: "Reciprocal Rank Fusion uniting Postgres Full-Text Search tsvectors with vector embeddings.",
metrics: [
{ label: "FTS Algorithm", value: "tsvector + GIN" },
{ label: "Vector Search", value: "Cosine Similarity" },
{ label: "Fusion Constant", value: "k = 60" },
],
features: [
"Sub-millisecond keyword retrieval",
"Conceptual similarity matching",
"Instant snippet generation with highlight markers",
],
docLink: "/search",
},
{
id: "postgres-db",
title: "PostgreSQL 16 + pgvector",
badge: "DB::POSTGRES",
type: "storage",
icon: Database,
desc: "ACID-compliant relational database storing document bodies, revision trees, vector embeddings, and full-text indexes.",
metrics: [
{ label: "Extension", value: "vector (1536 dim)" },
{ label: "Backups", value: "Auto .md to disk" },
{ label: "Isolation", value: "Transactional" },
],
features: [
"HNSW vector index for embeddings",
"Complete revision history preservation",
"Instant document schema migrations",
],
docLink: "/docs",
},
];
export default function InteractiveTopology() {
const [activeNodeId, setActiveNodeId] = useState<string>("core-engine");
const activeNode = NODES.find((n) => n.id === activeNodeId) || NODES[0];
const ActiveIcon = activeNode.icon;
return (
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
{/* Header Bar */}
<div className="p-4 sm:p-5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
Architecture Schematic
</span>
</div>
<h3 className="text-base sm:text-lg font-bold text-[var(--text-primary)] tracking-tight">
System Topology: Single Core Architecture
</h3>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-2 py-1 rounded">
Click nodes to inspect
</span>
</div>
</div>
{/* Grid Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12">
{/* Visual Map Column (Left) */}
<div className="lg:col-span-7 p-5 border-b lg:border-b-0 lg:border-r border-[var(--border-color)] bg-[var(--bg-app)] flex flex-col justify-between space-y-5">
{/* Layer 1: Clients */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
1. Ingestion & Access Layer
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{NODES.filter((n) => n.type === "client").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
{node.badge}
</span>
</div>
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
</button>
);
})}
</div>
</div>
{/* Flow Connector Arrow */}
<div className="flex justify-center text-[var(--text-dim)]">
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
↓ Protocol Gateway
</span>
</div>
{/* Layer 2: Protocol & Core Services */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
2. Core Engine & Protocol Layer
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
{NODES.filter((n) => n.type === "protocol" || n.type === "core").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-3.5 h-3.5" />
</div>
</div>
<div className="font-semibold text-xs text-[var(--text-primary)] leading-tight mb-0.5 truncate">
{node.title}
</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono truncate">
{node.badge}
</div>
</button>
);
})}
</div>
</div>
{/* Flow Connector Arrow */}
<div className="flex justify-center text-[var(--text-dim)]">
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
↓ Persistent Storage Layer
</span>
</div>
{/* Layer 3: Storage */}
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
3. Database (Source of Truth)
</span>
{NODES.filter((n) => n.type === "storage").map((node) => {
const Icon = node.icon;
const isSelected = activeNodeId === node.id;
return (
<button
key={node.id}
onClick={() => setActiveNodeId(node.id)}
type="button"
className={`w-full p-3 rounded-lg border text-left transition-all cursor-pointer ${
isSelected
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
<div className="text-[11px] text-[var(--text-muted)]">Source of truth for all content & embeddings</div>
</div>
</div>
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
{node.badge}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Node Inspector Details Column (Right) */}
<div className="lg:col-span-5 p-5 bg-[var(--bg-surface)] flex flex-col justify-between space-y-5 animate-fade-in">
<div>
{/* Inspector Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] flex items-center justify-center">
<ActiveIcon className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-[var(--text-primary)]">{activeNode.title}</h4>
<span className="text-[10px] font-mono text-[var(--text-muted)]">
{activeNode.badge}
</span>
</div>
</div>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-5">
{activeNode.desc}
</p>
{/* Telemetry Metrics */}
<div className="mb-5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
Specifications
</span>
<div className="space-y-1.5">
{activeNode.metrics.map((m) => (
<div
key={m.label}
className="flex items-center justify-between p-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-xs"
>
<span className="text-[var(--text-muted)] text-[11px]">{m.label}</span>
<span className="font-mono font-medium text-[var(--text-primary)] text-[11px]">{m.value}</span>
</div>
))}
</div>
</div>
{/* Key Capabilities */}
<div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
Capabilities
</span>
<ul className="space-y-1.5">
{activeNode.features.map((feat, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-[var(--text-secondary)]">
<span className="text-emerald-500 shrink-0 font-mono text-[11px]">✓</span>
<span>{feat}</span>
</li>
))}
</ul>
</div>
</div>
{/* Link to Documentation */}
{activeNode.docLink && (
<div className="pt-3 border-t border-[var(--border-color)]">
<Link
href={activeNode.docLink}
className="inline-flex items-center justify-center gap-1.5 w-full py-2 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-xs font-semibold text-[var(--text-primary)] rounded-lg transition-colors group"
>
<span>Read documentation</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
)}
</div>
</div>
</div>
);
}
+69 -24
View File
@@ -5,6 +5,15 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug"; import rehypeSlug from "rehype-slug";
import rehypeHighlight from "rehype-highlight"; import rehypeHighlight from "rehype-highlight";
import {
Copy,
Check,
Info,
Lightbulb,
AlertTriangle,
Flame,
ShieldAlert,
} from "lucide-react";
function extractText(node: React.ReactNode): string { function extractText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") { if (node === null || node === undefined || typeof node === "boolean") {
@@ -42,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
} }
return ( return (
<div className="code-block-wrapper relative group my-5 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-xs print:my-3 print:rounded-md print:border-slate-300 print:bg-slate-50"> <div className="code-block-wrapper relative group my-6 print:my-2 rounded-xl print:rounded border border-[var(--border-code)] print:border-slate-300 bg-[var(--code-block-bg)] print:bg-slate-50 shadow-sm print:shadow-none">
{/* Code block header bar */} {/* Code block header bar */}
<div className="code-block-header flex items-center justify-between px-4 py-2 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none print:px-3 print:py-1 print:bg-slate-100 print:border-slate-300"> <div className="code-block-header flex items-center justify-between px-4 py-2.5 print:px-2.5 print:py-0.5 bg-[var(--code-header-bg)] print:bg-slate-100 border-b border-[var(--border-code)] print:border-slate-300 text-xs font-mono select-none">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity"> <div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
<span className="w-2.5 h-2.5 rounded-full bg-rose-400/80 dark:bg-rose-500/60 inline-block" /> <span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-400/80 dark:bg-amber-500/60 inline-block" /> <span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400/80 dark:bg-emerald-500/60 inline-block" /> <span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
</div> </div>
<span className="text-[11px] font-medium uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-secondary)] transition-colors"> <span className="text-[11px] print:text-[7pt] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
{language} {language}
</span> </span>
</div> </div>
@@ -64,23 +73,19 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
> >
{copied ? ( {copied ? (
<> <>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Check className="w-3.5 h-3.5 text-emerald-500" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-emerald-500 font-medium">Copied!</span> <span className="text-emerald-500 font-medium">Copied!</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Copy className="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Copy</span> <span>Copy</span>
</> </>
)} )}
</button> </button>
</div> </div>
<pre className="!m-0 !bg-transparent p-4 print:p-3 overflow-x-auto text-[13.5px] print:text-[8.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed" {...props}> <pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-2 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[7.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed print:leading-snug" {...props}>
{children} {children}
</pre> </pre>
</div> </div>
@@ -91,16 +96,16 @@ export default function Markdown({ source }: { source: string }) {
return ( return (
<div <div
className="prose max-w-none className="prose max-w-none
prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] print:prose-h1:text-[13pt] print:prose-h1:mt-2 print:prose-h1:mb-1 print:prose-h1:pb-0.5
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] print:prose-h2:text-[10.5pt] print:prose-h2:mt-2 print:prose-h2:mb-1 print:prose-h2:pb-0.5
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 print:prose-h3:text-[9.5pt] print:prose-h3:mt-1.5 print:prose-h3:mb-0.5
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 print:prose-p:text-[8.5pt] print:prose-p:leading-snug print:prose-p:mb-1.5
prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)] prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)]
prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 print:prose-ul:my-1 print:prose-li:my-0.5
prose-ol:text-[var(--text-secondary)] prose-ol:my-4 prose-ol:text-[var(--text-secondary)] prose-ol:my-4 print:prose-ol:my-1
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
prose-hr:border-[var(--border-color)] prose-hr:my-8" prose-hr:border-[var(--border-color)] prose-hr:my-8 print:prose-hr:my-2"
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
@@ -117,7 +122,7 @@ export default function Markdown({ source }: { source: string }) {
} }
return ( return (
<code <code
className="px-1.5 py-0.5 rounded-md bg-[var(--code-inline-bg)] text-[var(--code-inline-text)] border border-[var(--code-inline-border)] font-mono text-[0.875em] font-medium" className="px-1.5 py-0.5 rounded-md print:rounded-none bg-[var(--code-inline-bg)] print:bg-slate-100 text-[var(--code-inline-text)] print:text-slate-900 border border-[var(--code-inline-border)] print:border-slate-200 font-mono text-[0.875em] print:text-[7.5pt] font-medium"
{...props} {...props}
> >
{children} {children}
@@ -125,13 +130,53 @@ export default function Markdown({ source }: { source: string }) {
); );
}, },
table: ({ node, ...props }) => ( table: ({ node, ...props }) => (
<div className="overflow-x-auto my-6 border border-[var(--border-color)] rounded-xl shadow-xs"> <div className="overflow-x-auto my-6 print:my-1.5 border border-[var(--border-color)] print:border-slate-300 rounded-xl print:rounded-none shadow-xs print:shadow-none">
<table className="w-full border-collapse" {...props} /> <table className="w-full border-collapse" {...props} />
</div> </div>
), ),
blockquote: ({ children }) => { blockquote: ({ children }) => {
const rawText = extractText(children).trim();
const alertMatch = rawText.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
if (alertMatch) {
const alertType = alertMatch[1].toUpperCase();
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200 print:bg-indigo-50 print:text-indigo-950";
let Icon = Info;
let title = "Note";
if (alertType === "TIP") {
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 print:bg-emerald-50 print:text-emerald-950";
Icon = Lightbulb;
title = "Tip";
} else if (alertType === "IMPORTANT") {
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200 print:bg-violet-50 print:text-violet-950";
Icon = Flame;
title = "Important";
} else if (alertType === "WARNING") {
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200 print:bg-amber-50 print:text-amber-950";
Icon = AlertTriangle;
title = "Warning";
} else if (alertType === "CAUTION") {
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200 print:bg-rose-50 print:text-rose-950";
Icon = ShieldAlert;
title = "Caution";
}
return ( return (
<blockquote className="border-l-4 border-[var(--brand)] bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 pl-4 py-2.5 my-5 rounded-r-lg text-[var(--text-secondary)] not-italic"> <div className={`my-5 print:my-1.5 p-4 print:p-2 rounded-xl print:rounded border-l-4 ${borderClass} shadow-xs print:shadow-none`}>
<div className="flex items-center gap-2 font-bold text-xs print:text-[7.5pt] uppercase tracking-wider mb-1.5 print:mb-0.5">
<Icon className="w-4 h-4 print:w-3 print:h-3" />
<span>{title}</span>
</div>
<div className="text-xs sm:text-sm print:text-[8pt] text-[var(--text-secondary)] print:text-slate-900 [&>p]:mb-1 [&>p:last-child]:mb-0 print:[&>p]:mb-0.5">
{children}
</div>
</div>
);
}
return (
<blockquote className="border-l-4 border-[var(--brand)] print:border-slate-500 bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 print:bg-slate-50 pl-4 print:pl-2.5 py-2.5 print:py-1 my-5 print:my-1.5 rounded-r-xl print:rounded-none text-[var(--text-secondary)] print:text-slate-900 not-italic print:text-[8pt]">
{children} {children}
</blockquote> </blockquote>
); );
+92 -28
View File
@@ -2,11 +2,20 @@
import { useState } from "react"; import { useState } from "react";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import {
Sparkles,
Terminal,
Server,
Copy,
Check,
Laptop,
} from "lucide-react";
const CONFIGS = [ const CONFIGS = [
{ {
id: "claude", id: "claude",
label: "Claude Desktop", label: "Claude Desktop",
icon: Sparkles,
lang: "json", lang: "json",
filename: "~/Library/Application Support/Claude/claude_desktop_config.json", filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
content: `{ content: `{
@@ -15,7 +24,7 @@ const CONFIGS = [
"command": "bun", "command": "bun",
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"], "args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
"env": { "env": {
"DATABASE_URL": "postgresql://...", "DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia",
"OPENROUTER_API_KEY": "sk-or-v1-..." "OPENROUTER_API_KEY": "sk-or-v1-..."
} }
} }
@@ -23,10 +32,29 @@ const CONFIGS = [
}`, }`,
}, },
{ {
id: "http", id: "cursor",
label: "Remote HTTP (SSE)", label: "Cursor AI",
icon: Laptop,
lang: "json", lang: "json",
filename: "Streamable HTTP Endpoint", filename: ".cursor/mcp.json",
content: `{
"mcpServers": {
"mcpedia": {
"command": "bun",
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia"
}
}
}
}`,
},
{
id: "http",
label: "Streamable HTTP (SSE)",
icon: Server,
lang: "json",
filename: "Streamable HTTP Client Config",
content: `{ content: `{
"mcpServers": { "mcpServers": {
"mcpedia-remote": { "mcpedia-remote": {
@@ -40,19 +68,21 @@ const CONFIGS = [
}, },
{ {
id: "cli", id: "cli",
label: "CLI Client", label: "CLI & Scripts",
icon: Terminal,
lang: "bash", lang: "bash",
filename: "Terminal One-Shot / REPL", filename: "Terminal Subprocess / REPL",
content: `# One-shot question content: `# Semantic question answering via MCP client
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work?" bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work in x86_64?"
# Interactive REPL # Interactive REPL with tool discovery
bun run apps/mcp-client/src/index.ts`, bun run apps/mcp-client/src/index.ts`,
}, },
]; ];
export default function McpConfigSnippet() { export default function McpConfigSnippet() {
const [activeTab, setActiveTab] = useState("claude"); const [activeTab, setActiveTab] = useState("claude");
const [copied, setCopied] = useState(false);
const current = CONFIGS.find((c) => c.id === activeTab) || CONFIGS[0]; const current = CONFIGS.find((c) => c.id === activeTab) || CONFIGS[0];
@@ -60,45 +90,79 @@ export default function McpConfigSnippet() {
${current.content} ${current.content}
\`\`\``; \`\`\``;
function handleCopyAll() {
navigator.clipboard.writeText(current.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return ( return (
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-sm"> <div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
{/* Header bar with tabs */} {/* Header bar with tabs */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2"> <div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 flex-wrap">
{CONFIGS.map((c) => ( {CONFIGS.map((c) => {
const Icon = c.icon;
const isSelected = activeTab === c.id;
return (
<button <button
key={c.id} key={c.id}
onClick={() => setActiveTab(c.id)} onClick={() => setActiveTab(c.id)}
className={`px-3 py-1.5 text-xs rounded-lg font-medium transition-all cursor-pointer ${ type="button"
activeTab === c.id className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium transition-all cursor-pointer ${
? "bg-[var(--brand)] text-white shadow-xs" isSelected
? "bg-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
}`} }`}
> >
{c.label} <Icon className="w-3.5 h-3.5" />
<span>{c.label}</span>
</button> </button>
))} );
})}
</div> </div>
<div className="text-[11px] text-[var(--text-dim)] font-mono hidden sm:block"> <button
{current.filename} onClick={handleCopyAll}
</div> type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer font-medium"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500">Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy Config</span>
</>
)}
</button>
</div> </div>
{/* Code preview via Markdown syntax highlighter */} {/* Code preview */}
<div className="p-4 [&>.prose>.group]:my-0 [&>.prose]:max-w-none"> <div className="p-4 sm:p-5 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
<Markdown source={markdownSnippet} /> <Markdown source={markdownSnippet} />
</div> </div>
<div className="px-4 py-2 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-[11px] text-[var(--text-dim)] flex items-center justify-between flex-wrap gap-2"> {/* Footer Info */}
<span> <div className="px-4 py-2.5 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] flex flex-col sm:flex-row sm:items-center justify-between gap-2">
Config target: <span className="font-mono text-[var(--text-secondary)]">{current.filename}</span> <div className="flex items-center gap-2">
<span className="font-mono uppercase text-[10px] text-[var(--text-muted)]">
Target File:
</span> </span>
<span className="text-[10px] text-[var(--text-muted)]"> <span className="font-mono text-[var(--text-secondary)] text-[11px]">
Format: <span className="uppercase font-mono font-semibold">{current.lang}</span> {current.filename}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-[11px] font-mono">
<span className="flex items-center gap-1 text-[var(--text-muted)]">
<span>[ 13_TOOLS_ACTIVE ]</span>
</span>
</div>
</div>
</div> </div>
); );
} }
+107 -60
View File
@@ -14,6 +14,15 @@ import {
type ExportSortOption, type ExportSortOption,
} from "@mcpedia/core/export-helpers"; } from "@mcpedia/core/export-helpers";
import { getSectionMeta } from "@mcpedia/config/sections"; import { getSectionMeta } from "@mcpedia/config/sections";
import {
Printer,
FileDown,
FileText,
Copy,
Check,
ArrowLeft,
BookOpen,
} from "lucide-react";
interface PdfExportViewProps { interface PdfExportViewProps {
exportData: ExportData; exportData: ExportData;
@@ -111,25 +120,54 @@ export default function PdfExportView({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
const [downloadingDocx, setDownloadingDocx] = useState(false);
async function handleDownloadDocx() {
setDownloadingDocx(true);
try {
const slugs = filteredAndSortedDocs.map((d) => d.slug).join(",");
const safeName =
summary.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "export";
const url = `/api/export?format=docx&slugs=${encodeURIComponent(slugs)}&sort=${sortBy}&pageBreaks=${pageBreaks}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to generate Word document");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${safeName}.docx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err) {
console.error("DOCX download error:", err);
alert("Failed to generate .docx file. Please try again.");
} finally {
setDownloadingDocx(false);
}
}
return ( return (
<div className="pb-16 print:pb-0"> <div className="pb-16 print:pb-0">
{/* ========================================================================= {/* Export Control Toolbar (Screen Only — Hidden in Print) */}
Export Control Toolbar (Screen Only — Hidden in Print) <aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs">
========================================================================= */}
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 shadow-sm">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
{/* Left info & Back button */} {/* Left info & Back button */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
href={backHref} href={backHref}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all shadow-xs" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors shadow-xs"
> >
<span>←</span> <ArrowLeft className="w-3.5 h-3.5" />
<span>Back</span> <span>Back</span>
</Link> </Link>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--text-primary)] font-semibold uppercase"> <span className="text-xs font-mono text-[var(--text-primary)] font-bold uppercase">
{sectionMeta.label} {sectionMeta.label}
</span> </span>
<span className="text-xs text-[var(--text-dim)]">·</span> <span className="text-xs text-[var(--text-dim)]">·</span>
@@ -150,13 +188,19 @@ export default function PdfExportView({
<button <button
onClick={handleCopyMarkdown} onClick={handleCopyMarkdown}
type="button" type="button"
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
title="Copy compiled markdown document" title="Copy compiled markdown document"
> >
{copiedMd ? ( {copiedMd ? (
<span className="text-emerald-600 font-bold">✓ Copied</span> <>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500 font-bold">Copied</span>
</>
) : ( ) : (
<span>Copy Markdown</span> <>
<Copy className="w-3.5 h-3.5" />
<span>Copy MD</span>
</>
)} )}
</button> </button>
@@ -164,46 +208,51 @@ export default function PdfExportView({
<button <button
onClick={handleDownloadMarkdown} onClick={handleDownloadMarkdown}
type="button" type="button"
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
title="Download compiled .md file" title="Download compiled .md file"
> >
<FileDown className="w-3.5 h-3.5" />
<span>Download .md</span> <span>Download .md</span>
</button> </button>
{/* Download Word (.docx) */}
<button
onClick={handleDownloadDocx}
disabled={downloadingDocx}
type="button"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer disabled:opacity-50"
title="Download editable Microsoft Word (.docx) document"
>
{downloadingDocx ? (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<FileText className="w-3.5 h-3.5" />
)}
<span>Download .docx</span>
</button>
{/* Print / Save as PDF Button */} {/* Print / Save as PDF Button */}
<button <button
onClick={handlePrint} onClick={handlePrint}
type="button" type="button"
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold bg-slate-900 hover:bg-slate-800 text-white rounded-md shadow-sm transition-all cursor-pointer hover:shadow" className="inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-bold bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-md shadow-xs transition-all cursor-pointer"
title="Open browser print dialog to save as PDF" title="Open browser print dialog to save as PDF"
> >
<svg <Printer className="w-4 h-4" />
className="w-4 h-4" <span>Print / Save PDF</span>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
<span>Print / Save as PDF</span>
</button> </button>
</div> </div>
</div> </div>
{/* Options & Filters Bar */} {/* Options & Filters Bar */}
<div className="mt-4 pt-3.5 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs"> <div className="mt-3.5 pt-3 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
{/* Sort Order Selector */} {/* Sort Order Selector */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[var(--text-muted)] font-medium">Sort by:</span> <span className="text-[var(--text-muted)] font-mono text-[10px] uppercase">Sort:</span>
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as ExportSortOption)} onChange={(e) => setSortBy(e.target.value as ExportSortOption)}
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-md px-2.5 py-1 text-xs text-[var(--text-primary)] focus:outline-none focus:border-slate-500" className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded px-2.5 py-1 text-xs text-[var(--text-primary)] focus:outline-none"
> >
<option value="category_points">Category & Points (Standard)</option> <option value="category_points">Category & Points (Standard)</option>
<option value="points_desc">Points (Highest First)</option> <option value="points_desc">Points (Highest First)</option>
@@ -217,23 +266,23 @@ export default function PdfExportView({
{/* Section toggles */} {/* Section toggles */}
<div className="flex items-center gap-3 flex-wrap text-[var(--text-secondary)]"> <div className="flex items-center gap-3 flex-wrap text-[var(--text-secondary)]">
{cleanDocs.length > 1 && ( {cleanDocs.length > 1 && (
<label className="inline-flex items-center gap-1.5 cursor-pointer"> <label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input <input
type="checkbox" type="checkbox"
checked={showTOC} checked={showTOC}
onChange={(e) => setShowTOC(e.target.checked)} onChange={(e) => setShowTOC(e.target.checked)}
className="rounded border-[var(--border-color)] text-slate-800" className="rounded border-[var(--border-color)]"
/> />
<span>Table of Contents</span> <span>Table of Contents</span>
</label> </label>
)} )}
<label className="inline-flex items-center gap-1.5 cursor-pointer"> <label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input <input
type="checkbox" type="checkbox"
checked={pageBreaks} checked={pageBreaks}
onChange={(e) => setPageBreaks(e.target.checked)} onChange={(e) => setPageBreaks(e.target.checked)}
className="rounded border-[var(--border-color)] text-slate-800" className="rounded border-[var(--border-color)]"
/> />
<span>Page Break per Challenge</span> <span>Page Break per Challenge</span>
</label> </label>
@@ -243,13 +292,13 @@ export default function PdfExportView({
{/* Category filter pills */} {/* Category filter pills */}
{allCategories.length > 1 && ( {allCategories.length > 1 && (
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap text-xs"> <div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap text-xs">
<span className="text-[var(--text-muted)] mr-1">Filter:</span> <span className="text-[var(--text-muted)] font-mono text-[10px] uppercase mr-1">Filter:</span>
<button <button
onClick={() => setSelectedCategory("all")} onClick={() => setSelectedCategory("all")}
type="button" type="button"
className={`px-2.5 py-0.5 rounded-md text-xs font-mono transition-all cursor-pointer ${ className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
selectedCategory === "all" selectedCategory === "all"
? "bg-slate-900 text-white font-semibold" ? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
}`} }`}
> >
@@ -264,9 +313,9 @@ export default function PdfExportView({
key={cat} key={cat}
onClick={() => setSelectedCategory(cat)} onClick={() => setSelectedCategory(cat)}
type="button" type="button"
className={`px-2.5 py-0.5 rounded-md text-xs font-mono transition-all cursor-pointer ${ className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
selectedCategory.toLowerCase() === cat.toLowerCase() selectedCategory.toLowerCase() === cat.toLowerCase()
? "bg-slate-900 text-white font-semibold" ? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
}`} }`}
> >
@@ -278,17 +327,14 @@ export default function PdfExportView({
)} )}
</aside> </aside>
{/* ========================================================================= {/* Writeup Document Flow */}
Clean, Simple, Human-Written Writeup Document Flow (No Cards) <main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:m-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none">
========================================================================= */}
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:max-w-none">
{/* Document Header */} {/* Document Header */}
<header className="mb-8 pb-3 border-b border-slate-300"> <header className="doc-header mb-8 pb-4 print:mb-2.5 print:pb-1.5 border-b border-slate-300">
<h1 className="text-3xl sm:text-4xl font-bold text-slate-950 tracking-tight mb-2"> <h1 className="text-3xl sm:text-4xl print:text-lg font-extrabold text-slate-950 tracking-tight mb-2 print:mb-0.5">
{summary.title} {summary.title}
</h1> </h1>
<div className="text-xs text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="text-xs print:text-[7.5pt] text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1 print:gap-x-1.5 print:gap-y-0.5">
<span>{filteredAndSortedDocs.length} Challenges</span> <span>{filteredAndSortedDocs.length} Challenges</span>
{currentTotalPoints > 0 && ( {currentTotalPoints > 0 && (
<span>· {currentTotalPoints.toLocaleString()} Total Points</span> <span>· {currentTotalPoints.toLocaleString()} Total Points</span>
@@ -297,26 +343,27 @@ export default function PdfExportView({
</div> </div>
</header> </header>
{/* Table of Contents (Simple & Clean) */} {/* Table of Contents */}
{showTOC && filteredAndSortedDocs.length > 1 && ( {showTOC && filteredAndSortedDocs.length > 1 && (
<section className="toc-section mb-10 pb-6 border-b border-slate-200"> <section className="toc-section mb-10 pb-6 print:mb-2.5 print:pb-1.5 border-b border-slate-200 print:border-slate-300">
<h2 className="text-sm font-bold uppercase font-mono text-slate-700 mb-3 tracking-wider"> <h2 className="text-xs print:text-[8pt] font-bold uppercase font-mono text-slate-700 mb-3 print:mb-1 tracking-wider flex items-center gap-1.5">
Table of Contents <BookOpen className="w-3.5 h-3.5 print:w-3 print:h-3" />
<span>Table of Contents</span>
</h2> </h2>
<ol className="list-decimal list-inside space-y-1.5 text-xs text-slate-800"> <ol className="list-decimal list-inside space-y-1.5 print:space-y-0.5 text-xs print:text-[7.5pt] text-slate-800">
{filteredAndSortedDocs.map((doc, idx) => { {filteredAndSortedDocs.map((doc, idx) => {
const cat = extractDocCategory(doc); const cat = extractDocCategory(doc);
const pts = extractDocPoints(doc); const pts = extractDocPoints(doc);
return ( return (
<li key={doc.slug}> <li key={doc.slug} className="print:leading-tight">
<a <a
href={`#ch-${idx + 1}`} href={`#ch-${idx + 1}`}
className="hover:underline font-medium text-slate-900" className="hover:underline font-medium text-slate-900"
> >
{doc.title} {doc.title}
</a> </a>
<span className="text-slate-500 font-mono ml-2"> <span className="text-slate-500 font-mono ml-2 print:ml-1">
[{cat}{pts > 0 ? ` · ${pts} pts` : ""}] [{cat}{pts > 0 ? ` · ${pts} pts` : ""}]
</span> </span>
</li> </li>
@@ -326,7 +373,7 @@ export default function PdfExportView({
</section> </section>
)} )}
{/* Challenges / Writeups Flow ("Mirip Writeup Manusia") */} {/* Challenges Flow */}
<div className="challenges-flow space-y-10 print:space-y-0"> <div className="challenges-flow space-y-10 print:space-y-0">
{filteredAndSortedDocs.map((doc, idx) => { {filteredAndSortedDocs.map((doc, idx) => {
const chNum = idx + 1; const chNum = idx + 1;
@@ -341,19 +388,19 @@ export default function PdfExportView({
id={`ch-${chNum}`} id={`ch-${chNum}`}
className={`challenge-entry ${ className={`challenge-entry ${
pageBreaks && idx > 0 ? "page-break-before" : "" pageBreaks && idx > 0 ? "page-break-before" : ""
} pt-6 first:pt-0`} } pt-6 first:pt-0 print:pt-2.5 print:pb-2 print:border-b print:border-slate-200 print:last:border-none`}
> >
{/* Challenge Title & Metadata */} {/* Challenge Title & Metadata */}
<div className="mb-4 pb-2 border-b border-slate-200"> <div className="challenge-header mb-4 pb-2 print:mb-1.5 print:pb-1 border-b border-slate-200 print:border-slate-300">
<h2 className="text-2xl font-bold text-slate-950 tracking-tight leading-tight mb-1.5"> <h2 className="text-2xl print:text-[11pt] font-bold text-slate-950 tracking-tight leading-tight mb-1.5 print:mb-0.5">
{chNum}. {doc.title} {chNum}. {doc.title}
</h2> </h2>
<div className="text-xs font-mono text-slate-600 flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="text-xs print:text-[7.5pt] font-mono text-slate-600 flex flex-wrap items-center gap-x-3 print:gap-x-2 gap-y-1 print:gap-y-0.5">
<span><strong>Category:</strong> {cat}</span> <span><strong>Category:</strong> {cat}</span>
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>} {pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
{diff && <span><strong>Difficulty:</strong> {diff}</span>} {diff && <span><strong>Difficulty:</strong> {diff}</span>}
{/* Extra fields (excluding author, slug, scope, etc.) */} {/* Extra fields */}
{Object.entries(extra).map(([k, v]) => { {Object.entries(extra).map(([k, v]) => {
const lowerKey = k.toLowerCase(); const lowerKey = k.toLowerCase();
if ( if (
@@ -388,7 +435,7 @@ export default function PdfExportView({
</div> </div>
{/* Markdown Writeup Content */} {/* Markdown Writeup Content */}
<div className="prose prose-slate max-w-none text-slate-900 mb-8"> <div className="prose prose-slate max-w-none text-slate-900 mb-8 print:mb-1">
<Markdown source={doc.body} /> <Markdown source={doc.body} />
</div> </div>
+79
View File
@@ -0,0 +1,79 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import { Clock, CheckCircle2, AlertTriangle, Hourglass, Layers } from "lucide-react";
export default function QueueBadge() {
const { data, error, isLoading } = useSWR<{ waiting: number; active: number; completed: number; failed: number; delayed: number }>(
"/api/queue/status",
fetcher,
{ refreshInterval: 8_000, dedupingInterval: 4_000, revalidateOnFocus: false },
);
if (isLoading) {
return (
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
<Hourglass className="w-3 h-3 animate-spin" /> queue…
</span>
);
}
if (error || !data) return null;
const total = data.waiting + data.active + data.delayed;
const hasWork = total > 0 || data.failed > 0;
if (!hasWork) {
return (
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded">
<CheckCircle2 className="w-3 h-3" /> index idle
</span>
);
}
return (
<span
className={`inline-flex items-center gap-1.5 text-[11px] font-mono px-2.5 py-1 rounded border ${
data.failed > 0
? "text-amber-700 dark:text-amber-400 bg-amber-500/10 border-amber-500/30"
: "text-[var(--text-secondary)] bg-[var(--bg-elevated)] border-[var(--border-color)]"
}`}
title={`waiting:${data.waiting} active:${data.active} delayed:${data.delayed} failed:${data.failed}`}
role="status"
aria-live="polite"
>
{data.failed > 0 ? <AlertTriangle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
<span>
q:{data.waiting}w · {data.active}a
{data.delayed ? ` · ${data.delayed}d` : ""}
{data.failed ? ` · ${data.failed} ✕` : ""}
</span>
</span>
);
}
export function QueueStrip() {
const { data } = useSWR<{ waiting: number; active: number; completed: number; failed: number; delayed: number }>(
"/api/queue/status",
fetcher,
{ refreshInterval: 6_000, revalidateOnFocus: false },
);
if (!data) return null;
const items: { label: string; value: number; tone: string }[] = [
{ label: "waiting", value: data.waiting, tone: "text-[var(--text-muted)]" },
{ label: "active", value: data.active, tone: "text-sky-600 dark:text-sky-400" },
{ label: "delayed", value: data.delayed, tone: "text-[var(--text-muted)]" },
{ label: "failed", value: data.failed, tone: data.failed ? "text-amber-600 dark:text-amber-400" : "text-[var(--text-dim)]" },
{ label: "completed", value: data.completed, tone: "text-emerald-600 dark:text-emerald-400" },
];
return (
<div className="flex flex-wrap gap-2 text-[11px] font-mono">
{items.map((it) => (
<span key={it.label} className={`px-2 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] ${it.tone}`}>
{it.label}: <strong>{it.value}</strong>
</span>
))}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { useRelated } from "@/hooks/useRelated";
import { getSectionMeta } from "@mcpedia/config/sections";
import { Sparkles } from "lucide-react";
export default function RelatedGrid({ slug }: { slug: string }) {
const { related, isLoading, error } = useRelated(slug, 4);
if (isLoading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 animate-pulse">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg" />
))}
</div>
);
}
if (error || related.length === 0) return null;
return (
<aside className="mt-12 pt-8 border-t border-[var(--border-color)]">
<div className="flex items-center gap-2 mb-3.5">
<Sparkles className="w-4 h-4 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">Related Knowledge</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{related.map((r) => {
const rInfo = getSectionMeta(r.section);
return (
<Link
key={r.slug}
href={`/${r.slug}`}
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg p-4 transition-colors shadow-xs"
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<span className="text-[10px] text-[var(--text-muted)] font-mono uppercase font-semibold">
{rInfo.icon} {r.section}
</span>
<time className="text-[11px] text-[var(--text-dim)] font-mono">
{new Date(r.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</time>
</div>
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1">{r.title}</h3>
</Link>
);
})}
</div>
</aside>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { mutate } from "swr";
import { useRevisions } from "@/hooks/useRevisions";
import { History } from "lucide-react";
export default function RevisionList({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { revisions, isLoading, error, mutate: revalidate } = useRevisions(slug, 10);
if (isLoading) {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg" />
))}
</div>
);
}
if (error) {
return <p className="text-xs text-amber-600 dark:text-amber-400">Failed to load revisions: {(error as Error).message}</p>;
}
if (revisions.length === 0) return null;
return (
<aside className="mt-10 pt-8 border-t border-[var(--border-color)]">
<div className="flex items-center justify-between mb-3.5">
<div className="flex items-center gap-2">
<History className="w-4 h-4 text-[var(--text-muted)]" />
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">Revision History ({revisions.length})</h2>
</div>
</div>
<div className="space-y-2">
{revisions.map((rev) => (
<div key={rev.id} className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-xs">
<div className="flex items-center gap-2 min-w-0 font-mono">
<span className="text-[var(--text-primary)] bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] font-bold text-[10px]">v{rev.revisionNo}</span>
<span className="text-[var(--text-secondary)] font-sans truncate text-xs">{rev.reason || "Updated document in PostgreSQL"}</span>
<span className="text-[var(--text-dim)] hidden sm:inline text-[11px]">· {new Date(rev.createdAt).toLocaleString()}</span>
</div>
{canEdit && (
<form
action="/api/revisions/restore"
method="post"
onSubmit={() => {
// Optimistically revalidate after form post navigates back
setTimeout(() => {
revalidate();
mutate((k) => typeof k === "string" && k.startsWith("/api/docs"));
}, 800);
}}
>
<input type="hidden" name="id" value={rev.id} />
<button
type="submit"
className="text-xs text-[var(--text-primary)] hover:text-black dark:hover:text-white bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] transition-colors font-medium cursor-pointer"
>
Restore
</button>
</form>
)}
</div>
))}
</div>
</aside>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { Search, X } from "lucide-react";
interface SearchBarProps {
value: string;
onChange: (v: string) => void;
placeholder?: string;
autoFocus?: boolean;
onClear?: () => void;
}
export default function SearchBar({ value, onChange, placeholder, autoFocus, onClear }: SearchBarProps) {
return (
<div className="relative">
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Search knowledge base..."}
autoFocus={autoFocus}
className="w-full pl-10 pr-9 py-3 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] text-sm transition-all"
/>
<Search className="w-4 h-4 text-[var(--text-muted)] absolute left-3.5 top-3.5 pointer-events-none" />
{value && (
<button
onClick={() => {
onChange("");
onClear?.();
}}
className="absolute right-3 top-3 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
aria-label="Clear query"
type="button"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
export function InlineSearchBar({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="relative">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Filter index..."}
className="w-full pl-8 pr-7 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-all"
/>
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
{value && (
<button
onClick={() => onChange("")}
className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
aria-label="Clear filter"
type="button"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import Link from "next/link";
export function SectionCard({
id,
label,
icon,
desc,
docCount,
}: {
id: string;
label: string;
icon: string;
desc: string;
docCount: number;
}) {
return (
<div className="group flex flex-col justify-between bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-5 transition-all shadow-xs">
<div>
<div className="flex items-center justify-between gap-2 mb-3">
<span className="text-2xl">{icon}</span>
<Link
href={`/${id}`}
className="text-xs font-mono text-[var(--text-muted)] hover:text-[var(--text-primary)] font-semibold"
>
{docCount} docs →
</Link>
</div>
<h3 className="font-bold text-sm text-[var(--text-primary)] mb-1">{label}</h3>
<p className="text-xs text-[var(--text-muted)] mb-4 line-clamp-2 leading-relaxed">{desc}</p>
</div>
<div className="pt-3 border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] font-mono">
/{id} · {docCount} document{docCount !== 1 ? "s" : ""}
</div>
</div>
);
}
+59 -53
View File
@@ -1,9 +1,21 @@
"use client"; "use client";
import { useEffect, useState, useMemo } from "react"; import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { getSectionMeta } from "@mcpedia/config/sections"; import { getSectionMeta } from "@mcpedia/config/sections";
import { useDocuments } from "@/hooks/useDocuments";
import {
Folder,
FolderOpen,
FileText,
ChevronRight,
Search,
X,
Plus,
} from "lucide-react";
import type { DocumentMeta } from "@mcpedia/types";
interface Doc { interface Doc {
slug: string; slug: string;
@@ -102,13 +114,13 @@ function TreeNodeItem({
const isCollapsed = collapsedMap[node.slug] ?? false; const isCollapsed = collapsedMap[node.slug] ?? false;
return ( return (
<li className={`select-none ${depth > 0 ? "ml-3 pl-2 border-l border-[var(--border-color)]" : ""}`}> <li className={`select-none ${depth > 0 ? "ml-3.5 pl-2 border-l border-[var(--border-color)]" : ""}`}>
<div <div
className={`group flex items-center justify-between gap-1.5 text-xs py-1.5 px-2 rounded-md transition-all ${ className={`group flex items-center justify-between gap-1 text-xs py-1.5 px-2 rounded-md transition-all ${
isActive isActive
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-semibold border border-[var(--brand)]/30 shadow-sm" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
: isParentOfActive : isParentOfActive
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)]/60"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`} }`}
> >
@@ -117,15 +129,23 @@ function TreeNodeItem({
className="flex items-center gap-2 min-w-0 flex-1 py-0.5" className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
title={node.title || node.name} title={node.title || node.name}
> >
<span className="text-xs shrink-0 opacity-80 group-hover:opacity-100"> <span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
{isFolder ? "📁" : "📄"} {isFolder ? (
isCollapsed ? (
<Folder className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5 text-[var(--text-primary)]" />
)
) : (
<FileText className="w-3.5 h-3.5" />
)}
</span> </span>
<span className="truncate">{node.title || node.name}</span> <span className="truncate">{node.title || node.name}</span>
</Link> </Link>
{isFolder && ( {isFolder && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1 py-0.2 rounded font-mono"> <span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
{node.docCount} {node.docCount}
</span> </span>
<button <button
@@ -135,24 +155,19 @@ function TreeNodeItem({
e.stopPropagation(); e.stopPropagation();
toggleCollapse(node.slug); toggleCollapse(node.slug);
}} }}
className="p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform" className="p-1 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform cursor-pointer"
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"} aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
> >
<svg <ChevronRight
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : "rotate-0"}`} className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "rotate-0" : "rotate-90"}`}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
</div> </div>
)} )}
</div> </div>
{isFolder && !isCollapsed && ( {isFolder && !isCollapsed && (
<ul className="mt-0.5 space-y-0.5 animate-fade-in"> <ul className="mt-1 space-y-0.5 animate-fade-in">
{node.children.map((child) => ( {node.children.map((child) => (
<TreeNodeItem <TreeNodeItem
key={child.slug} key={child.slug}
@@ -170,21 +185,11 @@ function TreeNodeItem({
} }
export default function Sidebar() { export default function Sidebar() {
const [docs, setDocs] = useState<Doc[]>([]); const { docs } = useDocuments();
const [filterText, setFilterText] = useState(""); const [filterText, setFilterText] = useState("");
const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({}); const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({});
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => {
fetch("/api/docs")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setDocs(data);
else if (data?.docs) setDocs(data.docs);
})
.catch(() => setDocs([]));
}, []);
function toggleCollapse(slug: string) { function toggleCollapse(slug: string) {
setCollapsedMap((prev) => ({ ...prev, [slug]: !prev[slug] })); setCollapsedMap((prev) => ({ ...prev, [slug]: !prev[slug] }));
} }
@@ -196,7 +201,7 @@ export default function Sidebar() {
(d) => (d) =>
d.title.toLowerCase().includes(lower) || d.title.toLowerCase().includes(lower) ||
d.slug.toLowerCase().includes(lower) || d.slug.toLowerCase().includes(lower) ||
d.tags.some((t) => t.toLowerCase().includes(lower)), d.tags.some((t: string) => t.toLowerCase().includes(lower)),
); );
}, [docs, filterText]); }, [docs, filterText]);
@@ -212,7 +217,7 @@ export default function Sidebar() {
}, [filteredDocs]); }, [filteredDocs]);
return ( return (
<nav className="h-full flex flex-col py-4 px-3"> <nav className="h-full flex flex-col py-4 px-3 select-none">
{/* Sidebar search filter */} {/* Sidebar search filter */}
<div className="mb-3"> <div className="mb-3">
<div className="relative"> <div className="relative">
@@ -220,31 +225,25 @@ export default function Sidebar() {
type="text" type="text"
value={filterText} value={filterText}
onChange={(e) => setFilterText(e.target.value)} onChange={(e) => setFilterText(e.target.value)}
placeholder="Filter docs..." placeholder="Filter index..."
className="w-full pl-7 pr-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] transition-colors" className="w-full pl-8 pr-7 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-all"
/> />
<svg <Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
className="w-3.5 h-3.5 text-[var(--text-muted)] absolute left-2 top-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{filterText && ( {filterText && (
<button <button
onClick={() => setFilterText("")} onClick={() => setFilterText("")}
className="absolute right-2 top-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]" className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
aria-label="Clear filter"
> >
✕ <X className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Summary count */} {/* Summary count */}
<div className="flex items-center justify-between px-1 mb-3 text-[11px] text-[var(--text-dim)]"> <div className="flex items-center justify-between px-1 mb-2 text-[10px] text-[var(--text-muted)] font-mono uppercase tracking-wider">
<span className="uppercase tracking-wider font-mono">Knowledge Base</span> <span>Knowledge Index</span>
<span>{filteredDocs.length} items</span> <span>{filteredDocs.length} items</span>
</div> </div>
@@ -261,17 +260,17 @@ export default function Sidebar() {
<div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0"> <div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
<Link <Link
href={`/${id}`} href={`/${id}`}
className={`flex items-center justify-between gap-1.5 px-1 py-1 mb-1 rounded text-xs font-semibold uppercase tracking-wider transition-colors ${ className={`flex items-center justify-between gap-1.5 px-2 py-1 mb-1 rounded-md text-xs font-semibold uppercase tracking-wider transition-colors ${
isSectionActive isSectionActive
? "text-[var(--brand)] dark:text-[var(--accent)]" ? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
}`} }`}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<span>{icon}</span> <span className="text-xs">{icon}</span>
<span>{label}</span> <span>{label}</span>
</div> </div>
<span className="text-[10px] text-[var(--text-dim)] font-normal font-mono"> <span className="text-[10px] text-[var(--text-muted)] font-normal font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)]">
{sectionDocs.length} {sectionDocs.length}
</span> </span>
</Link> </Link>
@@ -291,15 +290,22 @@ export default function Sidebar() {
</div> </div>
); );
})} })}
{filteredDocs.length === 0 && (
<div className="text-center py-8 text-xs text-[var(--text-dim)] font-mono">
No matching documents
</div>
)}
</div> </div>
{/* Bottom quick links */} {/* Bottom quick links */}
<div className="pt-3 border-t border-[var(--border-color)] mt-auto"> <div className="pt-3 border-t border-[var(--border-color)] mt-auto">
<Link <Link
href="/create" href="/create"
className="flex items-center justify-center gap-1.5 w-full py-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors font-medium" className="flex items-center justify-center gap-2 w-full py-2 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-primary)] rounded-md transition-all font-medium shadow-xs"
> >
<span>+ Create New Document</span> <Plus className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<span>Create Document</span>
</Link> </Link>
</div> </div>
</nav> </nav>
+22 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import GithubSlugger from "github-slugger"; import GithubSlugger from "github-slugger";
import { AlignLeft, ArrowUp, Hash } from "lucide-react";
interface TOCEntry { interface TOCEntry {
id: string; id: string;
@@ -58,18 +59,29 @@ export default function TOC({ source }: { source: string }) {
if (toc.length === 0) return null; if (toc.length === 0) return null;
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
return ( return (
<nav className="text-xs"> <nav className="text-xs space-y-4">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]"> <div className="flex items-center justify-between pb-2 border-b border-[var(--border-color)]">
<svg className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px] font-mono">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" /> <AlignLeft className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
</svg> <span>On This Page</span>
<span className="font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px]"> </div>
On this page <button
</span> onClick={scrollToTop}
type="button"
className="text-[10px] text-[var(--text-dim)] hover:text-[var(--text-primary)] flex items-center gap-0.5 hover:underline"
title="Scroll to top"
>
<ArrowUp className="w-3 h-3" />
<span>Top</span>
</button>
</div> </div>
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-2.5"> <ul className="space-y-1.5 border-l border-[var(--border-color)] pl-3">
{toc.map((entry) => { {toc.map((entry) => {
const isActive = activeId === entry.id; const isActive = activeId === entry.id;
return ( return (
@@ -90,7 +102,7 @@ export default function TOC({ source }: { source: string }) {
history.pushState(null, "", `#${entry.id}`); history.pushState(null, "", `#${entry.id}`);
} }
}} }}
className={`block py-0.5 transition-colors line-clamp-1 ${ className={`block py-0.5 transition-all line-clamp-1 ${
isActive isActive
? "text-[var(--brand)] dark:text-[var(--accent)] font-semibold translate-x-0.5" ? "text-[var(--brand)] dark:text-[var(--accent)] font-semibold translate-x-0.5"
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]" : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+57
View File
@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import { Tag, TrendingUp } from "lucide-react";
export default function TagCloud({ limit = 24 }: { limit?: number }) {
const { data, isLoading } = useSWR<{ tags: { tag: string; count: number }[] }>(
`/api/tags?limit=${limit}`,
fetcher,
{ revalidateOnFocus: false },
);
const tags = data?.tags ?? [];
if (isLoading) {
return (
<div className="flex flex-wrap gap-1.5 animate-pulse">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-6 w-16 bg-[var(--bg-elevated)] rounded" />
))}
</div>
);
}
if (tags.length === 0) return null;
const max = Math.max(...tags.map((t) => t.count), 1);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<h3 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">Popular Tags</h3>
<span className="text-[11px] text-[var(--text-dim)] font-mono">({tags.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{tags.map(({ tag, count }) => {
const intensity = count / max;
const opacity = 0.45 + intensity * 0.55;
return (
<Link
key={tag}
href={`/search?q=${encodeURIComponent(tag)}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-mono transition-all hover:scale-[1.02] bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
style={{ opacity }}
title={`${count} document${count !== 1 ? "s" : ""}`}
>
<span>#{tag}</span>
<span className="text-[10px] bg-[var(--bg-elevated)] px-1 py-0.2 rounded font-semibold">{count}</span>
</Link>
);
})}
</div>
</div>
);
}
+5 -8
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Sun, Moon } from "lucide-react";
const STORAGE_KEY = "mcpedia-theme"; const STORAGE_KEY = "mcpedia-theme";
@@ -36,7 +37,7 @@ export default function ThemeToggle() {
if (!mounted) { if (!mounted) {
return ( return (
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-50" /> <div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-40" />
); );
} }
@@ -46,16 +47,12 @@ export default function ThemeToggle() {
type="button" type="button"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"} title={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center" className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center cursor-pointer"
> >
{isDark ? ( {isDark ? (
<svg className="w-4 h-4 text-amber-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : ( ) : (
<svg className="w-4 h-4 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Moon className="w-4 h-4 text-indigo-600 hover:-rotate-12 transition-transform" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)} )}
</button> </button>
); );
+9 -6
View File
@@ -4,6 +4,7 @@ import { WEBHOOK_SECRET } from "@mcpedia/config";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import DocForm from "@/components/DocForm"; import DocForm from "@/components/DocForm";
import { Edit3, ArrowLeft, Plus } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -33,14 +34,16 @@ export default async function CreatePage() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]"> <div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3.5">
<div className="w-10 h-10 rounded-lg bg-[var(--brand)]/15 border border-[var(--brand)]/30 flex items-center justify-center text-xl text-[var(--brand)] dark:text-[var(--accent)]"> <div className="w-11 h-11 rounded-2xl bg-[var(--brand)]/15 border border-[var(--brand)]/30 flex items-center justify-center text-[var(--brand)] dark:text-[var(--accent)]">
✏️ <Plus className="w-6 h-6" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Create Document</h1> <h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
<p className="text-xs text-[var(--text-muted)] mt-0.5"> Create Knowledge Document
Writes directly to PostgreSQL, records revision history, and computes vector embeddings. </h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
Persists directly into PostgreSQL, tracks revision history, and generates vector embeddings.
</p> </p>
</div> </div>
</div> </div>
+21
View File
@@ -0,0 +1,21 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
export default function DashboardQueueStrip() {
const { data } = useSWR<{ waiting: number; active: number; completed: number; failed: number; delayed: number }>("/api/queue/status", fetcher, {
refreshInterval: 6000,
revalidateOnFocus: false,
});
if (!data) return null;
return (
<div className="flex flex-wrap gap-2 text-[11px] font-mono" role="status" aria-live="polite">
<span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">waiting: <strong>{data.waiting}</strong></span>
<span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] text-sky-600 dark:text-sky-400">active: <strong>{data.active}</strong></span>
<span className={`px-2.5 py-1 rounded bg-[var(--bg-surface)] border ${data.failed ? "border-amber-500/40 text-amber-600 dark:text-amber-400" : "border-[var(--border-color)] text-[var(--text-dim)]"}`}>failed: <strong>{data.failed}</strong></span>
<span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] text-emerald-600 dark:text-emerald-400">completed: <strong>{data.completed}</strong></span>
{data.delayed ? <span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">delayed: <strong>{data.delayed}</strong></span> : null}
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import TagCloud from "@/components/TagCloud";
export default function TagCloudClient() {
return <TagCloud limit={30} />;
}
+114
View File
@@ -0,0 +1,114 @@
import Link from "next/link";
import { listDocuments, listSections } from "@mcpedia/core";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [all, sections] = await Promise.all([listDocuments(), listSections()]);
const totalTags = new Set(all.flatMap((d) => d.tags)).size;
const recentDocs = [...all].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 8);
// Tag aggregation server-side for SSR snapshot (client TagCloud will hydrate live via SWR)
const tagCounts = new Map<string, number>();
for (const d of all) for (const t of d.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
const topTags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12);
return (
<div className="space-y-8 max-w-5xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">Dashboard</h1>
<p className="text-xs sm:text-sm text-[var(--text-muted)] mt-1">Live overview of the MCPedia knowledge base. Data via PostgreSQL Core + BullMQ queue.</p>
</div>
<Link href="/create" className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] text-[var(--brand-fg)] rounded-md text-xs font-semibold shadow-xs">
+ New Doc
</Link>
</div>
{/* Metric strip — SSR snapshot */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">DB Documents</div>
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">{all.length}</div>
<div className="text-[11px] text-[var(--text-dim)] font-mono">{totalTags} unique tags</div>
</div>
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Active Sections</div>
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">{sections.length}</div>
<div className="text-[11px] text-[var(--text-dim)] font-mono truncate">{sections.map((s) => s.id).join(" · ")}</div>
</div>
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Search</div>
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">RRF</div>
<div className="text-[11px] text-[var(--text-dim)] font-mono">FTS + Cosine Vectors</div>
</div>
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">MCP Tools</div>
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">13</div>
<div className="text-[11px] text-[var(--text-dim)] font-mono">HTTP :4021</div>
</div>
</div>
{/* Queue strip — client component polls live */}
<DashboardQueueStrip />
{/* Section breakdown */}
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
<h2 className="text-xs font-bold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-3">Section Breakdown</h2>
<div className="space-y-2">
{sections.map((s) => {
const pct = all.length ? Math.round((s.docCount / all.length) * 100) : 0;
return (
<Link key={s.id} href={`/${s.id}`} className="flex items-center gap-3 group">
<span className="text-sm w-6 text-center">{s.icon}</span>
<span className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline w-28 truncate">{s.label}</span>
<div className="flex-1 h-2 bg-[var(--bg-elevated)] rounded-full overflow-hidden border border-[var(--border-color)]">
<div className="h-full bg-[var(--brand)] rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[11px] font-mono text-[var(--text-muted)] w-14 text-right">{s.docCount} docs · {pct}%</span>
</Link>
);
})}
</div>
</section>
{/* Top tags */}
{topTags.length > 0 && (
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
<h2 className="text-xs font-bold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-3">Top Tags</h2>
<div className="flex flex-wrap gap-1.5">
{topTags.map(([tag, count]) => (
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`} className="px-2.5 py-1 rounded-full bg-[var(--bg-elevated)] border border-[var(--border-color)] text-xs font-mono text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-muted)]">
#{tag} <span className="text-[10px] bg-[var(--bg-surface)] px-1 py-0.2 rounded ml-1">{count}</span>
</Link>
))}
</div>
<div className="mt-3">
<TagCloudClient />
</div>
</section>
)}
{/* Recent */}
{recentDocs.length > 0 && (
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-3">Recently Updated</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{recentDocs.map((d) => (
<Link key={d.slug} href={`/${d.slug}`} className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl shadow-xs group">
<div className="text-[10px] font-mono uppercase text-[var(--text-muted)]">{d.section} · {new Date(d.updatedAt).toLocaleDateString()}</div>
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1 mt-1">{d.title}</div>
{d.tags.length > 0 && <div className="flex gap-1 mt-2 flex-wrap">{d.tags.slice(0, 3).map((t) => <span key={t} className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded font-mono text-[var(--text-muted)]">#{t}</span>)}</div>}
</Link>
))}
</div>
</section>
)}
</div>
);
}
// Client islands — isolated so the page stays RSC
import DashboardQueueStrip from "./QueueStrip";
import TagCloudClient from "./TagCloudClient";
+11 -7
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "next/link";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getExportDocuments, formatScopeTitle, type ExportSortOption } from "@mcpedia/core"; import { getExportDocuments, formatScopeTitle, type ExportSortOption } from "@mcpedia/core";
import PdfExportView from "@/components/PdfExportView"; import PdfExportView from "@/components/PdfExportView";
import { FileText, ArrowLeft } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -43,14 +44,16 @@ export default async function ExportPdfPage({
if (exportData.documents.length === 0) { if (exportData.documents.length === 0) {
return ( return (
<div className="py-16 text-center space-y-4 max-w-lg mx-auto"> <div className="py-16 text-center space-y-4 max-w-lg mx-auto bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-3xl p-8 shadow-sm">
<div className="text-4xl">📄</div> <div className="w-14 h-14 rounded-2xl bg-[var(--bg-elevated)] text-[var(--text-dim)] flex items-center justify-center mx-auto">
<FileText className="w-7 h-7" />
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]"> <h1 className="text-2xl font-bold text-[var(--text-primary)]">
No Documents Found for Export No Documents Found for Export
</h1> </h1>
<p className="text-sm text-[var(--text-muted)]"> <p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
Could not find any published documents matching the path{" "} Could not find any published documents matching the path{" "}
<code className="text-[var(--brand)] font-mono"> <code className="text-[var(--brand)] dark:text-[var(--accent)] font-mono font-semibold">
{targetPath || section || "root"} {targetPath || section || "root"}
</code> </code>
. .
@@ -58,9 +61,10 @@ export default async function ExportPdfPage({
<div className="pt-4"> <div className="pt-4">
<Link <Link
href={backHref} href={backHref}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-lg shadow-sm transition-all" className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-xl shadow-md shadow-[var(--brand)]/20 transition-all"
> >
<span>← Return to Knowledge Base</span> <ArrowLeft className="w-3.5 h-3.5" />
<span>Return to Knowledge Base</span>
</Link> </Link>
</div> </div>
</div> </div>
+321 -207
View File
@@ -1,32 +1,42 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
/* Light mode defaults (:root) */ /*
MCPedia Design Philosophy: Archival Precision & Technical Monolith
- Strict monochromatic foundation (Graphite, Zinc, Charcoal)
- Zero decorative AI gradients or blurred blobs
- Typographic hierarchy: Strong contrast, razor-sharp hairline borders, monospace telemetry
- Functional signal indicators (Status Green, Alert Amber, Critical Coral)
*/
/* Light mode defaults (:root) - Technical Paper / Archival Slate */
:root { :root {
color-scheme: light; color-scheme: light;
--bg-app: #f8fafc; --bg-app: #f9fafb;
--bg-surface: #ffffff; --bg-surface: #ffffff;
--bg-surface-hover: #f1f5f9; --bg-surface-hover: #f3f4f6;
--bg-elevated: #f1f5f9; --bg-elevated: #f3f4f6;
--bg-elevated-hover: #e2e8f0; --bg-elevated-hover: #e5e7eb;
--border-color: #e2e8f0; --border-color: #e5e7eb;
--border-subtle: #edf2f7; --border-subtle: #f3f4f6;
--border-card: #e2e8f0; --border-card: #e5e7eb;
--text-primary: #0f172a; --text-primary: #111827;
--text-secondary: #334155; --text-secondary: #374151;
--text-muted: #64748b; --text-muted: #6b7280;
--text-dim: #94a3b8; --text-dim: #9ca3af;
--brand: #4f46e5; /* Accent: Precision Technical Ink */
--brand-hover: #4338ca; --brand: #18181b;
--accent: #4f46e5; --brand-hover: #27272a;
--accent-hover: #4338ca; --brand-fg: #ffffff;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--nav-bg: rgba(255, 255, 255, 0.88); --nav-bg: rgba(255, 255, 255, 0.92);
/* Code & Syntax variables (Light Mode) */ /* Code & Syntax (Light Mode) */
--code-bg: #f1f5f9; --code-bg: #f3f4f6;
--code-block-bg: #f8fafc; --code-block-bg: #f8fafc;
--code-header-bg: #f1f5f9; --code-header-bg: #f1f5f9;
--border-code: #e2e8f0; --border-code: #e2e8f0;
@@ -37,15 +47,15 @@
--code-inline-text: #0f172a; --code-inline-text: #0f172a;
--code-inline-border: #e2e8f0; --code-inline-border: #e2e8f0;
--table-zebra: rgba(241, 245, 249, 0.6); --table-zebra: rgba(243, 244, 246, 0.5);
--scrollbar-thumb: #cbd5e1; --scrollbar-thumb: #d1d5db;
--scrollbar-thumb-hover: #94a3b8; --scrollbar-thumb-hover: #9ca3af;
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.05); --shadow-card: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
/* Syntax Highlighting (Light Mode) */ /* Syntax Highlighting */
--hl-keyword: #cf222e; --hl-keyword: #cf222e;
--hl-string: #0a3069; --hl-string: #0a3069;
--hl-title: #8250df; --hl-title: #6f42c1;
--hl-attr: #953800; --hl-attr: #953800;
--hl-number: #0550ae; --hl-number: #0550ae;
--hl-comment: #6e7781; --hl-comment: #6e7781;
@@ -55,48 +65,50 @@
--hl-meta: #6e7781; --hl-meta: #6e7781;
} }
/* Dark mode overrides (html.dark) */ /* Dark mode overrides (html.dark) - Deep Basalt / Precision Carbon */
html.dark { html.dark {
color-scheme: dark; color-scheme: dark;
--bg-app: #08090a; --bg-app: #090a0c;
--bg-surface: #0f1011; --bg-surface: #101216;
--bg-surface-hover: #131415; --bg-surface-hover: #16181f;
--bg-elevated: #141517; --bg-elevated: #161920;
--bg-elevated-hover: #1b1d20; --bg-elevated-hover: #1e222b;
--border-color: #1f2022; --border-color: #222630;
--border-subtle: rgba(255, 255, 255, 0.06); --border-subtle: #181b22;
--border-card: #1f2022; --border-card: #222630;
--text-primary: #f7f8f8; --text-primary: #f3f4f6;
--text-secondary: #d0d6e0; --text-secondary: #d1d5db;
--text-muted: #8a8f98; --text-muted: #9ca3af;
--text-dim: #62666d; --text-dim: #6b7280;
--brand: #5e6ad2; /* Accent: Monolithic Snow Ink */
--brand-hover: #7170ff; --brand: #f3f4f6;
--accent: #7170ff; --brand-hover: #ffffff;
--accent-hover: #828fff; --brand-fg: #090a0c;
--accent: #60a5fa;
--accent-hover: #93c5fd;
--nav-bg: rgba(8, 9, 10, 0.85); --nav-bg: rgba(9, 10, 12, 0.90);
/* Code & Syntax variables (Dark Mode) */ /* Code & Syntax (Dark Mode) */
--code-bg: #141517; --code-bg: #0c0e12;
--code-block-bg: #0d1117; --code-block-bg: #0c0e12;
--code-header-bg: #161b22; --code-header-bg: #14171d;
--border-code: #30363d; --border-code: #222630;
--code-button-bg: #21262d; --code-button-bg: #181b22;
--code-button-hover-bg: #30363d; --code-button-hover-bg: #222630;
--pre-text: #e6edf3; --pre-text: #e5e7eb;
--code-inline-bg: #161b22; --code-inline-bg: #161920;
--code-inline-text: #e6edf3; --code-inline-text: #e5e7eb;
--code-inline-border: #30363d; --code-inline-border: #222630;
--table-zebra: rgba(255, 255, 255, 0.02); --table-zebra: rgba(255, 255, 255, 0.015);
--scrollbar-thumb: #23252a; --scrollbar-thumb: #222630;
--scrollbar-thumb-hover: #383b42; --scrollbar-thumb-hover: #374151;
--shadow-card: none; --shadow-card: none;
/* Syntax Highlighting (Dark Mode) */ /* Syntax Highlighting */
--hl-keyword: #ff7b72; --hl-keyword: #ff7b72;
--hl-string: #a5d6ff; --hl-string: #a5d6ff;
--hl-title: #d2a8ff; --hl-title: #d2a8ff;
@@ -110,7 +122,7 @@ html.dark {
} }
@theme inline { @theme inline {
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--color-background: var(--bg-app); --color-background: var(--bg-app);
@@ -141,16 +153,17 @@ body {
background-color: var(--bg-app); background-color: var(--bg-app);
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-sans); font-family: var(--font-sans);
font-feature-settings: "cv01", "ss03"; font-feature-settings: "cv01", "ss03", "zero", "calt";
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color 0.2s ease, color 0.2s ease; transition: background-color 0.15s ease, color 0.15s ease;
min-height: 100vh;
} }
/* Custom scrollbar */ /* Custom hairline scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 5px;
height: 6px; height: 5px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -159,49 +172,88 @@ body {
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb); background: var(--scrollbar-thumb);
border-radius: 9999px; border-radius: 2px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover); background: var(--scrollbar-thumb-hover);
} }
/* Glassmorphism helpers */ /* Glass navigation */
.glass-panel {
background: var(--bg-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
}
.glass-nav { .glass-nav {
background: var(--nav-bg); background: var(--nav-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
} }
/* Prose markdown typography for both Light and Dark modes */ /* Tactical Architectural Callouts */
.callout {
border-left-width: 3px;
border-radius: 0 0.375rem 0.375rem 0;
padding: 0.875rem 1.125rem;
margin: 1.5rem 0;
}
.callout-note {
border-left-color: var(--text-muted);
background-color: var(--bg-elevated);
}
.callout-tip {
border-left-color: #10b981;
background-color: rgba(16, 185, 129, 0.06);
}
.callout-important {
border-left-color: #3b82f6;
background-color: rgba(59, 130, 246, 0.06);
}
.callout-warning {
border-left-color: #f59e0b;
background-color: rgba(245, 158, 11, 0.06);
}
.callout-caution {
border-left-color: #ef4444;
background-color: rgba(239, 68, 68, 0.06);
}
/* Prose Typography */
.prose { .prose {
color: var(--text-secondary) !important; color: var(--text-secondary) !important;
max-width: 100% !important;
} }
.prose h1, .prose h2, .prose h3, .prose h4, .prose strong { .prose h1, .prose h2, .prose h3, .prose h4, .prose strong {
color: var(--text-primary) !important; color: var(--text-primary) !important;
font-weight: 700;
letter-spacing: -0.02em;
} }
.prose a { .prose a {
color: var(--accent) !important; color: var(--text-primary) !important;
text-decoration: underline;
text-decoration-color: var(--border-color);
text-underline-offset: 3px;
font-weight: 500;
transition: all 0.15s ease;
}
.prose a:hover {
text-decoration-color: var(--text-primary);
} }
.prose table { .prose table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
margin: 1.75rem 0; margin: 2rem 0;
font-size: 0.875rem; font-size: 0.875rem;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--bg-surface);
} }
.prose th, .prose th,
@@ -215,9 +267,10 @@ body {
background: var(--bg-elevated); background: var(--bg-elevated);
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
font-size: 0.8125rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.08em;
font-family: var(--font-mono);
} }
.prose td { .prose td {
@@ -228,6 +281,10 @@ body {
border-bottom: none; border-bottom: none;
} }
.prose tbody tr:hover {
background: var(--bg-elevated-hover);
}
.prose tbody tr:nth-child(even) { .prose tbody tr:nth-child(even) {
background: var(--table-zebra); background: var(--table-zebra);
} }
@@ -236,11 +293,12 @@ body {
background: var(--code-block-bg) !important; background: var(--code-block-bg) !important;
color: var(--pre-text) !important; color: var(--pre-text) !important;
border: 1px solid var(--border-code); border: 1px solid var(--border-code);
border-radius: 0.75rem; border-radius: 0.5rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
overflow-x: auto; overflow-x: auto;
position: relative; position: relative;
font-family: var(--font-mono); font-family: var(--font-mono);
line-height: 1.6;
} }
.prose code { .prose code {
@@ -252,8 +310,8 @@ body {
background: var(--code-inline-bg); background: var(--code-inline-bg);
color: var(--code-inline-text); color: var(--code-inline-text);
border: 1px solid var(--code-inline-border); border: 1px solid var(--code-inline-border);
padding: 0.15rem 0.375rem; padding: 0.15rem 0.35rem;
border-radius: 0.375rem; border-radius: 0.25rem;
font-weight: 500; font-weight: 500;
} }
@@ -263,13 +321,13 @@ body {
} }
.prose blockquote { .prose blockquote {
border-left: 3px solid var(--brand); border-left: 2px solid var(--text-muted);
background: rgba(94, 106, 210, 0.08); background: var(--bg-elevated);
border-radius: 0 0.375rem 0.375rem 0; border-radius: 0 0.375rem 0.375rem 0;
margin: 1.5em 0; margin: 1.5em 0;
padding: 0.75em 1.25em; padding: 0.75em 1.25em;
font-style: normal; font-style: normal;
color: var(--text-muted); color: var(--text-secondary);
} }
.prose hr { .prose hr {
@@ -277,7 +335,7 @@ body {
margin: 2.5rem 0; margin: 2.5rem 0;
} }
/* Syntax Highlighting Tokens (Highlight.js) */ /* Syntax Highlighting */
.hljs { .hljs {
color: var(--pre-text); color: var(--pre-text);
background: transparent; background: transparent;
@@ -346,14 +404,14 @@ body {
} }
.hljs-addition { .hljs-addition {
color: #1a7f37; color: #10b981;
background-color: rgba(46, 160, 67, 0.15); background-color: rgba(16, 185, 129, 0.15);
border-radius: 2px; border-radius: 2px;
} }
.hljs-deletion { .hljs-deletion {
color: #cf222e; color: #ef4444;
background-color: rgba(248, 81, 73, 0.15); background-color: rgba(239, 68, 68, 0.15);
border-radius: 2px; border-radius: 2px;
} }
@@ -365,47 +423,46 @@ body {
font-weight: bold; font-weight: bold;
} }
/* Animations */ /* Transitions */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.2s ease-out forwards; animation: fadeIn 0.18s ease-out forwards;
} }
/* ========================================================================== /* Print & PDF Export Stylesheet */
Print & PDF Export Stylesheet
Optimized for publication-grade PDF generation via browser / window.print()
========================================================================== */
@media print { @media print {
@page { @page {
size: A4 portrait; size: A4 portrait;
margin: 18mm 15mm 18mm 15mm; margin: 10mm 12mm 10mm 12mm;
} }
/* Force exact background colors and graphics */
*, *::before, *::after { *, *::before, *::after {
-webkit-print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important; print-color-adjust: exact !important;
box-sizing: border-box !important;
} }
/* Reset document canvas to white for crisp printing & ink economy */
html, body { html, body {
background-color: #ffffff !important; background-color: #ffffff !important;
color: #0f172a !important; color: #0f172a !important;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
font-size: 10pt !important; font-size: 8.5pt !important;
line-height: 1.5 !important; line-height: 1.35 !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
width: 100% !important; width: 100% !important;
} }
/* Hide app navigation, headers, sidebars, interactive controls */ /* Hide navigation, toolbars, sidebar, and web UI controls */
header, header.site-header,
aside, header.glass-nav,
aside.site-sidebar,
aside:not(.print-include),
footer,
nav.site-header, nav.site-header,
.no-print, .no-print,
.export-toolbar, .export-toolbar,
@@ -413,9 +470,9 @@ body {
display: none !important; display: none !important;
} }
/* Full width container in print */
main, main,
.print-container { .print-container,
main > div {
max-width: 100% !important; max-width: 100% !important;
width: 100% !important; width: 100% !important;
margin: 0 !important; margin: 0 !important;
@@ -425,63 +482,190 @@ body {
background: transparent !important; background: transparent !important;
} }
/* Page break rules */ /* Explicit page break classes */
.page-break-before, .page-break-before,
.chapter-page, .chapter-page {
.challenge-entry {
break-before: page !important; break-before: page !important;
page-break-before: always !important; page-break-before: always !important;
} }
.page-break-after, /* Prevent awkward mid-element page breaks */
.cover-page,
.scoreboard-page,
.toc-page {
break-after: page !important;
page-break-after: always !important;
}
.avoid-break-inside, .avoid-break-inside,
.code-block-wrapper, .code-block-wrapper,
.code-block-container, .code-block-container,
.challenge-entry, .callout,
blockquote,
table, table,
tr, tr,
img, img {
pre,
blockquote {
break-inside: avoid !important; break-inside: avoid !important;
page-break-inside: avoid !important; page-break-inside: avoid !important;
} }
img { /* Prevent headings from being orphaned at the bottom of pages */
max-width: 100% !important; .challenge-header,
height: auto !important; h1, h2, h3, h4, h5, h6 {
break-after: avoid !important;
page-break-after: avoid !important;
}
/* Dense / compact challenge flow */
.challenge-entry {
break-inside: auto !important;
page-break-inside: auto !important;
}
/* Compact typography & prose overrides */
p,
.prose p {
margin-top: 0 !important;
margin-bottom: 2.5pt !important;
font-size: 8.5pt !important;
line-height: 1.35 !important;
color: #1e293b !important;
}
h1,
.prose h1 {
font-size: 13pt !important;
line-height: 1.2 !important;
font-weight: 800 !important;
color: #020617 !important;
margin-top: 6pt !important;
margin-bottom: 2pt !important;
padding-bottom: 1pt !important;
border-bottom: 1px solid #cbd5e1 !important;
}
h2,
.prose h2 {
font-size: 10.5pt !important;
line-height: 1.2 !important;
font-weight: 700 !important;
color: #020617 !important;
margin-top: 5pt !important;
margin-bottom: 2pt !important;
padding-bottom: 1pt !important;
border-bottom: 1px solid #e2e8f0 !important;
}
h3,
.prose h3 {
font-size: 9.5pt !important;
line-height: 1.2 !important;
font-weight: 700 !important;
color: #0f172a !important;
margin-top: 4pt !important;
margin-bottom: 1.5pt !important;
}
h4, h5, h6,
.prose h4, .prose h5, .prose h6 {
font-size: 8.5pt !important;
line-height: 1.2 !important;
font-weight: 600 !important;
color: #1e293b !important;
margin-top: 3pt !important;
margin-bottom: 1pt !important;
}
ul, ol,
.prose ul, .prose ol {
margin-top: 1pt !important;
margin-bottom: 2.5pt !important;
padding-left: 13pt !important;
}
li,
.prose li {
margin-top: 0.5pt !important;
margin-bottom: 0.5pt !important;
font-size: 8.5pt !important;
line-height: 1.3 !important;
}
li > p {
margin: 0 !important;
}
blockquote,
.prose blockquote,
.callout {
margin: 2.5pt 0 !important;
padding: 2pt 5pt !important;
font-size: 8pt !important;
line-height: 1.3 !important;
border-left: 2.5px solid #64748b !important;
background-color: #f8fafc !important;
border-radius: 0 2px 2px 0 !important;
}
table,
.prose table {
margin: 2.5pt 0 !important;
font-size: 7.5pt !important;
line-height: 1.25 !important;
border-collapse: collapse !important;
width: 100% !important;
border: 1px solid #cbd5e1 !important;
}
th, td,
.prose th, .prose td {
padding: 2pt 4pt !important;
border: 1px solid #cbd5e1 !important;
color: #1e293b !important;
}
th,
.prose th {
background-color: #f1f5f9 !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.04em !important;
}
hr,
.prose hr {
margin: 3pt 0 !important;
border-color: #e2e8f0 !important;
}
img {
max-width: 100% !important;
max-height: 220px !important;
height: auto !important;
object-fit: contain !important;
margin: 2.5pt auto !important;
display: block !important;
} }
/* Code block wrapping and styling in print */
.code-block-wrapper, .code-block-wrapper,
.code-block-container { .code-block-container {
break-inside: avoid !important; break-inside: avoid !important;
page-break-inside: avoid !important; page-break-inside: avoid !important;
border: 1px solid #cbd5e1 !important; border: 1px solid #cbd5e1 !important;
background-color: #f8fafc !important; background-color: #f8fafc !important;
border-radius: 4px !important; border-radius: 3px !important;
margin: 8pt 0 !important; margin: 2.5pt 0 !important;
overflow: hidden !important; overflow: hidden !important;
} }
.code-block-header { .code-block-header {
border-bottom: 1px solid #cbd5e1 !important; border-bottom: 1px solid #cbd5e1 !important;
background-color: #f1f5f9 !important; background-color: #f1f5f9 !important;
padding: 3pt 8pt !important; padding: 1.5pt 5pt !important;
color: #334155 !important; color: #334155 !important;
font-size: 7pt !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-weight: 700 !important;
text-transform: uppercase !important;
} }
.code-block-wrapper pre { .code-block-wrapper pre {
border: none !important; border: none !important;
background-color: transparent !important; background-color: transparent !important;
padding: 6pt 8pt !important; padding: 3pt 5pt !important;
margin: 0 !important; margin: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
@@ -492,87 +676,17 @@ body {
white-space: pre-wrap !important; white-space: pre-wrap !important;
word-break: break-all !important; word-break: break-all !important;
overflow-wrap: anywhere !important; overflow-wrap: anywhere !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-size: 8.5pt !important; font-size: 7.5pt !important;
line-height: 1.4 !important; line-height: 1.3 !important;
color: #0f172a !important;
} }
code { :not(pre) > code {
padding: 0.5pt 2pt !important;
font-size: 7.5pt !important;
background-color: #f1f5f9 !important; background-color: #f1f5f9 !important;
color: #0f172a !important;
padding: 1pt 3pt !important;
border-radius: 3px !important;
border: 1px solid #e2e8f0 !important; border: 1px solid #e2e8f0 !important;
} border-radius: 2px !important;
pre {
background-color: #f8fafc !important;
color: #0f172a !important;
border: 1px solid #cbd5e1 !important;
border-radius: 4px !important;
padding: 8pt 10pt !important;
margin: 8pt 0 !important;
}
pre code {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
/* Typography tweaks for formal print */
.prose {
color: #0f172a !important;
font-size: 10pt !important;
}
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
color: #020617 !important;
font-weight: 700 !important;
page-break-after: avoid !important;
break-after: avoid !important;
}
.prose p,
.prose li {
color: #334155 !important;
}
.prose a {
color: #0f172a !important;
text-decoration: underline !important;
}
.prose blockquote {
background-color: #f8fafc !important;
border-left: 3pt solid #334155 !important;
color: #334155 !important;
padding: 6pt 10pt !important;
margin: 8pt 0 !important;
}
.prose table {
border: 1px solid #cbd5e1 !important;
font-size: 9pt !important;
width: 100% !important;
border-collapse: collapse !important;
}
.prose th {
background-color: #f1f5f9 !important;
color: #0f172a !important;
font-weight: 600 !important;
border-bottom: 1.5px solid #94a3b8 !important;
padding: 4pt 6pt !important;
}
.prose td {
border-bottom: 1px solid #e2e8f0 !important;
padding: 4pt 6pt !important;
} }
} }
+30
View File
@@ -0,0 +1,30 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import type { DocumentMeta } from "@mcpedia/types";
type DocsEnvelope = DocumentMeta[] | { docs: DocumentMeta[] };
function normalize(data: DocsEnvelope | undefined): DocumentMeta[] {
if (!data) return [];
if (Array.isArray(data)) return data;
return (data as { docs: DocumentMeta[] }).docs ?? [];
}
export function useDocuments(section?: string, status = "published") {
const params = new URLSearchParams();
if (section) params.set("section", section);
if (status) params.set("status", status);
const qs = params.toString();
const key = qs ? `/api/docs?${qs}` : "/api/docs";
const swr = useSWR<DocsEnvelope>(key, fetcher, {
revalidateOnFocus: false,
});
return {
...swr,
docs: normalize(swr.data),
};
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
export interface QueueStatus {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}
export function useQueueStatus(enabled = false) {
return useSWR<QueueStatus>(enabled ? "/api/queue/status" : null, fetcher, {
refreshInterval: 5_000,
dedupingInterval: 4_000,
revalidateOnFocus: false,
});
}
export function useTags(limit = 30) {
const key = `/api/tags?limit=${limit}`;
return useSWR<{ tags: { tag: string; count: number }[] }>(key, fetcher, {
revalidateOnFocus: false,
});
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import type { DocumentMeta } from "@mcpedia/types";
export function useRelated(slug: string, limit = 4) {
const key = slug ? `/api/related?slug=${encodeURIComponent(slug)}&limit=${limit}` : null;
const swr = useSWR<{ results: DocumentMeta[] }>(key, fetcher, {
revalidateOnFocus: false,
keepPreviousData: true,
});
return {
...swr,
related: swr.data?.results ?? [],
};
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
export interface RevisionSummary {
id: string;
slug: string;
revisionNo: number;
title: string;
reason: string;
createdAt: string;
bodyLength: number;
}
export function useRevisions(slug: string, limit = 10) {
const key = slug ? `/api/revisions?slug=${encodeURIComponent(slug)}&limit=${limit}` : null;
const swr = useSWR<{ revisions: RevisionSummary[] }>(key, fetcher, {
revalidateOnFocus: false,
keepPreviousData: true,
});
return {
...swr,
revisions: swr.data?.revisions ?? [],
};
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
export interface SearchHit {
slug: string;
title: string;
section: string;
score: number;
snippet: string;
}
export type SearchMode = "hybrid" | "keyword" | "semantic";
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(t);
}, [value, delayMs]);
return debounced;
}
export function useSearch(q: string, mode: SearchMode = "hybrid", limit = 30) {
const debouncedQ = useDebounced(q, 180);
const trimmed = debouncedQ.trim();
const key = trimmed ? `/api/search?q=${encodeURIComponent(trimmed)}&mode=${mode}&limit=${limit}` : null;
const swr = useSWR<{ results: SearchHit[] }>(key, fetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
dedupingInterval: 2_000,
});
return {
...swr,
hits: swr.data?.results ?? [],
isSearching: !!trimmed && swr.isLoading,
};
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import type { SectionInfo } from "@mcpedia/types";
export function useSections() {
return useSWR<SectionInfo[]>("/api/sections", fetcher, {
revalidateOnFocus: false,
});
}
Loaded 50 of 69 files, more files were not shown because too many files have changed in this diff. Show more