"use client"; import { useState, useEffect, useCallback, useRef, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { PageHeader } from "@/components/page-header"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { Search, Loader2, BookOpen, Mail, Mic, BookA, PenLine, NotebookPen, Newspaper, FileStack, ChevronLeft, ChevronRight, } from "lucide-react"; // --------------------------------------------------------------------------- // 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 [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); // 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); const [isLoading, setIsLoading] = useState(false); const debouncedQuery = useDebouncedValue(inputValue, 300); const isQueryEmpty = !debouncedQuery.trim(); // When the query is empty, derive these as "no results" during render // instead of setting state inside an effect. const effectiveData = isQueryEmpty ? null : data; const hasSearched = isQueryEmpty ? false : !!debouncedQuery.trim(); // Sync URL when state changes const pushUrl = useCallback( (q: string, typeParam: string, page: number) => { 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)); const qs = params.toString(); router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false }); }, [router], ); // Reset page to 1 when query or type 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); if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) { setPrevQuery(debouncedQuery); setPrevType(activeTypeParam); setCurrentPage(1); } // Fetch search results useEffect(() => { const query = debouncedQuery.trim(); if (!query) { pushUrl("", urlTypeParam, 1); return; } setIsLoading(true); pushUrl(query, urlTypeParam, currentPage); const controller = new AbortController(); const params = new URLSearchParams({ q: query, type: activeTypeParam, page: String(currentPage), limit: "20", }); 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, pushUrl]); // Auto-focus the input on mount useEffect(() => { 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 (
← Home

Search

Search across all of Śrīla Prabhupāda's books, letters, lectures, and more

{/* Search Input */}
setInputValue(e.target.value)} className="h-14 pl-12 pr-4 text-lg rounded-xl" /> {isLoading && ( )}
{/* Content Type Tabs */}
{CONTENT_TABS.map((tab) => { const active = isTypeActive(tab.value); return ( ); })}
{/* Results Area */}
{/* Loading */} {isLoading && (
Searching…
)} {/* No query */} {!isLoading && !hasSearched && (

Search across all of Śrīla Prabhupāda's books, letters, lectures, and more

)} {/* No results */} {!isLoading && hasSearched && effectiveData && effectiveData.results.length === 0 && (

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

)} {/* Results */} {!isLoading && 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; return ( { try { sessionStorage.setItem("spsays_from_search", "1"); } catch { /* sessionStorage unavailable */ } }} > {/* Collection badge */}
{config.label}
{/* Title */}

{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} ))}
); } // --------------------------------------------------------------------------- // Exported wrapper with Suspense boundary // --------------------------------------------------------------------------- export function SearchContent() { return ( ← Home

Search

Search across all of Śrīla Prabhupāda's books, letters, lectures, and more

} >
); }