feat(web): FE overhaul — SWR centralized data layer + dashboard + islands
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s

- Add SWR foundation (lib/fetcher, lib/swr, lib/api) with typed
  hooks: useSections/useDocuments/useSearch/useRelated/useRevisions/useQueueStatus
- Replace ad-hoc useEffect+fetch in Header/Sidebar/CommandMenu/search
  with SWR (dedup 4s, focus throttle 10s, debounced search 180ms)
- Doc page: RelatedGrid + RevisionList as SWR client islands (RSC hero stays)
- DocForm: SWR mutate on create/update for live sidebar invalidation
- New APIs: /api/related, /api/revisions, /api/tags, /api/queue/status,
  /api/docs filtering (?section=&status=)
- New UI: DocumentCard, SectionCard, SearchBar, TagCloud, QueueBadge,
  EmptyState, dashboard (/dashboard with QueueStrip live + section stats)
- Layout wraps SWRProvider; no TanStack code (SWR-only per spec)
- Verified: next build passes (16.3.4 Turbopack), no manual fetch sprawl remains

Co-Authored-By: internal-model
This commit is contained in:
mytheclipsebotreviewandinternal-model committed 2026-09-08 19:37:27 +07:00
1 parent 1025454d87
commit 4330f47c65
36 files changed
+1393 -415

No files matched your search

+62 -165
View File
@@ -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>
);