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
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
This commit is contained in:
co-authored by
internal-model
parent
1025454d87
commit
4330f47c65
@@ -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) {
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Related Documents Grid */}
|
||||
{related.length > 0 && (
|
||||
<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 = 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(--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>
|
||||
)}
|
||||
|
||||
{/* 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-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">
|
||||
<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>
|
||||
)}
|
||||
<RelatedGrid slug={fullSlug} />
|
||||
<RevisionList slug={fullSlug} canEdit={canEdit} />
|
||||
</article>
|
||||
|
||||
{/* Desktop Sticky Table of Contents Column */}
|
||||
|
||||
@@ -78,8 +78,15 @@ function splitPayload(body: Record<string, unknown>): {
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<Hit[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(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 className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
|
||||
<span className="font-sans">Search...</span>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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)}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs transition-opacity" onClick={() => setOpen(false)} />
|
||||
<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)]">
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<input
|
||||
@@ -187,25 +121,14 @@ export default function CommandMenu() {
|
||||
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"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-[var(--text-muted)] 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>
|
||||
{loading && <div className="w-4 h-4 border-2 border-[var(--text-muted)] 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>
|
||||
</div>
|
||||
|
||||
{/* Results / Quick Actions List */}
|
||||
<div className="max-h-[22rem] overflow-y-auto p-2">
|
||||
{query.trim() === "" ? (
|
||||
<div className="p-1 space-y-0.5">
|
||||
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Quick Shortcuts
|
||||
</div>
|
||||
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Quick Shortcuts</div>
|
||||
{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)]"}`}
|
||||
>
|
||||
<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 className="text-xs font-semibold text-[var(--text-primary)]">
|
||||
{action.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]">
|
||||
{action.subtitle}
|
||||
</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)]" : ""}`} />
|
||||
@@ -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"}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<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>
|
||||
<span className={`text-xs sm:text-sm font-semibold ${isSelected ? "text-[var(--text-primary)]" : "text-[var(--text-primary)]"} truncate`}>
|
||||
{hit.title}
|
||||
</span>
|
||||
<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>
|
||||
<span className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] truncate">{hit.title}</span>
|
||||
</div>
|
||||
{hit.snippet && (
|
||||
<p
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">
|
||||
/{hit.slug}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">/{hit.slug}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -283,20 +189,9 @@ export default function CommandMenu() {
|
||||
)}
|
||||
</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 gap-2">
|
||||
<span>[ ↑↓ NAVIGATE ]</span>
|
||||
<span>[ ↵ SELECT ]</span>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center gap-2"><span>[ ↑↓ NAVIGATE ]</span><span>[ ↵ SELECT ]</span></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<SectionConfig[]>(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 (
|
||||
<header className="site-header no-print sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<Doc[]>([]);
|
||||
const { docs } = useDocuments();
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({});
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
<div id="a11y-live-region" aria-live="polite" aria-atomic="true" />
|
||||
</div>
|
||||
|
||||
<SWRProvider>
|
||||
<Header />
|
||||
|
||||
<div className="mx-auto max-w-7xl w-full flex-1 flex flex-col">
|
||||
@@ -70,6 +72,10 @@ export default function RootLayout({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-[11px] text-[var(--text-muted)]">
|
||||
<Link href="/dashboard" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<Link href="/search" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
RRF Hybrid Search
|
||||
</Link>
|
||||
@@ -82,6 +88,7 @@ export default function RootLayout({
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</SWRProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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<SectionInfo> {}
|
||||
|
||||
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<DocumentMeta[]>;
|
||||
export const fetchDocs: FetchDocs = async () => {
|
||||
const data = await fetcher<DocsApiResponse | DocumentMeta[]>("/api/docs");
|
||||
if (Array.isArray(data)) return data;
|
||||
return data.docs ?? [];
|
||||
};
|
||||
|
||||
export const fetchSections = () => fetcher<SectionInfo[]>("/api/sections");
|
||||
export const fetchSearch = (q: string, mode = "hybrid", limit = 30) =>
|
||||
fetcher<SearchApiResponse>(searchUrl(q, mode, limit));
|
||||
export const fetchRelated = (slug: string, limit = 5) =>
|
||||
fetcher<RelatedResponse>(`/api/related?slug=${encodeURIComponent(slug)}&limit=${limit}`);
|
||||
export const fetchRevisions = (slug: string, limit = 20) =>
|
||||
fetcher<RevisionsResponse>(`/api/revisions?slug=${encodeURIComponent(slug)}&limit=${limit}`);
|
||||
export const fetchSingleDoc = (slug: string) =>
|
||||
fetcher<SingleDocResponse>(`/api/docs/${encodeURIComponent(slug)}`);
|
||||
export const fetchQueueStatus = () => fetcher<QueueStatusResponse>("/api/queue/status");
|
||||
export const fetchTags = () => fetcher<TagsResponse>("/api/tags");
|
||||
@@ -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<T>(url: string): Promise<T> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { SWRConfig } from "swr";
|
||||
|
||||
export default function SWRProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
dedupingInterval: 4_000,
|
||||
focusThrottleInterval: 10_000,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: false,
|
||||
errorRetryCount: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
+62
-165
@@ -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<SearchMode>(
|
||||
["hybrid", "keyword", "semantic"].includes(initialMode) ? initialMode : "hybrid",
|
||||
);
|
||||
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
|
||||
const [selectedSection, setSelectedSection] = useState<string>("all");
|
||||
const [results, setResults] = useState<DocHit[]>([]);
|
||||
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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Multi-Modal Search Console
|
||||
</h1>
|
||||
<h1 className="text-3xl font-extrabold text-[var(--text-primary)] tracking-tight mb-2">Multi-Modal Search Console</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
Query across knowledge documents and CTF writeups using PostgreSQL Full-Text Search (tsvector), Cosine Vector Embeddings, or Reciprocal Rank Fusion (RRF).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Box & Controls */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs space-y-4">
|
||||
{/* Search Input Bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search by keywords, challenge names, tools, concepts..."
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)] absolute left-3.5 top-3.5 pointer-events-none" />
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}}
|
||||
className="absolute right-3 top-3 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||
aria-label="Clear query"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SearchBar value={query} onChange={setQuery} placeholder="Search by keywords, challenge names, tools, concepts..." autoFocus onClear={() => setQuery("")} />
|
||||
|
||||
{/* Algorithm & Filter Controls */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2.5 border-t border-[var(--border-color)] text-xs">
|
||||
{/* Mode Selector */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[var(--text-dim)] font-mono uppercase text-[10px] mr-1">Algorithm:</span>
|
||||
{[
|
||||
@@ -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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section Filter Pills */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedSection("all")}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
selectedSection === "all"
|
||||
? "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 === "all" ? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold" : "text-[var(--text-dim)] hover:text-[var(--text-muted)]"}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@@ -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}
|
||||
</button>
|
||||
@@ -183,44 +111,31 @@ function SearchContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Spinner */}
|
||||
{error && <ErrorState message={(error as Error).message} onRetry={() => setQuery((q) => q + " ")} />}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-3 py-10 text-[var(--text-muted)] text-xs font-mono">
|
||||
<div className="flex items-center justify-center gap-3 py-10 text-[var(--text-muted)] text-xs font-mono" role="status" aria-live="polite">
|
||||
<div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin" />
|
||||
<span>SEARCHING_VECTOR_SPACE...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && query && filteredResults.length === 0 && (
|
||||
<div className="text-center py-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs space-y-2 animate-fade-in">
|
||||
<h3 className="text-sm font-bold text-[var(--text-primary)]">No matching documents found</h3>
|
||||
<p className="text-xs text-[var(--text-muted)] max-w-sm mx-auto">
|
||||
Try switching algorithm mode to <strong>Hybrid</strong> or <strong>Semantic</strong>, or search with different keywords.
|
||||
</p>
|
||||
</div>
|
||||
{!loading && query.trim() && filteredHits.length === 0 && !error && (
|
||||
<EmptyState
|
||||
title="No matching documents found"
|
||||
description="Try switching algorithm mode to Hybrid or Semantic, or search with different keywords."
|
||||
icon={SearchX}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Initial Suggestions */}
|
||||
{!loading && !query && (
|
||||
{!loading && !query.trim() && (
|
||||
<div className="p-6 text-center bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-xs">
|
||||
<p className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Popular Queries:</p>
|
||||
<div className="flex justify-center flex-wrap gap-2 text-xs">
|
||||
{[
|
||||
"stack alignment",
|
||||
"ret2win exploit",
|
||||
"bullmq worker",
|
||||
"streamable http",
|
||||
"full-text search",
|
||||
"caddy reverse proxy",
|
||||
"pgvector cosine",
|
||||
].map((term) => (
|
||||
{suggestions.map((term) => (
|
||||
<button
|
||||
key={term}
|
||||
onClick={() => {
|
||||
setQuery(term);
|
||||
handleSearch(term, mode);
|
||||
}}
|
||||
onClick={() => setQuery(term)}
|
||||
className="px-3 py-1 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all font-mono text-xs cursor-pointer"
|
||||
>
|
||||
{term}
|
||||
@@ -230,16 +145,14 @@ function SearchContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results List */}
|
||||
{!loading && filteredResults.length > 0 && (
|
||||
{!loading && filteredHits.length > 0 && (
|
||||
<div className="space-y-3 animate-fade-in">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-dim)] font-mono">
|
||||
<span>Found {filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""}</span>
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-dim)] font-mono" aria-live="polite">
|
||||
<span>Found {filteredHits.length} result{filteredHits.length !== 1 ? "s" : ""}</span>
|
||||
<span>Ranked by {mode} score</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{filteredResults.map((r) => (
|
||||
{filteredHits.map((r) => (
|
||||
<Link
|
||||
key={r.slug}
|
||||
href={`/${r.slug}`}
|
||||
@@ -247,25 +160,16 @@ function SearchContent() {
|
||||
>
|
||||
<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">
|
||||
{r.section}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)] font-mono truncate">
|
||||
/{r.slug}
|
||||
</span>
|
||||
<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">{r.section}</span>
|
||||
<span className="text-xs text-[var(--text-dim)] font-mono truncate">/{r.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">
|
||||
{r.score.toFixed(3)}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">{r.score.toFixed(3)}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline mb-1.5">
|
||||
{r.title}
|
||||
</h2>
|
||||
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline mb-1.5">{r.title}</h2>
|
||||
{r.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"
|
||||
// snippet from Postgres ts_headline — sanitized by DB
|
||||
dangerouslySetInnerHTML={{ __html: r.snippet }}
|
||||
/>
|
||||
)}
|
||||
@@ -280,14 +184,7 @@ function SearchContent() {
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="max-w-4xl py-12">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] mb-4">Search</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">Loading search module...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<div className="max-w-4xl py-12"><h1 className="text-2xl font-bold text-[var(--text-primary)] mb-4">Search</h1><p className="text-sm text-[var(--text-muted)]">Loading search module...</p></div>}>
|
||||
<SearchContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ const nextConfig: NextConfig = {
|
||||
"@mcpedia/config",
|
||||
"@mcpedia/parser",
|
||||
"@mcpedia/search",
|
||||
"@mcpedia/queue",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user