305 lines
9.1 KiB
TypeScript
305 lines
9.1 KiB
TypeScript
"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-[92px] md: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-base text-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("");
|
|
}
|