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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user