870 lines
28 KiB
TypeScript
870 lines
28 KiB
TypeScript
"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 { useMediaQuery } from "@/components/selection-toolbar/shared";
|
|
import { SearchFiltersMenu } from "./filters-menu";
|
|
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<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);
|
|
// Drives the popover-vs-drawer split inside SearchFiltersMenu —
|
|
// same convention as the rest of the app's overflow menus.
|
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
|
|
// 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);
|
|
// 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<UserSearchResponse | null>(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 (
|
|
<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-2xl sm:text-4xl leading-tight text-foreground">
|
|
Find a word, a verse, a name.
|
|
</h1>
|
|
</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>
|
|
|
|
{/* Filters — single trigger that opens a popover (desktop) or
|
|
drawer (mobile) of multi-select checkboxes. The trigger
|
|
carries a count badge when one or more filters are active.
|
|
Users is mutually exclusive with content narrowing inside
|
|
the menu. */}
|
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
<SearchFiltersMenu
|
|
contentTabs={CONTENT_TABS}
|
|
selectedTypes={selectedTypes}
|
|
onTypeChange={handleTypeChange}
|
|
isUserChipActive={isUserChipActive}
|
|
onToggleUsers={toggleUsersChip}
|
|
isMobile={isMobile}
|
|
/>
|
|
</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 — render the full Knowledgebase index so the search
|
|
page doubles as the browsing entry point. */}
|
|
{!isLoading && !hasSearched && <KnowledgebaseIndex />}
|
|
|
|
{/* User search — empty state (just typed @) */}
|
|
{!isLoading && isUserSearch && isUserQueryEmpty && (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<AtSign className="size-12 text-muted-foreground/40 mb-4" />
|
|
<p className="text-lg text-muted-foreground">
|
|
Searching users
|
|
<br />
|
|
<span className="text-sm">
|
|
Type a name or handle after the “@” to find
|
|
someone.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* User search — no matches */}
|
|
{!isLoading &&
|
|
isUserSearch &&
|
|
!isUserQueryEmpty &&
|
|
userData &&
|
|
userData.results.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<AtSign className="size-12 text-muted-foreground/40 mb-4" />
|
|
<p className="text-lg text-muted-foreground">
|
|
No users found for “@{userQuery}”.
|
|
<br />
|
|
<span className="text-sm">
|
|
Try a different name or handle.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* User search — results */}
|
|
{!isLoading &&
|
|
isUserSearch &&
|
|
userData &&
|
|
userData.results.length > 0 && (
|
|
<>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
{userData.results.length}{" "}
|
|
{userData.results.length === 1 ? "user" : "users"} matching
|
|
“@{userQuery}”
|
|
</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{userData.results.map((u) => (
|
|
<UserResultCard key={u.handle} user={u} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Content search — no results */}
|
|
{!isLoading &&
|
|
!isUserSearch &&
|
|
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 “{debouncedQuery}”.
|
|
<br />
|
|
<span className="text-sm">
|
|
Try a different search term or content type.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 */}
|
|
<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
|
|
“
|
|
{effectiveData.query}”
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User result card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function UserResultCard({ user }: { user: UserResult }) {
|
|
const initial = user.name?.charAt(0)?.toUpperCase() || "?";
|
|
return (
|
|
<Link
|
|
href={`/u/${user.handle}`}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-xl bg-card ring-1 ring-foreground/10 p-4",
|
|
"hover:ring-foreground/25 hover:shadow-sm transition-all",
|
|
)}
|
|
>
|
|
{user.avatar_url ? (
|
|
<Image
|
|
src={user.avatar_url}
|
|
alt={user.name}
|
|
width={56}
|
|
height={56}
|
|
className="size-14 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<div className="size-14 rounded-full bg-primary/15 text-primary flex items-center justify-center text-xl font-semibold ring-1 ring-foreground/10 shrink-0">
|
|
{initial}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-semibold text-foreground truncate">
|
|
{user.name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">@{user.handle}</p>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>
|
|
);
|
|
}
|