"use client"; import { useState, useEffect, useCallback, useRef, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { buildSearchResultUrl } from "@/lib/text-highlight"; import { KnowledgebaseIndex } from "@/components/knowledgebase-index"; import { Search, Loader2, BookOpen, Mail, Mic, BookA, PenLine, NotebookPen, Newspaper, FileStack, ChevronLeft, ChevronRight, WholeWord, AtSign, } from "lucide-react"; // --------------------------------------------------------------------------- // User search types // --------------------------------------------------------------------------- interface UserResult { handle: string; name: string; avatar_url?: string | null; } interface UserSearchResponse { query: string; results: UserResult[]; } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface SearchResult { collection: string; id: string; title: string; snippet: string; url: string; meta: Record; } interface SearchResponse { query: string; type: string; results: SearchResult[]; total: number; page: number; limit: number; total_pages: number; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CONTENT_TABS = [ { label: "All", value: "all" }, { label: "Books", value: "books" }, { label: "Letters", value: "letters" }, { label: "Transcriptions", value: "transcriptions" }, { label: "Glossary", value: "glossary" }, { label: "Essays", value: "essays" }, { label: "Journals", value: "journals" }, { label: "Magazines", value: "magazines" }, { label: "Other Documents", value: "other_documents" }, ] as const; // Maps a frontend tab value to the actual BFF collection name(s). // "books" expands to verses + prose for the API call. const TAB_TO_COLLECTIONS: Record = { books: ["verses", "prose"], }; function tabsToBffType(tabs: Set): string { if (tabs.size === 0) return "all"; const collections: string[] = []; for (const tab of tabs) { const expanded = TAB_TO_COLLECTIONS[tab]; if (expanded) { collections.push(...expanded); } else { collections.push(tab); } } return collections.join(","); } function bffTypeToTabs(typeParam: string): Set { if (!typeParam || typeParam === "all") return new Set(); const collections = typeParam .split(",") .map((t) => t.trim()) .filter(Boolean); const tabs = new Set(); for (const col of collections) { // If this collection is part of an expanded tab, use the tab name let mapped = false; for (const [tab, expanded] of Object.entries(TAB_TO_COLLECTIONS)) { if (expanded.includes(col)) { tabs.add(tab); mapped = true; break; } } if (!mapped) tabs.add(col); } return tabs; } const COLLECTION_CONFIG: Record< string, { label: string; icon: typeof BookOpen; color: string } > = { verses: { label: "Verse", icon: BookOpen, color: "bg-primary/10 text-primary", }, prose: { label: "Prose", icon: BookOpen, color: "bg-blue-500/10 text-blue-700 dark:text-blue-400", }, letters: { label: "Letter", icon: Mail, color: "bg-green-500/10 text-green-700 dark:text-green-400", }, transcriptions: { label: "Transcript", icon: Mic, color: "bg-purple-500/10 text-purple-700 dark:text-purple-400", }, glossary: { label: "Glossary", icon: BookA, color: "bg-amber-500/10 text-amber-700 dark:text-amber-400", }, essays: { label: "Essay", icon: PenLine, color: "bg-teal-500/10 text-teal-700 dark:text-teal-400", }, journals: { label: "Journal", icon: NotebookPen, color: "bg-orange-500/10 text-orange-700 dark:text-orange-400", }, magazines: { label: "Magazine", icon: Newspaper, color: "bg-pink-500/10 text-pink-700 dark:text-pink-400", }, other_documents: { label: "Document", icon: FileStack, color: "bg-muted text-muted-foreground", }, }; // --------------------------------------------------------------------------- // Debounce hook // --------------------------------------------------------------------------- function useDebouncedValue(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced; } // --------------------------------------------------------------------------- // Inner search component (uses useSearchParams) // --------------------------------------------------------------------------- function SearchInner() { const searchParams = useSearchParams(); const router = useRouter(); const inputRef = useRef(null); // Read initial state from URL const initialQuery = searchParams.get("q") || ""; const initialType = searchParams.get("type") || "all"; const initialPage = parseInt(searchParams.get("page") || "1", 10); const initialExact = searchParams.get("exact") === "1"; const [inputValue, setInputValue] = useState(initialQuery); // selectedTypes holds tab values (e.g., "books", "letters") — not raw collection names. const [selectedTypes, setSelectedTypes] = useState>(() => bffTypeToTabs(initialType), ); const [currentPage, setCurrentPage] = useState(initialPage); // When `exactMatch` is on, the BFF disables typo tolerance + prefix expansion. // Toggling it resets pagination (handled below) and resyncs the URL. const [exactMatch, setExactMatch] = useState(initialExact); // Convert selected tabs to the BFF type param ("books" → "verses,prose") const activeTypeParam = tabsToBffType(selectedTypes); // For URL: keep the tab-friendly form ("books") instead of the expanded form const urlTypeParam = selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(","); const [data, setData] = useState(null); // Separate state slot for user-search results — keeps the content // search response shape clean and lets the two branches render // entirely different cards without a discriminated-union dance. const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(false); const debouncedQuery = useDebouncedValue(inputValue, 300); const isQueryEmpty = !debouncedQuery.trim(); // User-search mode is driven entirely by the input — when the query // starts with "@" we strip it and hit /api/search/users instead of // the regular content search. The Users chip is a UI shortcut that // toggles the "@" prefix on / off; it never holds independent state. const isUserSearch = debouncedQuery.trimStart().startsWith("@"); const userQuery = isUserSearch ? debouncedQuery.trimStart().slice(1).trim() : ""; const isUserQueryEmpty = isUserSearch && userQuery.length === 0; // When the query is empty, derive these as "no results" during render // instead of setting state inside an effect. const effectiveData = isQueryEmpty || isUserSearch ? null : data; const hasSearched = isUserSearch ? !isUserQueryEmpty : !isQueryEmpty; // Sync URL when state changes const pushUrl = useCallback( (q: string, typeParam: string, page: number, exact: boolean) => { const params = new URLSearchParams(); if (q.trim()) params.set("q", q.trim()); if (typeParam !== "all") params.set("type", typeParam); if (page > 1) params.set("page", String(page)); if (exact) params.set("exact", "1"); const qs = params.toString(); router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false }); }, [router], ); // Reset page to 1 when query, type, or exact-mode changes. // Uses the "compare previous value during render" pattern // recommended by React instead of a setState-in-effect. const [prevQuery, setPrevQuery] = useState(debouncedQuery); const [prevType, setPrevType] = useState(activeTypeParam); const [prevExact, setPrevExact] = useState(exactMatch); if ( prevQuery !== debouncedQuery || prevType !== activeTypeParam || prevExact !== exactMatch ) { setPrevQuery(debouncedQuery); setPrevType(activeTypeParam); setPrevExact(exactMatch); setCurrentPage(1); } // Fetch search results — branches between user search (`@`-prefixed) // and content search. We clear the *other* state slot on every run // so the UI doesn't show stale results from a previous mode. useEffect(() => { const query = debouncedQuery.trim(); // Empty query in either mode — push a clean URL and bail. if (!query || isUserQueryEmpty) { pushUrl("", urlTypeParam, 1, exactMatch); return; } setIsLoading(true); pushUrl(query, urlTypeParam, currentPage, exactMatch); const controller = new AbortController(); if (isUserSearch) { // User-search branch — strip the @ prefix server-side too. // Clear content data so we don't flash old content results. setData(null); const userParams = new URLSearchParams({ q: userQuery, limit: "20" }); fetch(`/api/search/users?${userParams.toString()}`, { signal: controller.signal, }) .then((res) => res.json()) .then((json: UserSearchResponse) => { setUserData({ ...json, results: json.results ?? [] }); setIsLoading(false); }) .catch((err) => { if (err.name !== "AbortError") { setIsLoading(false); setUserData({ query: userQuery, results: [] }); } }); return () => controller.abort(); } // Content-search branch (unchanged from before — verbatim). setUserData(null); const params = new URLSearchParams({ q: query, type: activeTypeParam, page: String(currentPage), limit: "20", }); if (exactMatch) params.set("exact", "1"); fetch(`/api/search?${params.toString()}`, { signal: controller.signal }) .then((res) => res.json()) .then((json: SearchResponse) => { // Defensive: ensure results is always an array (BFF may return null) setData({ ...json, results: json.results ?? [] }); setIsLoading(false); }) .catch((err) => { if (err.name !== "AbortError") { setIsLoading(false); setData({ query, type: activeTypeParam, results: [], total: 0, page: 1, limit: 20, total_pages: 0, }); } }); return () => controller.abort(); }, [ debouncedQuery, activeTypeParam, urlTypeParam, currentPage, exactMatch, pushUrl, isUserSearch, isUserQueryEmpty, userQuery, ]); // Auto-focus the input on mount useEffect(() => { inputRef.current?.focus(); }, []); // Users chip is "active" whenever the live input starts with "@". // Clicking it toggles the prefix on / off — no separate state slot // since the input is already the source of truth. const isUserChipActive = inputValue.trimStart().startsWith("@"); const toggleUsersChip = () => { const trimmed = inputValue.trimStart(); if (trimmed.startsWith("@")) { // Strip the @ prefix (and the surrounding whitespace it carried). setInputValue(trimmed.slice(1)); } else { // Prepend @ — preserves whatever the user has already typed. setInputValue("@" + trimmed); } // Focus the input so the cursor lands ready for typing. inputRef.current?.focus(); }; const handleTypeChange = (type: string) => { if (type === "all") { // "All" clears all individual selections setSelectedTypes(new Set()); } else { setSelectedTypes((prev) => { const next = new Set(prev); if (next.has(type)) { // Deselect — if nothing left, goes back to "all" next.delete(type); } else { next.add(type); } return next; }); } }; const isTypeActive = (type: string): boolean => { if (type === "all") return selectedTypes.size === 0; return selectedTypes.has(type); }; const handlePageChange = (page: number) => { setCurrentPage(page); window.scrollTo({ top: 0, behavior: "smooth" }); }; return (
{/* Editorial hero — title in serif, supporting copy underneath */}

Search

Find a word, a verse, a name.

Smart search across books, letters, lectures, essays, journals and magazines. Diacritics are ignored, so “Krsna” finds “Kṛṣṇa.”

{/* Search input — the right edge carries the loading spinner (when fetching) and the exact-match toggle button. Right padding on the input reserves space for both so text never collides with them. */}
setInputValue(e.target.value)} className="h-14 pl-12 pr-24 text-lg rounded-xl ring-1 ring-foreground/10 border-transparent shadow-sm focus-visible:ring-2 focus-visible:ring-primary/40" /> {isLoading && ( )}
{/* Content Type Tabs */}
{/* Users chip — toggles the "@" prefix on the input. Sits at the start of the row so its at-sign icon reads as a kind of "search mode" indicator. */} {CONTENT_TABS.map((tab) => { const active = !isUserChipActive && isTypeActive(tab.value); return ( ); })}
{/* Results Area */}
{/* Loading */} {isLoading && (
Searching…
)} {/* No query — render the full Knowledgebase index so the search page doubles as the browsing entry point. */} {!isLoading && !hasSearched && } {/* User search — empty state (just typed @) */} {!isLoading && isUserSearch && isUserQueryEmpty && (

Searching users
Type a name or handle after the “@” to find someone.

)} {/* User search — no matches */} {!isLoading && isUserSearch && !isUserQueryEmpty && userData && userData.results.length === 0 && (

No users found for “@{userQuery}”.
Try a different name or handle.

)} {/* User search — results */} {!isLoading && isUserSearch && userData && userData.results.length > 0 && ( <>

{userData.results.length}{" "} {userData.results.length === 1 ? "user" : "users"} matching “@{userQuery}”

{userData.results.map((u) => ( ))}
)} {/* Content search — no results */} {!isLoading && !isUserSearch && hasSearched && effectiveData && effectiveData.results.length === 0 && (

No results found for “{debouncedQuery}”.
Try a different search term or content type.

)} {/* Results (content search only — user-search has its own block above with avatar-flavoured cards) */} {!isLoading && !isUserSearch && effectiveData && effectiveData.results.length > 0 && ( <> {/* Results count + top pagination */}

{effectiveData.total.toLocaleString()}{" "} {effectiveData.total === 1 ? "result " : "results "} for “ {effectiveData.query}”

{effectiveData.total_pages > 1 && ( )}
{/* Result list */}
{effectiveData.results.map((result, i) => ( ))}
{/* Bottom pagination */} {effectiveData.total_pages > 1 && (
)} )}
); } // --------------------------------------------------------------------------- // Pagination Controls // --------------------------------------------------------------------------- function PaginationControls({ page, totalPages, total, onPageChange, compact = false, }: { page: number; totalPages: number; total?: number; onPageChange: (page: number) => void; compact?: boolean; }) { if (compact) { return (
{page} / {totalPages}
); } return (
Page {page} of {totalPages} {total != null && ` · ${total.toLocaleString()} results`}
); } // --------------------------------------------------------------------------- // Result Card // --------------------------------------------------------------------------- function ResultCard({ result }: { result: SearchResult }) { const config = COLLECTION_CONFIG[result.collection] || { label: result.collection, icon: FileStack, color: "bg-muted text-muted-foreground", }; const Icon = config.icon; // Append `?hl=` so the destination page highlights the snippet // text Typesense matched. Falls back to the original URL if no marks were // present in the snippet. const href = buildSearchResultUrl(result.url, result.snippet); return ( { try { sessionStorage.setItem("spsays_from_search", "1"); } catch { /* sessionStorage unavailable */ } }} >
{/* Collection badge */}
{config.label}
{/* Title — serif for editorial weight */}

{result.title}

{/* Snippet with highlighted matches */}

{/* Meta information */}

); } // --------------------------------------------------------------------------- // Result Meta — varies by collection // --------------------------------------------------------------------------- function ResultMeta({ result }: { result: SearchResult }) { const { collection, meta } = result; if (!meta || Object.keys(meta).length === 0) return null; let items: string[] = []; switch (collection) { case "verses": if (meta.book) items.push(meta.book); if (meta.reference) items.push(meta.reference); break; case "letters": if (meta.date) items.push(meta.date); if (meta.location) items.push(meta.location); break; case "transcriptions": if (meta.type) items.push(meta.type); if (meta.date) items.push(meta.date); if (meta.place) items.push(meta.place); break; case "glossary": if (meta.term) items.push(meta.term); break; case "prose": case "essays": case "journals": case "magazines": case "other_documents": if (meta.book) items.push(meta.book); break; default: // Show all meta values items = Object.values(meta).filter(Boolean) as string[]; } if (items.length === 0) return null; return (
{items.map((item, i) => ( {item} ))}
); } // --------------------------------------------------------------------------- // User result card // --------------------------------------------------------------------------- function UserResultCard({ user }: { user: UserResult }) { const initial = user.name?.charAt(0)?.toUpperCase() || "?"; return ( {user.avatar_url ? ( {user.name} ) : (
{initial}
)}

{user.name}

@{user.handle}

); } // --------------------------------------------------------------------------- // Exported wrapper with Suspense boundary // --------------------------------------------------------------------------- export function SearchContent() { return ( } > ); }