Files
spsays-training/app/(app)/(reader)/disciples/disciples-browser.tsx
T

589 lines
18 KiB
TypeScript

"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
Search,
ArrowUpDown,
ArrowDownAZ,
ArrowUpAZ,
Calendar,
CalendarArrowDown,
MapPin,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DiscipleSummary = {
id: string;
initiatedName: string;
suffix: string;
initiationDate: string | null;
initiationPlace: string | null;
isVerified: boolean;
};
export type LetterCount = { letter: string; count: number };
export type PlaceGroup = { name: string; count: number };
export type YearGroup = { year: number; count: number };
export type SuffixCount = { suffix: string; count: number };
type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
type Props = {
initialDisciples: DiscipleSummary[];
initialLetter: string;
activeSuffix: SuffixFilter;
letterCounts: LetterCount[];
suffixCounts: SuffixCount[];
places: PlaceGroup[];
years: YearGroup[];
totalCount: number;
filteredCount: number;
};
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXY".split("");
const PAGE_SIZE = 60;
const SUFFIX_TABS: { key: SuffixFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "Dasa", label: "Dasa" },
{ key: "Devi Dasi", label: "Devi Dasi" },
];
// ---------------------------------------------------------------------------
// Diacritic-aware normalize
// ---------------------------------------------------------------------------
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
.replace(/ā/g, "a")
.replace(/ī/g, "i")
.replace(/ū/g, "u")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractYear(isoDate: string | null): number | null {
if (!isoDate) return null;
const year = parseInt(isoDate.slice(0, 4), 10);
return isNaN(year) ? null : year;
}
function formatLocationYear(
place: string | null,
date: string | null,
): string | null {
const year = extractYear(date);
const parts: string[] = [];
if (place) parts.push(place);
if (year) parts.push(String(year));
return parts.length > 0 ? parts.join(" · ") : null;
}
/**
* Build the href for letter + suffix navigation.
* Always preserves both params so the URL stays consistent.
*/
function buildHref(letter: string, suffix: SuffixFilter): string {
const params = new URLSearchParams();
params.set("letter", letter);
if (suffix !== "all") {
params.set("suffix", suffix);
}
return `/disciples?${params.toString()}`;
}
// ---------------------------------------------------------------------------
// Sort options
// ---------------------------------------------------------------------------
const SORT_OPTIONS: {
key: SortMode;
label: string;
icon: typeof ArrowDownAZ;
}[] = [
{ key: "name-asc", label: "Name A → Z", icon: ArrowDownAZ },
{ key: "name-desc", label: "Name Z → A", icon: ArrowUpAZ },
{ key: "date-asc", label: "Earliest first", icon: Calendar },
{ key: "date-desc", label: "Latest first", icon: CalendarArrowDown },
{ key: "place", label: "By place", icon: MapPin },
];
function sortDisciples(
data: DiscipleSummary[],
mode: SortMode,
): DiscipleSummary[] {
const sorted = [...data];
switch (mode) {
case "name-asc":
return sorted.sort((a, b) =>
a.initiatedName.localeCompare(b.initiatedName),
);
case "name-desc":
return sorted.sort((a, b) =>
b.initiatedName.localeCompare(a.initiatedName),
);
case "date-asc":
return sorted.sort((a, b) => {
if (!a.initiationDate && !b.initiationDate) return 0;
if (!a.initiationDate) return 1;
if (!b.initiationDate) return -1;
return a.initiationDate.localeCompare(b.initiationDate);
});
case "date-desc":
return sorted.sort((a, b) => {
if (!a.initiationDate && !b.initiationDate) return 0;
if (!a.initiationDate) return 1;
if (!b.initiationDate) return -1;
return b.initiationDate.localeCompare(a.initiationDate);
});
case "place":
return sorted.sort((a, b) => {
if (!a.initiationPlace && !b.initiationPlace) return 0;
if (!a.initiationPlace) return 1;
if (!b.initiationPlace) return -1;
const cmp = a.initiationPlace.localeCompare(b.initiationPlace);
return cmp !== 0 ? cmp : a.initiatedName.localeCompare(b.initiatedName);
});
}
}
// ---------------------------------------------------------------------------
// Sort Popover
// ---------------------------------------------------------------------------
function SortPopover({
sort,
onSortChange,
}: {
sort: SortMode;
onSortChange: (s: SortMode) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className={cn(
"flex items-center justify-center size-10 rounded-lg border transition-colors",
open
? "bg-primary/10 border-primary/30 text-primary"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title="Sort options"
aria-label="Sort options"
>
<ArrowUpDown className="size-4" />
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-52 rounded-lg border border-border bg-card shadow-lg py-1 animate-in fade-in-0 zoom-in-95">
{SORT_OPTIONS.map((opt) => {
const Icon = opt.icon;
const isActive = sort === opt.key;
return (
<button
key={opt.key}
onClick={() => {
onSortChange(opt.key);
setOpen(false);
}}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 text-sm transition-colors text-left",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{opt.label}</span>
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-3 pt-4">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page <= 1
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronLeft className="size-4" />
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page >= totalPages
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
Next
<ChevronRight className="size-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DisciplesBrowser({
initialDisciples,
initialLetter,
activeSuffix,
letterCounts,
suffixCounts,
totalCount,
filteredCount,
}: Props) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("name-asc");
const [page, setPage] = useState(1);
// Reset page when search changes
const handleQueryChange = (q: string) => {
setQuery(q);
setPage(1);
};
const handleSortChange = (s: SortMode) => {
setSort(s);
setPage(1);
};
// Build a lookup map for letter counts
const countByLetter = useMemo(() => {
const map = new Map<string, number>();
for (const lc of letterCounts) {
map.set(lc.letter.toUpperCase(), lc.count);
}
return map;
}, [letterCounts]);
// Build a lookup map for suffix counts
const countBySuffix = useMemo(() => {
const map = new Map<string, number>();
let total = 0;
for (const sc of suffixCounts) {
map.set(sc.suffix, sc.count);
total += sc.count;
}
map.set("all", total);
return map;
}, [suffixCounts]);
// Client-side search filtering + sorting
const filtered = useMemo(() => {
let data = initialDisciples;
if (query.trim()) {
const q = normalize(query.trim());
data = data.filter((d) => normalize(d.initiatedName).includes(q));
}
return sortDisciples(data, sort);
}, [initialDisciples, query, sort]);
// Pagination
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const startIdx = (safePage - 1) * PAGE_SIZE;
const pageData = filtered.slice(startIdx, startIdx + PAGE_SIZE);
// Total for letter display
const totalLetterCount = Array.from(countByLetter.values()).reduce(
(a, b) => a + b,
0,
);
return (
<div className="space-y-5">
{/* ---- Suffix Filter Tabs ---- */}
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-1 w-fit">
{SUFFIX_TABS.map((tab) => {
const count = countBySuffix.get(tab.key) ?? 0;
const isActive = activeSuffix === tab.key;
return (
<Link
key={tab.key}
href={buildHref(initialLetter, tab.key)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
<span
className={cn(
"text-xs tabular-nums",
isActive ? "text-foreground/60" : "text-muted-foreground/60",
)}
>
{count.toLocaleString()}
</span>
</Link>
);
})}
</div>
{/* ---- Letter Navigation Bar ---- */}
<nav className="flex flex-wrap gap-1 justify-center">
{/* "All" button */}
<Link
href={buildHref("All", activeSuffix)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg px-3 py-1.5 text-sm font-medium transition-colors min-w-9",
initialLetter === "All"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="leading-none">All</span>
<span
className={cn(
"text-[10px] leading-none mt-0.5",
initialLetter === "All"
? "text-primary-foreground/70"
: "text-muted-foreground/60",
)}
>
{totalLetterCount.toLocaleString()}
</span>
</Link>
{/* Divider */}
<div className="w-px bg-border self-stretch mx-0.5" />
{/* Letter buttons */}
{LETTERS.map((letter) => {
const count = countByLetter.get(letter) ?? 0;
const isActive =
letter.toUpperCase() === initialLetter.toUpperCase() &&
initialLetter !== "All";
const isEmpty = count === 0;
return (
<Link
key={letter}
href={buildHref(letter, activeSuffix)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors min-w-9",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: isEmpty
? "text-muted-foreground/40 cursor-default"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="leading-none">{letter}</span>
<span
className={cn(
"text-[10px] leading-none mt-0.5",
isActive
? "text-primary-foreground/70"
: "text-muted-foreground/60",
)}
>
{count}
</span>
</Link>
);
})}
</nav>
{/* ---- Search Bar + Sort ---- */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search initiated names..."
value={query}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleQueryChange(e.target.value)
}
className="pl-9"
/>
</div>
<SortPopover sort={sort} onSortChange={handleSortChange} />
</div>
{/* ---- Summary Stats ---- */}
<p className="text-sm text-muted-foreground">
{query.trim() ? (
<>
Found{" "}
<span className="font-medium text-foreground">
{filtered.length}
</span>{" "}
of{" "}
<span className="font-medium text-foreground">
{initialDisciples.length}
</span>{" "}
disciples
</>
) : (
<>
Showing{" "}
<span className="font-medium text-foreground">
{filteredCount.toLocaleString()}
</span>{" "}
{filteredCount === 1 ? "disciple" : "disciples"}
{filteredCount !== totalCount && (
<>
{" "}
of{" "}
<span className="font-medium text-foreground">
{totalCount.toLocaleString()}
</span>{" "}
total
</>
)}
</>
)}
</p>
{/* ---- Results Grid ---- */}
{pageData.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">No disciples found</p>
<p className="text-sm mt-1">
{query.trim()
? "Try a different search term"
: `No disciples found for the current filter`}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{pageData.map((disciple) => {
const locationYear = formatLocationYear(
disciple.initiationPlace,
disciple.initiationDate,
);
return (
<Card
key={disciple.id}
size="sm"
className="transition-all hover:shadow-md hover:ring-foreground/20"
>
<CardContent className="py-3 px-4 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p className="font-semibold text-foreground leading-snug truncate">
{disciple.initiatedName}
</p>
{disciple.suffix && (
<Badge
variant="secondary"
className="shrink-0 text-[10px]"
>
{disciple.suffix}
</Badge>
)}
</div>
{locationYear && (
<p className="text-xs text-muted-foreground truncate">
{locationYear}
</p>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* ---- Pagination ---- */}
<Pagination
page={safePage}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}