Files
spsays-training/app/glossary/glossary-client.tsx
T

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("");
}