diff --git a/app/glossary/[letter]/page.tsx b/app/glossary/[letter]/page.tsx new file mode 100644 index 00000000..879a7c26 --- /dev/null +++ b/app/glossary/[letter]/page.tsx @@ -0,0 +1,106 @@ +export const dynamic = "force-dynamic"; + +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getAllGlossaryEntries } from "@/lib/api"; +import { buildOgMeta } from "@/lib/og"; +import { GlossaryClient } from "../glossary-client"; + +// --------------------------------------------------------------------------- +// Diacritic → base letter mapping +// --------------------------------------------------------------------------- + +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; +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +export async function generateMetadata({ + params, +}: { + params: Promise<{ letter: string }>; +}): Promise { + const { letter } = await params; + const l = letter.toUpperCase(); + return buildOgMeta({ + title: `Glossary — ${l}`, + detail: `Sanskrit & Vedic terms starting with ${l}`, + quote: + "A comprehensive glossary of Sanskrit and Vedic terms from the books of Śrīla Prabhupāda.", + }); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default async function GlossaryLetterPage({ + params, +}: { + params: Promise<{ letter: string }>; +}) { + const { letter: rawLetter } = await params; + const activeLetter = rawLetter.toUpperCase(); + + // Validate — single letter A-Z + if (!/^[A-Z]$/.test(activeLetter)) notFound(); + + // Fetch all entries (cached by Next.js request dedup within a single render) + const allEntries = await getAllGlossaryEntries(); + + // Group all entries by base letter + const byLetter: Record = {}; + for (const entry of allEntries) { + const base = getBaseLetter(entry.term); + if (!byLetter[base]) byLetter[base] = []; + byLetter[base].push(entry); + } + + // All available letters (sorted) + const letters = Object.keys(byLetter).sort(); + + // Entries for the active letter + const letterEntries = byLetter[activeLetter] || []; + if (letterEntries.length === 0) notFound(); + + // Prepare client data — only the active letter's entries + const clientEntries = letterEntries.map((e) => ({ + term: e.term, + definition: e.definition, + definition_html: e.definitionHtml, + })); + + // Letter counts for the nav + const letterCounts: Record = {}; + for (const [l, entries] of Object.entries(byLetter)) { + letterCounts[l] = entries.length; + } + + return ( +
+ +
+ ); +} diff --git a/app/glossary/glossary-client.tsx b/app/glossary/glossary-client.tsx index 6180ce2f..fb99ae0b 100644 --- a/app/glossary/glossary-client.tsx +++ b/app/glossary/glossary-client.tsx @@ -1,11 +1,25 @@ "use client"; -import { useState, useMemo, useRef, useEffect } from "react"; -import { Search, X } from "lucide-react"; +import { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { Search, X, Menu, ChevronLeft, ChevronRight } 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"; +import { cn } from "@/lib/utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { ReadingSettings } from "@/components/reading-settings"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- type GlossaryEntry = { term: string; @@ -13,42 +27,17 @@ type GlossaryEntry = { definition_html: string; }; -type GlossaryData = { - title: string; - description: string; - totalEntries: number; - letters: string[]; +type Props = { entries: GlossaryEntry[]; + activeLetter: string; + letters: string[]; + letterCounts: Record; }; -// 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", -}; +// --------------------------------------------------------------------------- +// Normalize for search +// --------------------------------------------------------------------------- -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") @@ -56,249 +45,437 @@ function normalizeForSearch(text: string): string { .toLowerCase(); } -export function GlossaryClient({ data }: { data: GlossaryData }) { - const [query, setQuery] = useState(""); - const [activeLetter, setActiveLetter] = useState(null); - const letterRefs = useRef>({}); - const scrollContainerRef = useRef(null); +// --------------------------------------------------------------------------- +// Letter Navigation — horizontal row matching disciples pattern +// --------------------------------------------------------------------------- - const normalizedQuery = useMemo(() => normalizeForSearch(query), [query]); +function LetterNav({ + letters, + activeLetter, + letterCounts, +}: { + letters: string[]; + activeLetter: string; + letterCounts: Record; +}) { + return ( + ); +} - const sortedLetters = useMemo( - () => Object.keys(filteredGroups).sort(), - [filteredGroups], - ); +// --------------------------------------------------------------------------- +// Sidebar (desktop) — shows terms for active letter only +// --------------------------------------------------------------------------- - // Scroll to letter - function scrollToLetter(letter: string) { - setActiveLetter(letter); - const el = letterRefs.current[letter]; - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - } +function TermSidebar({ + terms, + activeTerm, + filteredTerms, + onSelectTerm, +}: { + terms: string[]; + activeTerm: string; + filteredTerms: string[] | null; + onSelectTerm: (term: string) => void; +}) { + const activeRef = useRef(null); - // 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" }, - ); + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [activeTerm]); - const sections = document.querySelectorAll("[data-letter]"); - sections.forEach((el) => observer.observe(el)); - - return () => observer.disconnect(); - }, [sortedLetters]); + const displayTerms = filteredTerms ?? terms; 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; +
- - {/* Entries grouped by letter */} -
- {sortedLetters.length === 0 && ( -
- No entries match your search. -
+ {displayTerms.length === 0 && ( +

No matches

)} + + + ); +} - {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"} - -
+// --------------------------------------------------------------------------- +// Mobile drawer +// --------------------------------------------------------------------------- - {/* Entry cards */} -
- {filteredGroups[letter].map((entry) => ( - - -
- {highlightMatch(entry.term, query)} -
-
- - - ))} -
+function TermMobileNav({ + terms, + activeTerm, + filteredTerms, + onSelectTerm, + activeLetter, +}: { + terms: string[]; + activeTerm: string; + filteredTerms: string[] | null; + onSelectTerm: (term: string) => void; + activeLetter: string; +}) { + const [open, setOpen] = useState(false); + const displayTerms = filteredTerms ?? terms; + + return ( + + + + Terms + + + + Glossary — {activeLetter} + + + + + ); +} + +// --------------------------------------------------------------------------- +// Prev / Next nav — matches BbdNav pattern +// --------------------------------------------------------------------------- + +function TermNav({ + prev, + next, + terms, + activeTerm, + filteredTerms, + onSelectTerm, + activeLetter, +}: { + prev: string | null; + next: string | null; + terms: string[]; + activeTerm: string; + filteredTerms: string[] | null; + onSelectTerm: (term: string) => void; + activeLetter: string; +}) { + return ( +
+
+ + + {prev ? ( + + ) : ( +
+ )}
+ + {next ? ( + + ) : ( +
+ )}
); } -/** - * Highlight matching parts of plain text (for the term). - */ -function highlightMatch(text: string, query: string): React.ReactNode { - if (!query) return text; +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- - const normalizedText = normalizeForSearch(text); - const normalizedQuery = normalizeForSearch(query); - const index = normalizedText.indexOf(normalizedQuery); +export function GlossaryClient({ + entries, + activeLetter, + letters, + letterCounts, +}: Props) { + const [query, setQuery] = useState(""); + const [activeTermRaw, setActiveTerm] = useState(entries[0]?.term || ""); + const contentRef = useRef(null); - if (index === -1) return text; + // All term names + const allTerms = useMemo(() => entries.map((e) => e.term), [entries]); - // 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); + // Term → entry index map + const termIndex = useMemo(() => { + const map = new Map(); + entries.forEach((e, i) => map.set(e.term, i)); + return map; + }, [entries]); + + // Search filter + const searchResults = useMemo(() => { + if (!query.trim()) return null; + const q = normalizeForSearch(query.trim()); + return entries + .filter((e) => { + const normTerm = normalizeForSearch(e.term); + const normDef = normalizeForSearch(e.definition); + return normTerm.includes(q) || normDef.includes(q); + }) + .map((e) => e.term); + }, [entries, query]); + + // Safe activeTerm — if search is active and current term isn't in results, use first result + const activeTerm = useMemo(() => { + if ( + searchResults && + searchResults.length > 0 && + !searchResults.includes(activeTermRaw) + ) { + return searchResults[0]; + } + return activeTermRaw; + }, [searchResults, activeTermRaw]); + + // Navigation list — search results or all terms + const navList = useMemo(() => { + return searchResults ?? allTerms; + }, [searchResults, allTerms]); + + // Prev / next within nav list + const currentIdx = navList.indexOf(activeTerm); + const prev = currentIdx > 0 ? navList[currentIdx - 1] : null; + const next = + currentIdx >= 0 && currentIdx < navList.length - 1 + ? navList[currentIdx + 1] + : null; + + // Active entry data + const activeEntry = useMemo(() => { + const idx = termIndex.get(activeTerm); + return idx !== undefined ? entries[idx] : null; + }, [entries, termIndex, activeTerm]); + + const handleSelectTerm = useCallback((term: string) => { + setActiveTerm(term); + contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); return ( <> - {before} - - {match} - - {after} + {/* Sticky header */} +
+
+
+ + ← Home + +

+ Glossary: + {activeEntry?.term || activeLetter} +

+
+
+ +
+
+
+ + {/* Letter navigation row */} +
+
+ +
+
+ + {/* Sidebar + Main */} +
+ {/* Sidebar — terms for active letter */} + + + {/* Main content */} +
+ {/* Search bar */} +
+ + setQuery(e.target.value)} + className="pl-10 pr-10" + /> + {query && ( + + )} +
+ + {/* Top nav */} + + + {/* Search hint */} + {searchResults && ( +

+ {searchResults.length === 0 + ? "No entries match your search." + : `${searchResults.length} result${searchResults.length !== 1 ? "s" : ""} for "${query}"`} +

+ )} + + {/* Single entry view */} + {activeEntry ? ( + + +
+

+ {activeEntry.term} +

+
+
+ + + ) : ( +
+

No entry selected

+
+ )} + + {/* Bottom nav */} + +
+
); } - -/** - * 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(""); -} diff --git a/app/glossary/page.tsx b/app/glossary/page.tsx index a1592390..a6b70d66 100644 --- a/app/glossary/page.tsx +++ b/app/glossary/page.tsx @@ -1,8 +1,5 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { PageHeader } from "@/components/page-header"; -import { GlossaryClient } from "./glossary-client"; -import { getAllGlossaryEntries } from "@/lib/api"; +import { redirect } from "next/navigation"; import { buildOgMeta } from "@/lib/og"; export function generateMetadata(): Metadata { @@ -14,48 +11,7 @@ export function generateMetadata(): Metadata { }); } -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 ( -
- - - ← Home - -

- {data.title} -

-

{data.description}

-
- -
- -
-
- ); +// Redirect /glossary to /glossary/a +export default function GlossaryPage() { + redirect("/glossary/a"); }