feat: add search
This commit is contained in:
+28
-1
@@ -17,6 +17,7 @@ Content is sourced from the pre-1980s Vedabase, extracted and indexed into Mongo
|
||||
- **vaul** for mobile drawer navigation
|
||||
- **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
|
||||
- **framer-motion** for audio player animations (spring enter/exit, minimize/expand transitions, audio wave bars)
|
||||
- **Typesense** (via BFF) — typo-tolerant full-text search across all content
|
||||
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
|
||||
|
||||
---
|
||||
@@ -69,9 +70,13 @@ knowledgebase/
|
||||
│ │ ├── place/[place]/page.tsx → Place listing
|
||||
│ │ ├── type/[type]/page.tsx → Type listing (lecture/conversation), year sidebar
|
||||
│ │ └── [slug]/page.tsx → Individual transcription reading page
|
||||
│ ├── search/ → Search page (server + client components)
|
||||
│ │ ├── page.tsx → Server component (URL-driven state)
|
||||
│ │ └── search-content.tsx → Client component (input, filters, results)
|
||||
│ └── api/
|
||||
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
||||
│ ├── books/ → Book/verse API routes
|
||||
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
|
||||
│ └── glossary/route.ts → Glossary API
|
||||
├── components/
|
||||
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
|
||||
@@ -86,6 +91,7 @@ knowledgebase/
|
||||
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
|
||||
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
||||
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
||||
│ ├── header-search.tsx → Header search button with framer-motion expand animation
|
||||
│ ├── site-footer.tsx → Global footer (About + Home links)
|
||||
│ ├── audio-player/ → Site-wide floating audio player
|
||||
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
|
||||
@@ -542,6 +548,23 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
- Implemented in all 24+ reader pages via shared `MobileNav` component
|
||||
|
||||
### Search
|
||||
|
||||
**Search** (`/search` page) — Typo-tolerant, diacritic-insensitive full-text search across all content.
|
||||
|
||||
- **Powered by Typesense** via the BFF (`/api/v1/search` proxied through `/api/search`)
|
||||
- **9 indexed content types** combined into 8 user-facing filters: Books (verses + prose), Letters, Transcriptions, Glossary, Essays, Journals, Magazines, Other Documents
|
||||
- **Multi-select filters** — click multiple type tabs to combine (e.g., Books + Letters); "All" deselects everything
|
||||
- **URL state sync** — `/search?q=surrender&type=books,letters` is shareable and bookmarkable
|
||||
- **Header search button** — `HeaderSearchButton` component on every page header expands into a search input on click; press `/` from anywhere to trigger
|
||||
- **300ms debounce** on input
|
||||
- **Highlighted snippets** — matched text wrapped in `<mark>` tags
|
||||
- **Typo tolerance** — "Krishna", "Krsna", "Krishn" all find Kṛṣṇa results
|
||||
- **Diacritic insensitivity** — `_ascii` shadow fields indexed alongside originals (e.g., "Yashodanandana" matches "Yaśodanandana")
|
||||
- **Prefix matching** — partial words like "sarv dharman parit" match `sarva-dharmān parityajya`
|
||||
- **Clickable result cards** — entire card navigates to the source page
|
||||
- Files: `app/search/page.tsx`, `app/search/search-content.tsx`, `app/api/search/route.ts`, `components/header-search.tsx`
|
||||
|
||||
### Per-Page Filters
|
||||
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
|
||||
- Glossary page: full-text search across terms and definitions
|
||||
- Uses Unicode NFD normalization + manual IAST character stripping
|
||||
@@ -620,6 +643,8 @@ ALLOW_RTF=true # Enable RTF viewer (development only)
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org # Public site URL for OG images
|
||||
```
|
||||
|
||||
> No new vars are needed for the frontend search feature — search goes through the BFF, which has `TYPESENSE_URL` and `TYPESENSE_API_KEY` configured server-side.
|
||||
|
||||
---
|
||||
|
||||
## How to Run
|
||||
@@ -776,4 +801,6 @@ Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `
|
||||
|
||||
7. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||
|
||||
8. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||
8. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||
|
||||
9. **Search depends on Typesense** — Search depends on Typesense being available via the BFF; if Typesense is down, search will return empty results but the rest of the site is unaffected.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const q = searchParams.get("q") || "";
|
||||
const type = searchParams.get("type") || "all";
|
||||
const page = searchParams.get("page") || "1";
|
||||
const limit = searchParams.get("limit") || "20";
|
||||
|
||||
if (!q.trim()) {
|
||||
return NextResponse.json({
|
||||
query: "",
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total_pages: 0,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("/api/v1/search", BFF_BASE);
|
||||
url.searchParams.set("q", q);
|
||||
url.searchParams.set("type", type);
|
||||
url.searchParams.set("page", page);
|
||||
url.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: q,
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
total_pages: 0,
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Unwrap BFF envelope: { data: { ... } } → { ... }
|
||||
const payload = data.data ?? data;
|
||||
return NextResponse.json(payload);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: q,
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
total_pages: 0,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -291,8 +291,28 @@ export function GlossaryClient({
|
||||
letterCounts,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTermRaw, setActiveTerm] = useState(entries[0]?.term || "");
|
||||
|
||||
// Initial active term: prefer ?term= URL param, then fall back to first entry
|
||||
const initialTerm = useMemo(() => {
|
||||
const termParam = searchParams.get("term");
|
||||
if (termParam) {
|
||||
// Match case-insensitively and ignore diacritics
|
||||
const normalize = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
const normalizedParam = normalize(termParam);
|
||||
const match = entries.find((e) => normalize(e.term) === normalizedParam);
|
||||
if (match) return match.term;
|
||||
}
|
||||
return entries[0]?.term || "";
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [activeTermRaw, setActiveTerm] = useState(initialTerm);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// All term names
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { SearchContent } from "./search-content";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return buildOgMeta({
|
||||
title: "Search",
|
||||
detail: "Search across all content",
|
||||
quote:
|
||||
"Search across Śrīla Prabhupāda's books, letters, lectures, conversations, and more.",
|
||||
});
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return <SearchContent />;
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
"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<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 [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);
|
||||
|
||||
// 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 [hasSearched, setHasSearched] = useState(!!initialQuery);
|
||||
|
||||
const debouncedQuery = useDebouncedValue(inputValue, 300);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// Fetch search results
|
||||
useEffect(() => {
|
||||
const query = debouncedQuery.trim();
|
||||
if (!query) {
|
||||
setData(null);
|
||||
setHasSearched(false);
|
||||
pushUrl("", urlTypeParam, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSearched(true);
|
||||
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]);
|
||||
|
||||
// Reset page to 1 when query or type changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedQuery, activeTypeParam]);
|
||||
|
||||
// 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="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Search</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Search across all of Śrīla Prabhupāda's books, letters, lectures,
|
||||
and more
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
{/* Search Input */}
|
||||
<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="Search for verses, letters, lectures, terms..."
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="h-14 pl-12 pr-4 text-lg rounded-xl"
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Type Tabs */}
|
||||
<div className="mt-6 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}
|
||||
{data &&
|
||||
active &&
|
||||
selectedTypes.size <= 1 &&
|
||||
data.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",
|
||||
)}
|
||||
>
|
||||
{data.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 */}
|
||||
{!isLoading && !hasSearched && (
|
||||
<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">
|
||||
Search across all of Śrīla Prabhupāda's books, letters,
|
||||
lectures, and more
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!isLoading && hasSearched && data && data.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 “{debouncedQuery}”.
|
||||
<br />
|
||||
<span className="text-sm">
|
||||
Try a different search term or content type.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!isLoading && data && data.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">
|
||||
{data.total.toLocaleString()}{" "}
|
||||
{data.total === 1 ? "result" : "results"} for “
|
||||
{data.query}”
|
||||
</p>
|
||||
{data.total_pages > 1 && (
|
||||
<PaginationControls
|
||||
page={data.page}
|
||||
totalPages={data.total_pages}
|
||||
onPageChange={handlePageChange}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result list */}
|
||||
<div className="space-y-3">
|
||||
{data.results.map((result, i) => (
|
||||
<ResultCard
|
||||
key={`${result.collection}-${result.id || i}`}
|
||||
result={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom pagination */}
|
||||
{data.total_pages > 1 && (
|
||||
<div className="mt-8">
|
||||
<PaginationControls
|
||||
page={data.page}
|
||||
totalPages={data.total_pages}
|
||||
total={data.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;
|
||||
|
||||
return (
|
||||
<Link href={result.url} className="block group">
|
||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer">
|
||||
<CardContent 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-xs font-medium",
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="text-base font-semibold 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-yellow-200/80 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-yellow-500/30"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
|
||||
{/* Meta information */}
|
||||
<ResultMeta result={result} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Search</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Search across all of Śrīla Prabhupāda's books, letters,
|
||||
lectures, and more
|
||||
</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* HeaderSearchButton — a search icon in the page header that expands
|
||||
* into a full search input on click. Typing a query and pressing Enter
|
||||
* (or clicking the search icon again) navigates to /search?q=...
|
||||
*
|
||||
* The expansion/collapse is animated with framer-motion.
|
||||
* Pressing Escape or clicking outside collapses the input.
|
||||
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
|
||||
*/
|
||||
export function HeaderSearchButton() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Focus input when expanded
|
||||
useEffect(() => {
|
||||
if (expanded && inputRef.current) {
|
||||
// Small delay to let the animation start before focusing
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [expanded]);
|
||||
|
||||
// Global "/" shortcut to open search
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Don't trigger if user is typing in an input/textarea
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
setExpanded(false);
|
||||
setQuery("");
|
||||
} else {
|
||||
router.push("/search");
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [query, router]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setExpanded(false);
|
||||
setQuery("");
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
initial={{ width: 36, opacity: 0.8 }}
|
||||
animate={{ width: 280, opacity: 1 }}
|
||||
exit={{ width: 36, opacity: 0.8 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 350 }}
|
||||
className="flex items-center gap-2 h-9 rounded-lg border border-border bg-background px-3 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
aria-label="Search"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpanded(false);
|
||||
setQuery("");
|
||||
}}
|
||||
aria-label="Close search"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="search-icon"
|
||||
initial={{ opacity: 0.8, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0.8, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-label="Search (press /)"
|
||||
title="Search (press /)"
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<Search className="size-[18px]" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { HeaderSearchButton } from "@/components/header-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function PageHeader({
|
||||
@@ -20,7 +21,8 @@ export function PageHeader({
|
||||
>
|
||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
<div className="shrink-0 pt-1 flex items-center gap-1.5">
|
||||
<HeaderSearchButton />
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-1
@@ -397,5 +397,17 @@ export async function getProseChapter(
|
||||
* Get all glossary entries, ordered alphabetically by term.
|
||||
*/
|
||||
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
||||
return bffList<GlossaryRecord>("/glossary", { tags: ["glossary"] });
|
||||
const raw = await bffList<{
|
||||
id: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
definition_html: string;
|
||||
}>("/glossary", { tags: ["glossary"] });
|
||||
|
||||
return raw.map((entry) => ({
|
||||
id: entry.id,
|
||||
term: entry.term,
|
||||
definition: entry.definition,
|
||||
definitionHtml: entry.definition_html ?? entry.definition,
|
||||
}));
|
||||
}
|
||||
|
||||
+31
-1
@@ -287,8 +287,38 @@ export async function getSidebarItems(code: string): Promise<SidebarItem[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarchical books (SVA, SSR, DS) — chapters have nested subChapters
|
||||
const hasHierarchical =
|
||||
meta.chapters &&
|
||||
meta.chapters.length > 0 &&
|
||||
meta.chapters.some((ch) => ch.subChapters && ch.subChapters.length > 0);
|
||||
|
||||
if (hasHierarchical && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
// Parent chapter heading (not navigable on its own)
|
||||
items.push({
|
||||
slug: `parent-${ch.number}`,
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "parent",
|
||||
chapter: ch.number,
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
// Sub-chapters
|
||||
if (ch.subChapters) {
|
||||
for (const sub of ch.subChapters) {
|
||||
items.push({
|
||||
slug: sub.slug,
|
||||
label: sub.title,
|
||||
type: "sub-chapter",
|
||||
chapter: ch.number,
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chapters — if parts exist, group chapters under part headers
|
||||
if (
|
||||
else if (
|
||||
meta.parts &&
|
||||
meta.parts.length > 0 &&
|
||||
meta.chapters &&
|
||||
|
||||
Reference in New Issue
Block a user