feat: add auth
This commit is contained in:
+136
-82
@@ -3,11 +3,9 @@
|
||||
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 { buildSearchResultUrl } from "@/lib/text-highlight";
|
||||
import {
|
||||
@@ -23,6 +21,7 @@ import {
|
||||
FileStack,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
WholeWord,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -185,6 +184,7 @@ function SearchInner() {
|
||||
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.
|
||||
@@ -192,6 +192,9 @@ function SearchInner() {
|
||||
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);
|
||||
@@ -211,25 +214,32 @@ function SearchInner() {
|
||||
|
||||
// Sync URL when state changes
|
||||
const pushUrl = useCallback(
|
||||
(q: string, typeParam: string, page: number) => {
|
||||
(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 or type changes.
|
||||
// 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);
|
||||
if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) {
|
||||
const [prevExact, setPrevExact] = useState(exactMatch);
|
||||
if (
|
||||
prevQuery !== debouncedQuery ||
|
||||
prevType !== activeTypeParam ||
|
||||
prevExact !== exactMatch
|
||||
) {
|
||||
setPrevQuery(debouncedQuery);
|
||||
setPrevType(activeTypeParam);
|
||||
setPrevExact(exactMatch);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
@@ -237,12 +247,12 @@ function SearchInner() {
|
||||
useEffect(() => {
|
||||
const query = debouncedQuery.trim();
|
||||
if (!query) {
|
||||
pushUrl("", urlTypeParam, 1);
|
||||
pushUrl("", urlTypeParam, 1, exactMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
pushUrl(query, urlTypeParam, currentPage);
|
||||
pushUrl(query, urlTypeParam, currentPage, exactMatch);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -252,6 +262,7 @@ function SearchInner() {
|
||||
page: String(currentPage),
|
||||
limit: "20",
|
||||
});
|
||||
if (exactMatch) params.set("exact", "1");
|
||||
|
||||
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
@@ -276,7 +287,7 @@ function SearchInner() {
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]);
|
||||
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, exactMatch, pushUrl]);
|
||||
|
||||
// Auto-focus the input on mount
|
||||
useEffect(() => {
|
||||
@@ -312,40 +323,67 @@ function SearchInner() {
|
||||
};
|
||||
|
||||
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
|
||||
<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>
|
||||
</PageHeader>
|
||||
<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
|
||||
“Krsna” finds “Kṛṣṇa.”
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
{/* Search Input */}
|
||||
<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="Search for verses, letters, lectures, terms..."
|
||||
placeholder="Try “sarva dharman parityajya” or “letter to mukunda”…"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="h-14 pl-12 pr-4 text-lg rounded-xl"
|
||||
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-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
|
||||
<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-6 flex flex-wrap gap-2">
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{CONTENT_TABS.map((tab) => {
|
||||
const active = isTypeActive(tab.value);
|
||||
return (
|
||||
@@ -388,15 +426,9 @@ function SearchInner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No query */}
|
||||
{/* No query — show editorial empty state with example queries */}
|
||||
{!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>
|
||||
<EmptyStateExamples onPick={(q) => setInputValue(q)} />
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
@@ -561,7 +593,7 @@ function ResultCard({ result }: { result: SearchResult }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block group"
|
||||
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");
|
||||
@@ -570,40 +602,79 @@ function ResultCard({ result }: { result: SearchResult }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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 */}
|
||||
<p className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
|
||||
{result.title}
|
||||
</p>
|
||||
{/* 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-yellow-200/80 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-yellow-500/30"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
{/* 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} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -665,25 +736,8 @@ 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 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>
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user