Files
spsays-training/app/search/search-content.tsx
T
2026-05-30 02:34:22 +05:30

748 lines
24 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
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 {
Search,
Loader2,
BookOpen,
Mail,
Mic,
BookA,
PenLine,
NotebookPen,
Newspaper,
FileStack,
ChevronLeft,
ChevronRight,
WholeWord,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SearchResult {
collection: string;
id: string;
title: string;
snippet: string;
url: string;
meta: Record<string, string | undefined>;
}
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<string, string[]> = {
books: ["verses", "prose"],
};
function tabsToBffType(tabs: Set<string>): 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<string> {
if (!typeParam || typeParam === "all") return new Set<string>();
const collections = typeParam
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const tabs = new Set<string>();
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<T>(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<HTMLInputElement>(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<Set<string>>(() =>
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<SearchResponse | null>(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, 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
useEffect(() => {
const query = debouncedQuery.trim();
if (!query) {
pushUrl("", urlTypeParam, 1, exactMatch);
return;
}
setIsLoading(true);
pushUrl(query, urlTypeParam, currentPage, exactMatch);
const controller = new AbortController();
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]);
// 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 (
<div className="flex-1">
{/* Editorial hero — title in serif, supporting copy underneath */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-10 sm:pt-16 pb-6">
<p className="uppercase tracking-[0.22em] text-xs font-semibold text-muted-foreground">
Search
</p>
<h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-4xl sm:text-5xl leading-tight text-foreground">
Find a word, a verse, a name.
</h1>
<p className="mt-3 max-w-xl text-base text-muted-foreground leading-relaxed">
Typo-tolerant search across every book, letter, lecture, essay,
journal, magazine, and glossary entry. Diacritics are ignored, so
&ldquo;Krsna&rdquo; finds &ldquo;Kṛṣṇa.&rdquo;
</p>
</section>
<main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16">
{/* 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. */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="search"
placeholder="Try “sarva dharman parityajya” or “letter to mukunda”…"
value={inputValue}
onChange={(e) => 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 && (
<Loader2 className="absolute right-14 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
)}
<button
type="button"
onClick={() => setExactMatch((v) => !v)}
aria-pressed={exactMatch}
aria-label={
exactMatch
? "Exact match is on — only literal keyword matches"
: "Enable exact match — disables typo tolerance"
}
title={
exactMatch
? "Exact match: on (no typo tolerance, no partial words)"
: "Exact match: off — toggle to require literal keyword matches"
}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-lg transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-primary/40",
exactMatch
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<WholeWord className="size-5" />
</button>
</div>
{/* Content Type Tabs */}
<div className="mt-5 flex flex-wrap gap-2">
{CONTENT_TABS.map((tab) => {
const active = isTypeActive(tab.value);
return (
<button
key={tab.value}
onClick={() => handleTypeChange(tab.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
{tab.label}
{effectiveData &&
active &&
selectedTypes.size <= 1 &&
effectiveData.total > 0 && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none",
"bg-primary-foreground/20 text-primary-foreground",
)}
>
{effectiveData.total.toLocaleString()}
</span>
)}
</button>
);
})}
</div>
{/* Results Area */}
<div className="mt-8">
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span>Searching…</span>
</div>
)}
{/* No query — show editorial empty state with example queries */}
{!isLoading && !hasSearched && (
<EmptyStateExamples onPick={(q) => setInputValue(q)} />
)}
{/* No results */}
{!isLoading &&
hasSearched &&
effectiveData &&
effectiveData.results.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;.
<br />
<span className="text-sm">
Try a different search term or content type.
</span>
</p>
</div>
)}
{/* Results */}
{!isLoading && effectiveData && effectiveData.results.length > 0 && (
<>
{/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground">
{effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result " : "results "} for
&ldquo;
{effectiveData.query}&rdquo;
</p>
{effectiveData.total_pages > 1 && (
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
onPageChange={handlePageChange}
compact
/>
)}
</div>
{/* Result list */}
<div className="space-y-3">
{effectiveData.results.map((result, i) => (
<ResultCard
key={`${result.collection}-${result.id || i}`}
result={result}
/>
))}
</div>
{/* Bottom pagination */}
{effectiveData.total_pages > 1 && (
<div className="mt-8">
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
total={effectiveData.total}
onPageChange={handlePageChange}
/>
</div>
)}
</>
)}
</div>
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
</div>
);
}
return (
<div className="flex items-center justify-between">
<Button
variant="outline"
size="default"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{total != null && ` · ${total.toLocaleString()} results`}
</span>
<Button
variant="outline"
size="default"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// 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=<matched-text>` 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 (
<Link
href={href}
className="block group rounded-xl bg-card/60 ring-1 ring-foreground/8 hover:ring-foreground/25 hover:bg-card transition-all px-4 sm:px-5 py-4"
onClick={() => {
try {
sessionStorage.setItem("spsays_from_search", "1");
} catch {
/* sessionStorage unavailable */
}
}}
>
<div className="flex flex-col gap-2">
{/* Collection badge */}
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.12em] font-medium",
config.color,
)}
>
<Icon className="size-3" />
{config.label}
</span>
</div>
{/* Title — serif for editorial weight */}
<p className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground group-hover:text-primary transition-colors leading-snug">
{result.title}
</p>
{/* Snippet with highlighted matches */}
<p
className="text-sm text-muted-foreground leading-relaxed [&_mark]:bg-primary/20 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-primary/30"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
{/* Meta information */}
<ResultMeta result={result} />
</div>
</Link>
);
}
// ---------------------------------------------------------------------------
// Empty-state example chips
// ---------------------------------------------------------------------------
const EXAMPLE_QUERIES: { label: string; query: string }[] = [
{ label: "sarva dharman parityajya", query: "sarva dharman parityajya" },
{ label: "letter to mukunda", query: "letter to mukunda" },
{ label: "bhakti yoga", query: "bhakti yoga" },
{ label: "tulasi", query: "tulasi" },
{ label: "morning walk los angeles", query: "morning walk los angeles" },
];
function EmptyStateExamples({ onPick }: { onPick: (q: string) => void }) {
return (
<div className="py-12 sm:py-16">
<p className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground text-center">
Try one of these
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2 max-w-2xl mx-auto">
{EXAMPLE_QUERIES.map((ex) => (
<button
key={ex.query}
type="button"
onClick={() => onPick(ex.query)}
className="rounded-full bg-card ring-1 ring-foreground/10 hover:ring-foreground/30 px-3.5 py-1.5 text-sm font-medium text-foreground transition-all"
>
{ex.label}
</button>
))}
</div>
<p className="mt-8 text-center text-xs text-muted-foreground">
Press{" "}
<kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-[0.7rem]">
/
</kbd>{" "}
from any page to jump here.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="flex flex-wrap items-center gap-1.5">
{items.map((item, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
{item}
</Badge>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Exported wrapper with Suspense boundary
// ---------------------------------------------------------------------------
export function SearchContent() {
return (
<Suspense
fallback={
<div className="flex-1 max-w-5xl mx-auto px-4 sm:px-6 py-16 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
}
>
<SearchInner />
</Suspense>
);
}