diff --git a/src/app/globals.css b/src/app/globals.css index 81a809be..a97dd466 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -330,6 +330,47 @@ textarea:focus-visible { border-radius: 4px; } +/* Search dialog open/close animations. tailwindcss-animate isn't + installed; these handcrafted keyframes give us the fade + slight + slide-down on open and the inverse on close. Linear-style: snappy, + no scale bounce. */ +@keyframes ocb-search-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes ocb-search-overlay-out { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes ocb-search-card-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes ocb-search-card-out { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-4px); } +} +.ocb-search-overlay-in { + animation: ocb-search-overlay-in 150ms ease-out; +} +.ocb-search-overlay-out { + animation: ocb-search-overlay-out 100ms ease-in forwards; +} +.ocb-search-card-in { + animation: ocb-search-card-in 150ms cubic-bezier(0.16, 0.84, 0.32, 1); +} +.ocb-search-card-out { + animation: ocb-search-card-out 100ms ease-in forwards; +} +@media (prefers-reduced-motion: reduce) { + .ocb-search-overlay-in, + .ocb-search-overlay-out, + .ocb-search-card-in, + .ocb-search-card-out { + animation: none !important; + } +} + /* cmdk command palette input — opts out of the universal focus ring. The dialog already provides visual focus context (modal backdrop + centered card); a hard accent outline on the input itself reads as diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx index ff4e1492..38614dd7 100644 --- a/src/components/search/search-dialog.tsx +++ b/src/components/search/search-dialog.tsx @@ -2,10 +2,23 @@ import { Command } from "cmdk"; import Fuse from "fuse.js"; -import { ArrowRight, Search } from "lucide-react"; +import { + ArrowRight, + Building2, + FileText, + GitCompareArrows, + HelpCircle, + Layers, + Link2, + Search, + Trophy, + X, +} from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSearch } from "@/components/search/search-provider"; +import { useRecentSearches, type RecentEntry } from "@/components/search/use-recent-searches"; +import { ProviderLogo } from "@/components/provider-logo"; import type { SearchItem, SearchKind } from "@/lib/search/types"; const KIND_ORDER: SearchKind[] = [ @@ -28,18 +41,58 @@ const KIND_LABEL: Record = { Page: "Pages", }; +const KIND_SINGULAR: Record = { + Benchmark: "Bench", + Product: "Product", + Compare: "vs", + Alternative: "Alt", + Answer: "Answer", + Chain: "Chain", + Page: "Page", +}; + +const KIND_ICON: Record = { + Benchmark: Trophy, + Product: Building2, + Compare: GitCompareArrows, + Alternative: Layers, + Answer: HelpCircle, + Chain: Link2, + Page: FileText, +}; + /** - * Hardcoded "popular benches" shown when the query is empty. No - * analytics involved, just an editorial pick of high-traffic specs - * that map to known SEO winners. Update by hand when traffic shifts. + * Hand-picked editorial leaders. Shown as horizontal "Live leaders" cards + * when the query is empty. Live values (#1 provider + p50) come from + * `/api/citable`, which is already edge-cached for 300s. */ -const POPULAR_BENCH_SLUGS = [ +const FEATURED_BENCH_SLUGS = [ "pm-data-freshness", "aggregator-head-lag", "l1-finality", "rpc-capabilities", + "evm-quote-latency", + "solana-tx-landing-latency", +]; + +const TRENDING_BENCH_SLUGS = [ + "perp-fees", + "stablecoin-peg-usdt-anchored", + "bridge-quote-latency", + "metadata-coverage", + "validator-yield", + "network-fees", ]; +type CitableLeader = { + slug: string; + title: string; + category: string; + value: number | null; + unit: string; + leader: { name: string; slug: string; value: number } | null; +}; + function Kbd({ children }: { children: React.ReactNode }) { return ( @@ -48,11 +101,68 @@ function Kbd({ children }: { children: React.ReactNode }) { ); } +function SectionHeader({ label, action }: { label: string; action?: React.ReactNode }) { + return ( +
+ + {label} + + {action} +
+ ); +} + +function KindIcon({ kind, size = 14 }: { kind: SearchKind; size?: number }) { + const Icon = KIND_ICON[kind]; + return ; +} + +function fmtUnit(value: number | null | undefined, unit: string): string { + if (value == null) return "—"; + if (unit === "ms") { + if (value < 1000) return `${Math.round(value)} ms`; + return `${(value / 1000).toFixed(2)} s`; + } + if (unit === "s" || unit === "sec") return `${value.toFixed(2)} s`; + if (unit === "pct") return `${value.toFixed(1)}%`; + if (unit === "bps" || unit === "bp") return `${value.toFixed(1)} bp`; + if (unit === "usd") { + if (value > 1e9) return `$${(value / 1e9).toFixed(1)}B`; + if (value > 1e6) return `$${(value / 1e6).toFixed(1)}M`; + if (value > 1e3) return `$${(value / 1e3).toFixed(1)}k`; + return `$${value.toFixed(0)}`; + } + if (unit === "count") return value.toLocaleString(); + return String(value); +} + export default function SearchDialog() { const { items, close: onClose } = useSearch(); const router = useRouter(); const [query, setQuery] = useState(""); + const [isClosing, setIsClosing] = useState(false); const inputRef = useRef(null); + const { recent, push: pushRecent, remove: removeRecent, clear: clearRecent } = + useRecentSearches(); + + // Featured / trending live data. One fetch on mount, cached by browser + // since /api/citable ships s-maxage=300. + const [citable, setCitable] = useState | null>(null); + useEffect(() => { + let cancelled = false; + fetch("/api/citable") + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (cancelled || !j?.benchmarks) return; + const map = new Map(); + for (const b of j.benchmarks as CitableLeader[]) map.set(b.slug, b); + setCitable(map); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); // One Fuse instance per dialog mount. The full corpus is ~400 docs // so build cost is sub-millisecond, no need to memoise across mounts. @@ -71,38 +181,72 @@ export default function SearchDialog() { [items], ); - // Body scroll lock + ESC handler, same pattern as report-section-modal. + // Body scroll lock + ESC handler with deferred close so the exit + // animation has time to play. + const close = useMemo( + () => () => { + setIsClosing(true); + window.setTimeout(onClose, 120); + }, + [onClose], + ); + useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") close(); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; - }, [onClose]); + }, [close]); useEffect(() => { - // cmdk autofocuses its own input, but giving the ref-driven focus - // a tick of priority avoids a flash where typing the first letter - // gets eaten by the trigger button's focus. const t = window.setTimeout(() => inputRef.current?.focus(), 0); return () => window.clearTimeout(t); }, []); + const benchItemBySlug = useMemo(() => { + const map = new Map(); + for (const it of items) { + if (it.kind === "Benchmark") { + const slug = it.id.replace(/^bench:/, ""); + map.set(slug, it); + } + } + return map; + }, [items]); + + const featured = useMemo(() => { + return FEATURED_BENCH_SLUGS + .map((slug) => { + const item = benchItemBySlug.get(slug); + if (!item) return null; + const live = citable?.get(slug); + return { item, live: live ?? null }; + }) + .filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x)); + }, [benchItemBySlug, citable]); + + const trending = useMemo(() => { + return TRENDING_BENCH_SLUGS + .map((slug) => { + const item = benchItemBySlug.get(slug); + if (!item) return null; + const live = citable?.get(slug); + return { item, live: live ?? null }; + }) + .filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x)); + }, [benchItemBySlug, citable]); + const results = useMemo(() => { const q = query.trim(); - if (!q) { - const popular = POPULAR_BENCH_SLUGS - .map((slug) => items.find((it) => it.id === `bench:${slug}`)) - .filter((it): it is SearchItem => Boolean(it)); - return popular; - } - return fuse.search(q, { limit: 12 }).map((r) => r.item); - }, [query, fuse, items]); + if (!q) return []; + return fuse.search(q, { limit: 16 }).map((r) => r.item); + }, [query, fuse]); const grouped = useMemo(() => { const map = new Map(); @@ -116,46 +260,74 @@ export default function SearchDialog() { .filter((g) => g.list.length > 0); }, [results]); - function go(url: string) { - onClose(); + function go(url: string, entry?: RecentEntry) { + if (entry) pushRecent(entry); + close(); router.push(url); } + function entryFromItem(it: SearchItem): RecentEntry { + const slug = + it.kind === "Benchmark" + ? it.id.replace(/^bench:/, "") + : it.kind === "Product" + ? it.id.replace(/^product:/, "") + : it.kind === "Chain" + ? it.id.replace(/^chain:/, "") + : undefined; + return { id: it.id, title: it.title, url: it.url, kind: it.kind, slug }; + } + const trimmed = query.trim(); - const showEmpty = trimmed.length > 0 && results.length === 0; - const headerLabel = trimmed.length === 0 ? "Popular benchmarks" : null; + const isSearching = trimmed.length > 0; + const showEmpty = isSearching && results.length === 0; return (
e.stopPropagation()} > - {/* Input row — no border on the input itself, focus ring removed, - search icon left, ESC kbd right. */} -
+ {/* Input row */} +
+
- - {headerLabel && ( -
- {headerLabel} + + {/* IDLE STATE — Recent + Featured + Trending */} + {!isSearching && ( +
+ {recent.length > 0 && ( +
+ + Clear + + } + /> +
+
+ {recent.map((r) => { + const Icon = KIND_ICON[r.kind]; + return ( +
+ + +
+ ); + })} +
+
+
+ )} + + {featured.length > 0 && ( +
+ + + live + + ) + } + /> +
+
+ {featured.map(({ item, live }) => ( + + ))} +
+
+
+ )} + + {trending.length > 0 && ( +
+ + + {trending.map(({ item, live }) => ( + go(item.url, entryFromItem(item))} + className="group flex items-center gap-3 rounded-md px-2.5 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft transition-colors" + > + {live?.leader ? ( + + ) : ( +
+ +
+ )} +
+
+ {item.title} +
+
+ {live?.leader ? ( + <> + #1 {live.leader.name} ·{" "} + + {fmtUnit(live.value, live.unit)} + + + ) : ( + live?.category ?? item.description + )} +
+
+ +
+ ))} +
+
+ )}
)} - {showEmpty && ( - - No results for “{trimmed}”. -
- - Try a bench name, provider, or chain. - -
- )} + {/* SEARCH RESULTS STATE */} + {isSearching && ( + <> + {showEmpty && ( + + +

+ No results for “{trimmed}”. +

+

+ Try a bench name, provider, or chain. +

+
+ )} - {grouped.map(({ kind, list }) => ( - 0 ? KIND_LABEL[kind] : undefined} - className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.14em] [&_[cmdk-group-heading]]:text-ink-faint" - > - {list.map((it) => ( - go(it.url)} - className="group flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer text-sm aria-selected:bg-paper-soft transition-colors" + {grouped.map(({ kind, list }) => ( + - - - - {KIND_LABEL[it.kind].replace(/s$/, "")} - - · - - {it.title} - - - {it.description && ( - - {it.description} - - )} - - - + {list.map((it) => { + const entry = entryFromItem(it); + const benchLive = + it.kind === "Benchmark" && entry.slug + ? citable?.get(entry.slug) + : null; + const logoSlug = + it.kind === "Benchmark" + ? benchLive?.leader?.slug + : entry.slug; + const logoName = + it.kind === "Benchmark" + ? benchLive?.leader?.name ?? it.title + : it.title; + return ( + go(it.url, entry)} + className="group flex items-center gap-3 rounded-md px-2.5 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft transition-colors" + > + {logoSlug ? ( + + ) : ( +
+ +
+ )} +
+
+ + {it.title} + + + {KIND_SINGULAR[it.kind]} + +
+ {it.description && ( +
+ {it.description} +
+ )} +
+ +
+ ); + })} +
))} - - ))} + + )}
- {/* Footer — subtle separator, kbd-driven hints. */} -
+ {/* Footer */} +
diff --git a/src/components/search/use-recent-searches.ts b/src/components/search/use-recent-searches.ts new file mode 100644 index 00000000..ac0956c5 --- /dev/null +++ b/src/components/search/use-recent-searches.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useSyncExternalStore, useCallback } from "react"; +import type { SearchKind } from "@/lib/search/types"; + +const KEY = "ocb:search:recent:v1"; +const MAX_RECENT = 7; +const CHANGE_EVENT = "ocb:search:recent:changed"; + +export type RecentEntry = { + id: string; + title: string; + url: string; + kind: SearchKind; + /** Optional slug for logo resolution (provider/chain only). */ + slug?: string; +}; + +function readStorage(): RecentEntry[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.slice(0, MAX_RECENT) as RecentEntry[]; + } catch { + return []; + } +} + +function writeStorage(entries: RecentEntry[]) { + try { + window.localStorage.setItem(KEY, JSON.stringify(entries)); + // Same-tab notify. The native `storage` event only fires on OTHER + // tabs, so without this the dialog wouldn't see its own writes + // unless remounted. + window.dispatchEvent(new Event(CHANGE_EVENT)); + } catch { + // private-mode Safari, sandboxed iframes, etc. — fail silently. + } +} + +function subscribe(cb: () => void) { + if (typeof window === "undefined") return () => {}; + const handler = () => cb(); + window.addEventListener("storage", handler); + window.addEventListener(CHANGE_EVENT, handler); + return () => { + window.removeEventListener("storage", handler); + window.removeEventListener(CHANGE_EVENT, handler); + }; +} + +// useSyncExternalStore expects snapshot stability between renders that +// share the same underlying data, otherwise React falls into a render +// loop. Stable cache keyed by the raw JSON string so a fresh read of +// the same payload returns the same array reference. +let cachedJson = ""; +let cachedSnapshot: RecentEntry[] = []; +function getSnapshot(): RecentEntry[] { + const raw = (typeof window !== "undefined" && window.localStorage.getItem(KEY)) || "[]"; + if (raw === cachedJson) return cachedSnapshot; + cachedJson = raw; + cachedSnapshot = readStorage(); + return cachedSnapshot; +} + +const SERVER_SNAPSHOT: RecentEntry[] = []; +const getServerSnapshot = () => SERVER_SNAPSHOT; + +export function useRecentSearches() { + const recent = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const push = useCallback((entry: RecentEntry) => { + const current = readStorage(); + const next = [entry, ...current.filter((e) => e.id !== entry.id)].slice(0, MAX_RECENT); + writeStorage(next); + }, []); + + const remove = useCallback((id: string) => { + const current = readStorage(); + writeStorage(current.filter((e) => e.id !== id)); + }, []); + + const clear = useCallback(() => { + writeStorage([]); + }, []); + + return { recent, push, remove, clear }; +}