589 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|