mongo fix
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type GlossaryEntry = {
|
||||
term: string;
|
||||
definition: string;
|
||||
definition_html: string;
|
||||
};
|
||||
|
||||
type GlossaryData = {
|
||||
title: string;
|
||||
description: string;
|
||||
totalEntries: number;
|
||||
letters: string[];
|
||||
entries: GlossaryEntry[];
|
||||
};
|
||||
|
||||
// Map diacritical first characters to base letters for grouping
|
||||
const DIACRITIC_TO_BASE: Record<string, string> = {
|
||||
Ā: "A",
|
||||
Ä: "A",
|
||||
Ī: "I",
|
||||
É: "I",
|
||||
Ū: "U",
|
||||
Ü: "U",
|
||||
Ṛ: "R",
|
||||
Ṝ: "R",
|
||||
Ś: "S",
|
||||
Ç: "S",
|
||||
Ṣ: "S",
|
||||
Ñ: "N",
|
||||
Ṇ: "N",
|
||||
Ṅ: "N",
|
||||
Ṭ: "T",
|
||||
Ḍ: "D",
|
||||
Ḥ: "H",
|
||||
};
|
||||
|
||||
function getBaseLetter(term: string): string {
|
||||
if (!term) return "?";
|
||||
const first = term[0].toUpperCase();
|
||||
return DIACRITIC_TO_BASE[first] || first;
|
||||
}
|
||||
|
||||
// Normalize a string for search matching (strip diacritics, lowercase)
|
||||
function normalizeForSearch(text: string): string {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function GlossaryClient({ data }: { data: GlossaryData }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeLetter, setActiveLetter] = useState<string | null>(null);
|
||||
const letterRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const normalizedQuery = useMemo(() => normalizeForSearch(query), [query]);
|
||||
|
||||
// Group entries by letter
|
||||
const groupedEntries = useMemo(() => {
|
||||
const groups: Record<string, GlossaryEntry[]> = {};
|
||||
for (const entry of data.entries) {
|
||||
const letter = getBaseLetter(entry.term);
|
||||
if (!groups[letter]) groups[letter] = [];
|
||||
groups[letter].push(entry);
|
||||
}
|
||||
return groups;
|
||||
}, [data.entries]);
|
||||
|
||||
// Filtered entries
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!normalizedQuery) return groupedEntries;
|
||||
|
||||
const result: Record<string, GlossaryEntry[]> = {};
|
||||
for (const [letter, entries] of Object.entries(groupedEntries)) {
|
||||
const filtered = entries.filter((e) => {
|
||||
const normTerm = normalizeForSearch(e.term);
|
||||
const normDef = normalizeForSearch(e.definition);
|
||||
return normTerm.includes(normalizedQuery) || normDef.includes(normalizedQuery);
|
||||
});
|
||||
if (filtered.length > 0) {
|
||||
result[letter] = filtered;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [groupedEntries, normalizedQuery]);
|
||||
|
||||
const filteredCount = useMemo(
|
||||
() => Object.values(filteredGroups).reduce((sum, g) => sum + g.length, 0),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
const sortedLetters = useMemo(
|
||||
() => Object.keys(filteredGroups).sort(),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
// Scroll to letter
|
||||
function scrollToLetter(letter: string) {
|
||||
setActiveLetter(letter);
|
||||
const el = letterRefs.current[letter];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
// Track active letter on scroll via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const letter = entry.target.getAttribute("data-letter");
|
||||
if (letter) setActiveLetter(letter);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px" },
|
||||
);
|
||||
|
||||
const sections = document.querySelectorAll("[data-letter]");
|
||||
sections.forEach((el) => observer.observe(el));
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [sortedLetters]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Search bar */}
|
||||
<div className="sticky top-0 z-10 bg-background py-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search glossary…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{query
|
||||
? `${filteredCount.toLocaleString()} result${filteredCount !== 1 ? "s" : ""} for "${query}"`
|
||||
: `${data.totalEntries.toLocaleString()} entries`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Letter navigation */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.letters.map((letter) => {
|
||||
const hasResults = letter in filteredGroups;
|
||||
return (
|
||||
<button
|
||||
key={letter}
|
||||
disabled={!hasResults}
|
||||
onClick={() => scrollToLetter(letter)}
|
||||
className={`inline-flex items-center justify-center size-8 rounded-md text-sm font-medium transition-colors ${
|
||||
activeLetter === letter
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasResults
|
||||
? "text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Entries grouped by letter */}
|
||||
<div ref={scrollContainerRef} className="space-y-8">
|
||||
{sortedLetters.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No entries match your search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedLetters.map((letter) => (
|
||||
<div
|
||||
key={letter}
|
||||
ref={(el) => {
|
||||
letterRefs.current[letter] = el;
|
||||
}}
|
||||
data-letter={letter}
|
||||
className="scroll-mt-40"
|
||||
>
|
||||
{/* Letter heading */}
|
||||
<div className="sticky top-[84px] z-[5] bg-background py-2 border-b border-border mb-3">
|
||||
<Badge variant="secondary" className="text-base px-3 py-0.5">
|
||||
{letter}
|
||||
</Badge>
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{filteredGroups[letter].length} entr{filteredGroups[letter].length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Entry cards */}
|
||||
<div className="space-y-2 px-1">
|
||||
{filteredGroups[letter].map((entry) => (
|
||||
<Card key={entry.term} size="sm">
|
||||
<CardContent className="py-3">
|
||||
<dt className="font-semibold text-foreground">
|
||||
{highlightMatch(entry.term, query)}
|
||||
</dt>
|
||||
<dd
|
||||
className="mt-1 text-sm text-muted-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: query
|
||||
? highlightHtml(entry.definition_html, query)
|
||||
: entry.definition_html,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matching parts of plain text (for the term).
|
||||
*/
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query) return text;
|
||||
|
||||
const normalizedText = normalizeForSearch(text);
|
||||
const normalizedQuery = normalizeForSearch(query);
|
||||
const index = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (index === -1) return text;
|
||||
|
||||
// Map the index in normalized text back to original text
|
||||
const before = text.slice(0, index);
|
||||
const match = text.slice(index, index + query.length);
|
||||
const after = text.slice(index + query.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<mark className="bg-primary/20 text-foreground rounded-sm px-0.5">{match}</mark>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matching parts inside an HTML string (for definitions).
|
||||
* Only highlights within text nodes, not inside HTML tags.
|
||||
*/
|
||||
function highlightHtml(html: string, query: string): string {
|
||||
if (!query) return html;
|
||||
|
||||
const normalizedQuery = normalizeForSearch(query);
|
||||
if (!normalizedQuery) return html;
|
||||
|
||||
// Split by HTML tags, only highlight in text nodes
|
||||
const parts = html.split(/(<[^>]+>)/);
|
||||
return parts
|
||||
.map((part) => {
|
||||
// Skip HTML tags
|
||||
if (part.startsWith("<")) return part;
|
||||
|
||||
// Try to find and highlight match in text content
|
||||
const normalizedPart = normalizeForSearch(part);
|
||||
const index = normalizedPart.indexOf(normalizedQuery);
|
||||
if (index === -1) return part;
|
||||
|
||||
const before = part.slice(0, index);
|
||||
const match = part.slice(index, index + query.length);
|
||||
const after = part.slice(index + query.length);
|
||||
return `${before}<mark class="bg-primary/20 text-foreground rounded-sm px-0.5">${match}</mark>${after}`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { GlossaryClient } from "./glossary-client";
|
||||
import { getAllGlossaryEntries } from "@/lib/api";
|
||||
|
||||
export default async function GlossaryPage() {
|
||||
const entries = await getAllGlossaryEntries();
|
||||
|
||||
const letters = Array.from(
|
||||
new Set(
|
||||
entries
|
||||
.map((e) => e.term.charAt(0).toUpperCase())
|
||||
.filter((ch) => ch.length > 0),
|
||||
),
|
||||
).sort();
|
||||
|
||||
const data = {
|
||||
title: "Glossary",
|
||||
description:
|
||||
"Sanskrit and Bengali terms used in the books of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda.",
|
||||
totalEntries: entries.length,
|
||||
letters,
|
||||
entries: entries.map((e) => ({
|
||||
term: e.term,
|
||||
definition: e.definition,
|
||||
definition_html: e.definitionHtml,
|
||||
})),
|
||||
};
|
||||
|
||||
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">
|
||||
{data.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{data.description}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<GlossaryClient data={data} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user