diff --git a/.hermes/plans/frontend-redesign-spec.md b/.hermes/plans/frontend-redesign-spec.md new file mode 100644 index 0000000..9875716 --- /dev/null +++ b/.hermes/plans/frontend-redesign-spec.md @@ -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 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(url: string): Promise` — 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 = (url: string): Promise => 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 `` (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. diff --git a/apps/web/app/[section]/[...slug]/page.tsx b/apps/web/app/[section]/[...slug]/page.tsx index bff6c35..342f717 100644 --- a/apps/web/app/[section]/[...slug]/page.tsx +++ b/apps/web/app/[section]/[...slug]/page.tsx @@ -1,13 +1,15 @@ import Link from "next/link"; import { notFound } from "next/navigation"; 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 Markdown from "@/components/Markdown"; import DocForm from "@/components/DocForm"; import TOC from "@/components/TOC"; import DocActions from "@/components/DocActions"; import PdfExportView from "@/components/PdfExportView"; +import RelatedGrid from "@/components/RelatedGrid"; +import RevisionList from "@/components/RevisionList"; import { classifyPath, extractFoldersForSection } from "@mcpedia/core"; import { getSectionMeta } from "@mcpedia/config"; import type { DocumentMeta } from "@mcpedia/core"; @@ -15,10 +17,8 @@ import { Folder, FileText, Clock, - History, FileDown, ArrowLeft, - Sparkles, CheckCircle2, XCircle, Target, @@ -382,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 readingTime = calculateReadingTime(doc.body); @@ -497,89 +495,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) { - {/* Related Documents Grid */} - {related.length > 0 && ( - - )} - - {/* Revision History */} - {revisions.length > 0 && ( - - )} + + {/* Desktop Sticky Table of Contents Column */} diff --git a/apps/web/app/api/docs/route.ts b/apps/web/app/api/docs/route.ts index d403141..695c68e 100644 --- a/apps/web/app/api/docs/route.ts +++ b/apps/web/app/api/docs/route.ts @@ -78,8 +78,15 @@ function splitPayload(body: Record): { } // GET /api/docs — list all documents (for sidebar navigation). -export async function GET() { - const docs = await listDocuments(); +// Supports ?section=&status= filters. +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); } diff --git a/apps/web/app/api/queue/status/route.ts b/apps/web/app/api/queue/status/route.ts new file mode 100644 index 0000000..44e3591 --- /dev/null +++ b/apps/web/app/api/queue/status/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/app/api/related/route.ts b/apps/web/app/api/related/route.ts new file mode 100644 index 0000000..86e4bfa --- /dev/null +++ b/apps/web/app/api/related/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/revisions/route.ts b/apps/web/app/api/revisions/route.ts new file mode 100644 index 0000000..75893ef --- /dev/null +++ b/apps/web/app/api/revisions/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts new file mode 100644 index 0000000..bfcdff7 --- /dev/null +++ b/apps/web/app/api/tags/route.ts @@ -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(); + 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 }); + } +} diff --git a/apps/web/app/components/CommandMenu.tsx b/apps/web/app/components/CommandMenu.tsx index c19caa1..77622e2 100644 --- a/apps/web/app/components/CommandMenu.tsx +++ b/apps/web/app/components/CommandMenu.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useSearch } from "@/hooks/useSearch"; import { Search, Plus, @@ -10,74 +11,35 @@ import { BookOpen, Flag, Sparkles, - X, } from "lucide-react"; -interface Hit { - slug: string; - title: string; - section: string; - snippet?: string; - score: number; -} - interface QuickAction { id: string; title: string; subtitle: string; - icon: any; + icon: React.ComponentType<{ className?: string }>; href?: string; - action?: () => void; } export default function CommandMenu() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const router = useRouter(); const inputRef = useRef(null); + 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", - }, + { 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" }, ]; - // Global shortcut ⌘K or / useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { @@ -98,31 +60,12 @@ export default function CommandMenu() { if (open) { setTimeout(() => inputRef.current?.focus(), 50); setQuery(""); - setResults([]); setSelectedIndex(0); } }, [open]); - // Debounced search useEffect(() => { - if (!query.trim()) { - setResults([]); - return; - } - const timer = setTimeout(async () => { - setLoading(true); - try { - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`); - const data = await res.json(); - setResults(data.results || []); - setSelectedIndex(0); - } catch { - setResults([]); - } finally { - setLoading(false); - } - }, 120); - return () => clearTimeout(timer); + setSelectedIndex(0); }, [query]); function handleNavigate(href: string) { @@ -158,24 +101,15 @@ export default function CommandMenu() { > Search... - - ⌘K - + ⌘K ); } return (
- {/* Backdrop */} -
setOpen(false)} - /> - - {/* Modal */} +
setOpen(false)} />
- {/* Search input bar */}
- {loading && ( -
- )} - + {loading &&
} +
- {/* Results / Quick Actions List */}
{query.trim() === "" ? (
-
- Quick Shortcuts -
+
Quick Shortcuts
{quickActions.map((action, idx) => { const Icon = action.icon; const isSelected = idx === selectedIndex; @@ -214,23 +137,15 @@ export default function CommandMenu() { 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)]" - }`} + 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)]"}`} >
-
- {action.title} -
-
- {action.subtitle} -
+
{action.title}
+
{action.subtitle}
@@ -251,31 +166,22 @@ export default function CommandMenu() { key={hit.slug} onClick={() => handleNavigate(`/${hit.slug}`)} onMouseEnter={() => setSelectedIndex(index)} - 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" - }`} + 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"}`} >
- - {hit.section} - - - {hit.title} - + {hit.section} + {hit.title}
{hit.snippet && (

)}

- - /{hit.slug} - + /{hit.slug} ); })} @@ -283,20 +189,9 @@ export default function CommandMenu() { )}
- {/* Footer */}
-
- [ ↑↓ NAVIGATE ] - [ ↵ SELECT ] -
- setOpen(false)} - className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1" - > - Full Search Console - - +
[ ↑↓ NAVIGATE ][ ↵ SELECT ]
+ setOpen(false)} className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1">Full Search Console
diff --git a/apps/web/app/components/DocForm.tsx b/apps/web/app/components/DocForm.tsx index dabb7d2..26a6dc7 100644 --- a/apps/web/app/components/DocForm.tsx +++ b/apps/web/app/components/DocForm.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; +import { mutate } from "swr"; import Markdown from "./Markdown"; import { Bold, @@ -210,7 +211,12 @@ export default function DocForm({ } 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.refresh(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/apps/web/app/components/DocumentCard.tsx b/apps/web/app/components/DocumentCard.tsx new file mode 100644 index 0000000..7ce4953 --- /dev/null +++ b/apps/web/app/components/DocumentCard.tsx @@ -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 ( + +
+ +
+

+ {doc.title} +

+
+ {showSection && ( + + {sInfo.icon} {doc.section} + + )} + {doc.tags.slice(0, 4).map((t: string) => ( + + #{t} + + ))} + {typeof score === "number" && ( + + {score.toFixed(3)} + + )} +
+
+
+ + + ); +} + +export function CompactDocCard({ doc, snippet, score }: { doc: DocumentMeta; snippet?: string; score?: number }) { + const sInfo = getSectionMeta(doc.section); + return ( + +
+
+ + {sInfo.icon} {doc.section} + + /{doc.slug} +
+ {typeof score === "number" && ( + + {score.toFixed(3)} + + )} +
+

{doc.title}

+ {snippet && ( +

+ )} + + ); +} diff --git a/apps/web/app/components/EmptyState.tsx b/apps/web/app/components/EmptyState.tsx new file mode 100644 index 0000000..cbd1a7f --- /dev/null +++ b/apps/web/app/components/EmptyState.tsx @@ -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 ( +

+
+ +
+

{title}

+ {description &&

{description}

} + {action && ( + + {action.label} + + )} +
+ ); +} + +export function ErrorState({ message, onRetry }: { message?: string; onRetry?: () => void }) { + return ( +
+ +
+
Something went wrong
+ {message &&
{message}
} +
+ {onRetry && ( + + )} +
+ ); +} diff --git a/apps/web/app/components/Header.tsx b/apps/web/app/components/Header.tsx index 867d4cc..fe06946 100644 --- a/apps/web/app/components/Header.tsx +++ b/apps/web/app/components/Header.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import Link from "next/link"; 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 CommandMenu from "@/components/CommandMenu"; import { @@ -17,18 +18,8 @@ import { export default function Header() { const pathname = usePathname(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [sections, setSections] = useState(DEFAULT_SECTIONS); - - useEffect(() => { - fetch("/api/sections") - .then((r) => r.json()) - .then((data) => { - if (Array.isArray(data) && data.length > 0) { - setSections(data); - } - }) - .catch(() => {}); - }, []); + const { data: liveSections } = useSections(); + const sections = liveSections && liveSections.length > 0 ? liveSections : DEFAULT_SECTIONS; return (
diff --git a/apps/web/app/components/QueueBadge.tsx b/apps/web/app/components/QueueBadge.tsx new file mode 100644 index 0000000..91e4899 --- /dev/null +++ b/apps/web/app/components/QueueBadge.tsx @@ -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 ( + + queue… + + ); + } + + if (error || !data) return null; + + const total = data.waiting + data.active + data.delayed; + const hasWork = total > 0 || data.failed > 0; + + if (!hasWork) { + return ( + + index idle + + ); + } + + return ( + 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 ? : } + + q:{data.waiting}w · {data.active}a + {data.delayed ? ` · ${data.delayed}d` : ""} + {data.failed ? ` · ${data.failed} ✕` : ""} + + + ); +} + +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 ( +
+ {items.map((it) => ( + + {it.label}: {it.value} + + ))} +
+ ); +} diff --git a/apps/web/app/components/RelatedGrid.tsx b/apps/web/app/components/RelatedGrid.tsx new file mode 100644 index 0000000..8343955 --- /dev/null +++ b/apps/web/app/components/RelatedGrid.tsx @@ -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 ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error || related.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/app/components/RevisionList.tsx b/apps/web/app/components/RevisionList.tsx new file mode 100644 index 0000000..8cbb7cc --- /dev/null +++ b/apps/web/app/components/RevisionList.tsx @@ -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 ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error) { + return

Failed to load revisions: {(error as Error).message}

; + } + + if (revisions.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/app/components/SearchBar.tsx b/apps/web/app/components/SearchBar.tsx new file mode 100644 index 0000000..187d4eb --- /dev/null +++ b/apps/web/app/components/SearchBar.tsx @@ -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 ( +
+ 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" + /> + + {value && ( + + )} +
+ ); +} + +export function InlineSearchBar({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( +
+ 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" + /> + + {value && ( + + )} +
+ ); +} diff --git a/apps/web/app/components/SectionCard.tsx b/apps/web/app/components/SectionCard.tsx new file mode 100644 index 0000000..ad6f0a8 --- /dev/null +++ b/apps/web/app/components/SectionCard.tsx @@ -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 ( +
+
+
+ {icon} + + {docCount} docs → + +
+

{label}

+

{desc}

+
+
+ /{id} · {docCount} document{docCount !== 1 ? "s" : ""} +
+
+ ); +} diff --git a/apps/web/app/components/Sidebar.tsx b/apps/web/app/components/Sidebar.tsx index cae0a85..d42a819 100644 --- a/apps/web/app/components/Sidebar.tsx +++ b/apps/web/app/components/Sidebar.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useState, useMemo } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { getSectionMeta } from "@mcpedia/config/sections"; +import { useDocuments } from "@/hooks/useDocuments"; import { Folder, FolderOpen, @@ -14,6 +15,8 @@ import { Plus, } from "lucide-react"; +import type { DocumentMeta } from "@mcpedia/types"; + interface Doc { slug: string; title: string; @@ -182,21 +185,11 @@ function TreeNodeItem({ } export default function Sidebar() { - const [docs, setDocs] = useState([]); + const { docs } = useDocuments(); const [filterText, setFilterText] = useState(""); const [collapsedMap, setCollapsedMap] = useState>({}); 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) { setCollapsedMap((prev) => ({ ...prev, [slug]: !prev[slug] })); } @@ -208,7 +201,7 @@ export default function Sidebar() { (d) => d.title.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]); diff --git a/apps/web/app/components/TagCloud.tsx b/apps/web/app/components/TagCloud.tsx new file mode 100644 index 0000000..8329aed --- /dev/null +++ b/apps/web/app/components/TagCloud.tsx @@ -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 ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (tags.length === 0) return null; + + const max = Math.max(...tags.map((t) => t.count), 1); + + return ( +
+
+ +

Popular Tags

+ ({tags.length}) +
+
+ {tags.map(({ tag, count }) => { + const intensity = count / max; + const opacity = 0.45 + intensity * 0.55; + return ( + + #{tag} + {count} + + ); + })} +
+
+ ); +} diff --git a/apps/web/app/dashboard/QueueStrip.tsx b/apps/web/app/dashboard/QueueStrip.tsx new file mode 100644 index 0000000..04cff29 --- /dev/null +++ b/apps/web/app/dashboard/QueueStrip.tsx @@ -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 ( +
+ waiting: {data.waiting} + active: {data.active} + failed: {data.failed} + completed: {data.completed} + {data.delayed ? delayed: {data.delayed} : null} +
+ ); +} diff --git a/apps/web/app/dashboard/TagCloudClient.tsx b/apps/web/app/dashboard/TagCloudClient.tsx new file mode 100644 index 0000000..1d067e8 --- /dev/null +++ b/apps/web/app/dashboard/TagCloudClient.tsx @@ -0,0 +1,7 @@ +"use client"; + +import TagCloud from "@/components/TagCloud"; + +export default function TagCloudClient() { + return ; +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx new file mode 100644 index 0000000..8770050 --- /dev/null +++ b/apps/web/app/dashboard/page.tsx @@ -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(); + 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 ( +
+
+
+

Dashboard

+

Live overview of the MCPedia knowledge base. Data via PostgreSQL Core + BullMQ queue.

+
+ + + New Doc + +
+ + {/* Metric strip — SSR snapshot */} +
+
+
DB Documents
+
{all.length}
+
{totalTags} unique tags
+
+
+
Active Sections
+
{sections.length}
+
{sections.map((s) => s.id).join(" · ")}
+
+
+
Search
+
RRF
+
FTS + Cosine Vectors
+
+
+
MCP Tools
+
13
+
HTTP :4021
+
+
+ + {/* Queue strip — client component polls live */} + + + {/* Section breakdown */} +
+

Section Breakdown

+
+ {sections.map((s) => { + const pct = all.length ? Math.round((s.docCount / all.length) * 100) : 0; + return ( + + {s.icon} + {s.label} +
+
+
+ {s.docCount} docs · {pct}% + + ); + })} +
+
+ + {/* Top tags */} + {topTags.length > 0 && ( +
+

Top Tags

+
+ {topTags.map(([tag, count]) => ( + + #{tag} {count} + + ))} +
+
+ +
+
+ )} + + {/* Recent */} + {recentDocs.length > 0 && ( +
+

Recently Updated

+
+ {recentDocs.map((d) => ( + +
{d.section} · {new Date(d.updatedAt).toLocaleDateString()}
+
{d.title}
+ {d.tags.length > 0 &&
{d.tags.slice(0, 3).map((t) => #{t})}
} + + ))} +
+
+ )} +
+ ); +} + +// Client islands — isolated so the page stays RSC +import DashboardQueueStrip from "./QueueStrip"; +import TagCloudClient from "./TagCloudClient"; diff --git a/apps/web/app/hooks/useDocuments.ts b/apps/web/app/hooks/useDocuments.ts new file mode 100644 index 0000000..928bd11 --- /dev/null +++ b/apps/web/app/hooks/useDocuments.ts @@ -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(key, fetcher, { + revalidateOnFocus: false, + }); + + return { + ...swr, + docs: normalize(swr.data), + }; +} diff --git a/apps/web/app/hooks/useQueueStatus.ts b/apps/web/app/hooks/useQueueStatus.ts new file mode 100644 index 0000000..1485c9c --- /dev/null +++ b/apps/web/app/hooks/useQueueStatus.ts @@ -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(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, + }); +} diff --git a/apps/web/app/hooks/useRelated.ts b/apps/web/app/hooks/useRelated.ts new file mode 100644 index 0000000..5a99e0d --- /dev/null +++ b/apps/web/app/hooks/useRelated.ts @@ -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 ?? [], + }; +} diff --git a/apps/web/app/hooks/useRevisions.ts b/apps/web/app/hooks/useRevisions.ts new file mode 100644 index 0000000..70d7759 --- /dev/null +++ b/apps/web/app/hooks/useRevisions.ts @@ -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 ?? [], + }; +} diff --git a/apps/web/app/hooks/useSearch.ts b/apps/web/app/hooks/useSearch.ts new file mode 100644 index 0000000..f24efd4 --- /dev/null +++ b/apps/web/app/hooks/useSearch.ts @@ -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(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, + }; +} diff --git a/apps/web/app/hooks/useSections.ts b/apps/web/app/hooks/useSections.ts new file mode 100644 index 0000000..e18d7c6 --- /dev/null +++ b/apps/web/app/hooks/useSections.ts @@ -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("/api/sections", fetcher, { + revalidateOnFocus: false, + }); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index fcdb547..8234b50 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import Header from "@/components/Header"; import Sidebar from "@/components/Sidebar"; +import SWRProvider from "@/lib/swr"; import Link from "next/link"; import { Database } from "lucide-react"; @@ -42,6 +43,7 @@ export default function RootLayout({
+
@@ -70,6 +72,10 @@ export default function RootLayout({
+ + Dashboard + + · RRF Hybrid Search @@ -82,6 +88,7 @@ export default function RootLayout({
+ ); diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts new file mode 100644 index 0000000..0949740 --- /dev/null +++ b/apps/web/app/lib/api.ts @@ -0,0 +1,77 @@ +import type { Document, DocumentMeta, SectionInfo } from "@mcpedia/types"; +import { fetcher } from "./fetcher"; + +export interface SearchApiHit { + slug: string; + title: string; + section: string; + score: number; + snippet: string; +} + +export interface SearchApiResponse { + results: SearchApiHit[]; + error?: string; +} + +export interface DocsApiResponse { + docs?: DocumentMeta[]; + error?: string; +} + +export interface SectionsApiResponse extends Array {} + +export interface SingleDocResponse { + doc: Document; +} + +export interface RelatedResponse { + results: DocumentMeta[]; +} + +export interface RevisionsResponse { + revisions: { id: string; slug: string; revisionNo: number; title: string; reason: string; createdAt: string; bodyLength: number }[]; +} + +export interface QueueStatusResponse { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; +} + +export interface TagsResponse { + tags: { tag: string; count: number }[]; +} + +export function docsUrl(section?: string, status = "published"): string { + const p = new URLSearchParams(); + if (section) p.set("section", section); + if (status) p.set("status", status); + const qs = p.toString(); + return qs ? `/api/docs?${qs}` : "/api/docs"; +} + +export function searchUrl(q: string, mode = "hybrid", limit = 30): string { + return `/api/search?q=${encodeURIComponent(q)}&mode=${mode}&limit=${limit}`; +} + +export type FetchDocs = () => Promise; +export const fetchDocs: FetchDocs = async () => { + const data = await fetcher("/api/docs"); + if (Array.isArray(data)) return data; + return data.docs ?? []; +}; + +export const fetchSections = () => fetcher("/api/sections"); +export const fetchSearch = (q: string, mode = "hybrid", limit = 30) => + fetcher(searchUrl(q, mode, limit)); +export const fetchRelated = (slug: string, limit = 5) => + fetcher(`/api/related?slug=${encodeURIComponent(slug)}&limit=${limit}`); +export const fetchRevisions = (slug: string, limit = 20) => + fetcher(`/api/revisions?slug=${encodeURIComponent(slug)}&limit=${limit}`); +export const fetchSingleDoc = (slug: string) => + fetcher(`/api/docs/${encodeURIComponent(slug)}`); +export const fetchQueueStatus = () => fetcher("/api/queue/status"); +export const fetchTags = () => fetcher("/api/tags"); diff --git a/apps/web/app/lib/fetcher.ts b/apps/web/app/lib/fetcher.ts new file mode 100644 index 0000000..355cc8c --- /dev/null +++ b/apps/web/app/lib/fetcher.ts @@ -0,0 +1,32 @@ +export class FetchError extends Error { + status: number; + info: unknown; + constructor(message: string, status: number, info: unknown) { + super(message); + this.name = "FetchError"; + this.status = status; + this.info = info; + } +} + +export async function fetcher(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + let info: unknown = null; + try { + info = await res.json(); + } catch { + try { + info = await res.text(); + } catch { + info = null; + } + } + const msg = + (info as { error?: string })?.error || + (typeof info === "string" ? info : null) || + `Request failed: ${res.status} ${res.statusText}`; + throw new FetchError(msg, res.status, info); + } + return (await res.json()) as T; +} diff --git a/apps/web/app/lib/swr.tsx b/apps/web/app/lib/swr.tsx new file mode 100644 index 0000000..fa348e5 --- /dev/null +++ b/apps/web/app/lib/swr.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { SWRConfig } from "swr"; + +export default function SWRProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/search/page.tsx b/apps/web/app/search/page.tsx index e9eb2f6..b16422a 100644 --- a/apps/web/app/search/page.tsx +++ b/apps/web/app/search/page.tsx @@ -1,26 +1,17 @@ "use client"; -import { useState, useEffect, useCallback, Suspense } from "react"; +import { useState, Suspense, useEffect } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections"; -import { - Search, - X, -} from "lucide-react"; - -interface DocHit { - slug: string; - title: string; - section: string; - score: number; - snippet: string; -} - -type SearchMode = "hybrid" | "keyword" | "semantic"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useSections } from "@/hooks/useSections"; +import { useSearch, type SearchMode } from "@/hooks/useSearch"; +import SearchBar from "@/components/SearchBar"; +import { EmptyState, ErrorState } from "@/components/EmptyState"; +import { Search, SearchX } from "lucide-react"; function SearchContent() { const searchParams = useSearchParams(); + const router = useRouter(); const initialQ = searchParams.get("q") ?? ""; const initialMode = (searchParams.get("mode") as SearchMode) ?? "hybrid"; @@ -28,107 +19,57 @@ function SearchContent() { const [mode, setMode] = useState( ["hybrid", "keyword", "semantic"].includes(initialMode) ? initialMode : "hybrid", ); - const [sections, setSections] = useState(DEFAULT_SECTIONS); const [selectedSection, setSelectedSection] = useState("all"); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); + const { data: liveSections } = useSections(); + const sections = liveSections ?? []; + + // SWR search — debounced inside hook (180ms) + const { hits, error, isValidating, isLoading } = useSearch(query, mode, 30); + const loading = isLoading || isValidating; + + // Keep URL in sync (debounced via hook timing is fine — push without blocking render) useEffect(() => { - fetch("/api/sections") - .then((r) => r.json()) - .then((data) => { - if (Array.isArray(data) && data.length > 0) { - setSections(data); - } - }) - .catch(() => {}); - }, []); - - const handleSearch = useCallback(async (q: string, searchMode: SearchMode) => { - if (!q.trim()) { - setResults([]); - return; + const q = query.trim(); + const target = q ? `/search?q=${encodeURIComponent(q)}&mode=${mode}` : `/search?mode=${mode}`; + const current = `${searchParams.toString() ? `/search?${searchParams.toString()}` : "/search"}`; + if (target !== current && q) { + router.replace(target); } - setLoading(true); - try { - const res = await fetch( - `/api/search?q=${encodeURIComponent(q)}&mode=${searchMode}&limit=30`, - ); - const data = await res.json(); - setResults(data.results || []); - } catch { - setResults([]); - } finally { - setLoading(false); - } - }, []); + }, [query, mode]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - if (initialQ) { - handleSearch(initialQ, mode); - } - }, [initialQ, handleSearch, mode]); - - function handleChange(e: React.ChangeEvent) { - const val = e.target.value; - setQuery(val); - handleSearch(val, mode); - } + const filteredHits = selectedSection === "all" ? hits : hits.filter((r) => r.section === selectedSection); function handleModeChange(newMode: SearchMode) { setMode(newMode); - if (query.trim()) { - handleSearch(query, newMode); - } + const q = query.trim(); + const qs = q ? `?q=${encodeURIComponent(q)}&mode=${newMode}` : `?mode=${newMode}`; + router.replace(`/search${qs}`); } - const filteredResults = - selectedSection === "all" - ? results - : results.filter((r) => r.section === selectedSection); + const suggestions = [ + "stack alignment", + "ret2win exploit", + "bullmq worker", + "streamable http", + "full-text search", + "caddy reverse proxy", + "pgvector cosine", + ]; return (
- {/* Header */}
-

- Multi-Modal Search Console -

+

Multi-Modal Search Console

Query across knowledge documents and CTF writeups using PostgreSQL Full-Text Search (tsvector), Cosine Vector Embeddings, or Reciprocal Rank Fusion (RRF).

- {/* Search Box & Controls */}
- {/* Search Input Bar */} -
- - - {query && ( - - )} -
+ setQuery("")} /> - {/* Algorithm & Filter Controls */}
- {/* Mode Selector */}
Algorithm: {[ @@ -141,27 +82,18 @@ function SearchContent() { type="button" onClick={() => handleModeChange(m.id as SearchMode)} title={m.desc} - className={`px-3 py-1 rounded-md border text-xs font-medium transition-all cursor-pointer ${ - mode === m.id - ? "bg-[var(--brand)] border-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs" - : "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)]" - }`} + className={`px-3 py-1 rounded-md border text-xs font-medium transition-all cursor-pointer ${mode === m.id ? "bg-[var(--brand)] border-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs" : "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)]"}`} > {m.label} ))}
- {/* Section Filter Pills */}
@@ -170,11 +102,7 @@ function SearchContent() { key={s.id} type="button" onClick={() => setSelectedSection(s.id)} - className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${ - selectedSection === s.id - ? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold" - : "text-[var(--text-dim)] hover:text-[var(--text-muted)]" - }`} + className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${selectedSection === s.id ? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold" : "text-[var(--text-dim)] hover:text-[var(--text-muted)]"}`} > {s.label} @@ -183,44 +111,31 @@ function SearchContent() {
- {/* Loading Spinner */} + {error && setQuery((q) => q + " ")} />} + {loading && ( -
+
SEARCHING_VECTOR_SPACE...
)} - {/* Empty State */} - {!loading && query && filteredResults.length === 0 && ( -
-

No matching documents found

-

- Try switching algorithm mode to Hybrid or Semantic, or search with different keywords. -

-
+ {!loading && query.trim() && filteredHits.length === 0 && !error && ( + )} - {/* Initial Suggestions */} - {!loading && !query && ( + {!loading && !query.trim() && (

Popular Queries:

- {[ - "stack alignment", - "ret2win exploit", - "bullmq worker", - "streamable http", - "full-text search", - "caddy reverse proxy", - "pgvector cosine", - ].map((term) => ( + {suggestions.map((term) => (
)} - {/* Results List */} - {!loading && filteredResults.length > 0 && ( + {!loading && filteredHits.length > 0 && (
-
- Found {filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""} +
+ Found {filteredHits.length} result{filteredHits.length !== 1 ? "s" : ""} Ranked by {mode} score
-
- {filteredResults.map((r) => ( + {filteredHits.map((r) => (
- - {r.section} - - - /{r.slug} - + {r.section} + /{r.slug}
- - {r.score.toFixed(3)} - + {r.score.toFixed(3)}
- -

- {r.title} -

- +

{r.title}

{r.snippet && (

)} @@ -280,14 +184,7 @@ function SearchContent() { export default function SearchPage() { return ( - -

Search

-

Loading search module...

-
- } - > +

Search

Loading search module...

}> ); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a695497..3dd02b5 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -9,6 +9,7 @@ const nextConfig: NextConfig = { "@mcpedia/config", "@mcpedia/parser", "@mcpedia/search", + "@mcpedia/queue", ], }; diff --git a/apps/web/package.json b/apps/web/package.json index e9d2363..19a01ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,8 @@ "dependencies": { "@mcpedia/config": "workspace:*", "@mcpedia/core": "workspace:*", + "@mcpedia/queue": "workspace:*", + "@mcpedia/types": "workspace:*", "@tailwindcss/typography": "^0.5.20", "github-slugger": "^2.0.0", "lucide-react": "^1.33.0", @@ -21,7 +23,8 @@ "react-markdown": "^9.0.0", "rehype-highlight": "^7.0.2", "rehype-slug": "^6.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "swr": "^2.5.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/bun.lock b/bun.lock index b236e19..ae827a2 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,8 @@ "dependencies": { "@mcpedia/config": "workspace:*", "@mcpedia/core": "workspace:*", + "@mcpedia/queue": "workspace:*", + "@mcpedia/types": "workspace:*", "@tailwindcss/typography": "^0.5.20", "github-slugger": "^2.0.0", "lucide-react": "^1.33.0", @@ -80,6 +82,7 @@ "rehype-highlight": "^7.0.2", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", + "swr": "^2.5.1", }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1492,6 +1495,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "swr": ["swr@2.5.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -1558,6 +1563,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],