"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 = { Ā: "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(null); const letterRefs = useRef>({}); const scrollContainerRef = useRef(null); const normalizedQuery = useMemo(() => normalizeForSearch(query), [query]); // Group entries by letter const groupedEntries = useMemo(() => { const groups: Record = {}; 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 = {}; 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 (
{/* Search bar */}
setQuery(e.target.value)} className="pl-10 pr-10" /> {query && ( )}

{query ? `${filteredCount.toLocaleString()} result${filteredCount !== 1 ? "s" : ""} for "${query}"` : `${data.totalEntries.toLocaleString()} entries`}

{/* Letter navigation */}
{data.letters.map((letter) => { const hasResults = letter in filteredGroups; return ( ); })}
{/* Entries grouped by letter */}
{sortedLetters.length === 0 && (
No entries match your search.
)} {sortedLetters.map((letter) => (
{ letterRefs.current[letter] = el; }} data-letter={letter} className="scroll-mt-40" > {/* Letter heading */}
{letter} {filteredGroups[letter].length} entr {filteredGroups[letter].length === 1 ? "y" : "ies"}
{/* Entry cards */}
{filteredGroups[letter].map((entry) => (
{highlightMatch(entry.term, query)}
))}
))}
); } /** * 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} {match} {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}${match}${after}`; }) .join(""); }