feat: add disciples list
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import {
|
||||
getTotalDiscipleCount,
|
||||
getFilteredDisciples,
|
||||
getDiscipleLetterCounts,
|
||||
getDisciplePlaces,
|
||||
getDiscipleYears,
|
||||
getDiscipleSuffixCounts,
|
||||
getFilteredDiscipleCount,
|
||||
} from "@/lib/disciples";
|
||||
import type { SuffixFilter } from "@/lib/disciples";
|
||||
import { DisciplesBrowser } from "./disciples-browser";
|
||||
|
||||
const VALID_SUFFIXES: SuffixFilter[] = ["all", "Dasa", "Devi Dasi"];
|
||||
|
||||
function parseSuffix(raw: string | undefined): SuffixFilter {
|
||||
if (!raw) return "all";
|
||||
// Handle URL-encoded "Devi Dasi" → "Devi+Dasi" or "Devi%20Dasi"
|
||||
const decoded = decodeURIComponent(raw.replace(/\+/g, " "));
|
||||
if (VALID_SUFFIXES.includes(decoded as SuffixFilter)) {
|
||||
return decoded as SuffixFilter;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function parseLetter(raw: string | undefined): string {
|
||||
if (!raw) return "A";
|
||||
const cleaned = raw.trim();
|
||||
if (cleaned.toLowerCase() === "all") return "All";
|
||||
// Single uppercase letter
|
||||
const ch = cleaned.charAt(0).toUpperCase();
|
||||
if (ch >= "A" && ch <= "Z") return ch;
|
||||
return "A";
|
||||
}
|
||||
|
||||
export default async function DisciplesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ letter?: string; suffix?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const activeLetter = parseLetter(params.letter);
|
||||
const activeSuffix = parseSuffix(params.suffix);
|
||||
|
||||
// letter=null means "All" — no letter filter
|
||||
const letterFilter = activeLetter === "All" ? null : activeLetter;
|
||||
|
||||
const [
|
||||
totalCount,
|
||||
disciples,
|
||||
filteredCount,
|
||||
letterCounts,
|
||||
places,
|
||||
years,
|
||||
suffixCounts,
|
||||
] = await Promise.all([
|
||||
getTotalDiscipleCount(),
|
||||
getFilteredDisciples(letterFilter, activeSuffix),
|
||||
getFilteredDiscipleCount(letterFilter, activeSuffix),
|
||||
getDiscipleLetterCounts(),
|
||||
getDisciplePlaces(),
|
||||
getDiscipleYears(),
|
||||
getDiscipleSuffixCounts(),
|
||||
]);
|
||||
|
||||
// Serialize dates to ISO strings for the client component
|
||||
const serializedDisciples = disciples.map((d) => ({
|
||||
...d,
|
||||
initiationDate: d.initiationDate ? d.initiationDate.toISOString() : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Disciples</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{totalCount.toLocaleString()} disciples initiated by Śrīla Prabhupāda
|
||||
(1966–1977)
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<DisciplesBrowser
|
||||
initialDisciples={serializedDisciples}
|
||||
initialLetter={activeLetter}
|
||||
activeSuffix={activeSuffix}
|
||||
letterCounts={letterCounts}
|
||||
suffixCounts={suffixCounts}
|
||||
places={places}
|
||||
years={years}
|
||||
totalCount={totalCount}
|
||||
filteredCount={filteredCount}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+210
-40
@@ -1,15 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
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, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUpDown,
|
||||
ArrowDownAZ,
|
||||
ArrowUpAZ,
|
||||
Hash,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -19,6 +25,8 @@ export type YearGroup = { year: number; count: number };
|
||||
export type RecipientGroup = { name: string; count: number };
|
||||
export type LocationGroup = { name: string; count: number };
|
||||
|
||||
type SortMode = "count" | "asc" | "desc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diacritic-aware normalize (matches books-grid pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,13 +54,103 @@ function normalize(text: string): string {
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort Popover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SORT_OPTIONS: { key: SortMode; label: string; icon: typeof Hash }[] = [
|
||||
{ key: "count", label: "Most letters first", icon: Hash },
|
||||
{ key: "asc", label: "A → Z", icon: ArrowDownAZ },
|
||||
{ key: "desc", label: "Z → A", icon: ArrowUpAZ },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS_YEAR: { key: SortMode; label: string; icon: typeof Hash }[] =
|
||||
[
|
||||
{ key: "count", label: "Most letters first", icon: Hash },
|
||||
{ key: "asc", label: "Oldest first", icon: ArrowDownAZ },
|
||||
{ key: "desc", label: "Newest first", icon: ArrowUpAZ },
|
||||
];
|
||||
|
||||
function SortPopover({
|
||||
sort,
|
||||
onSortChange,
|
||||
isYear,
|
||||
}: {
|
||||
sort: SortMode;
|
||||
onSortChange: (s: SortMode) => void;
|
||||
isYear?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
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]);
|
||||
|
||||
const options = isYear ? SORT_OPTIONS_YEAR : SORT_OPTIONS;
|
||||
|
||||
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">
|
||||
{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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE_RECIPIENTS = 50;
|
||||
const PAGE_SIZE_LOCATIONS = 60;
|
||||
const PAGE_SIZE_YEARS = 100; // years are small cards, show all usually
|
||||
const PAGE_SIZE_YEARS = 100;
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
@@ -102,6 +200,49 @@ function Pagination({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sorting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sortYears(data: YearGroup[], mode: SortMode): YearGroup[] {
|
||||
const sorted = [...data];
|
||||
switch (mode) {
|
||||
case "count":
|
||||
return sorted.sort((a, b) => b.count - a.count);
|
||||
case "asc":
|
||||
return sorted.sort((a, b) => a.year - b.year);
|
||||
case "desc":
|
||||
return sorted.sort((a, b) => b.year - a.year);
|
||||
}
|
||||
}
|
||||
|
||||
function sortRecipients(
|
||||
data: RecipientGroup[],
|
||||
mode: SortMode,
|
||||
): RecipientGroup[] {
|
||||
const sorted = [...data];
|
||||
switch (mode) {
|
||||
case "count":
|
||||
return sorted.sort((a, b) => b.count - a.count);
|
||||
case "asc":
|
||||
return sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
case "desc":
|
||||
return sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
}
|
||||
|
||||
function sortLocations(data: LocationGroup[], mode: SortMode): LocationGroup[] {
|
||||
const sorted = [...data];
|
||||
switch (mode) {
|
||||
case "count":
|
||||
return sorted.sort((a, b) => b.count - a.count);
|
||||
case "asc":
|
||||
return sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
case "desc":
|
||||
return sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,32 +260,57 @@ export function LettersBrowser({
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortYear, setSortYear] = useState<SortMode>("asc");
|
||||
const [sortRecipient, setSortRecipient] = useState<SortMode>("count");
|
||||
const [sortLocation, setSortLocation] = useState<SortMode>("count");
|
||||
|
||||
// Reset page when query or view changes
|
||||
const handleQueryChange = (q: string) => {
|
||||
setQuery(q);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// --- Filtered data ---
|
||||
const handleSortChange = (mode: SortMode) => {
|
||||
if (currentView === "year") setSortYear(mode);
|
||||
else if (currentView === "recipient") setSortRecipient(mode);
|
||||
else setSortLocation(mode);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const currentSort =
|
||||
currentView === "year"
|
||||
? sortYear
|
||||
: currentView === "recipient"
|
||||
? sortRecipient
|
||||
: sortLocation;
|
||||
|
||||
// --- Filtered + sorted data ---
|
||||
|
||||
const filteredYears = useMemo(() => {
|
||||
if (!query.trim()) return years;
|
||||
const q = normalize(query.trim());
|
||||
return years.filter((yg) => String(yg.year).includes(q));
|
||||
}, [years, query]);
|
||||
let data = years;
|
||||
if (query.trim()) {
|
||||
const q = normalize(query.trim());
|
||||
data = data.filter((yg) => String(yg.year).includes(q));
|
||||
}
|
||||
return sortYears(data, sortYear);
|
||||
}, [years, query, sortYear]);
|
||||
|
||||
const filteredRecipients = useMemo(() => {
|
||||
if (!query.trim()) return recipients;
|
||||
const q = normalize(query.trim());
|
||||
return recipients.filter((rg) => normalize(rg.name).includes(q));
|
||||
}, [recipients, query]);
|
||||
let data = recipients;
|
||||
if (query.trim()) {
|
||||
const q = normalize(query.trim());
|
||||
data = data.filter((rg) => normalize(rg.name).includes(q));
|
||||
}
|
||||
return sortRecipients(data, sortRecipient);
|
||||
}, [recipients, query, sortRecipient]);
|
||||
|
||||
const filteredLocations = useMemo(() => {
|
||||
if (!query.trim()) return locations;
|
||||
const q = normalize(query.trim());
|
||||
return locations.filter((lg) => normalize(lg.name).includes(q));
|
||||
}, [locations, query]);
|
||||
let data = locations;
|
||||
if (query.trim()) {
|
||||
const q = normalize(query.trim());
|
||||
data = data.filter((lg) => normalize(lg.name).includes(q));
|
||||
}
|
||||
return sortLocations(data, sortLocation);
|
||||
}, [locations, query, sortLocation]);
|
||||
|
||||
// --- Paginated data ---
|
||||
|
||||
@@ -184,15 +350,22 @@ export function LettersBrowser({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
className="pl-9"
|
||||
{/* Search + 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={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<SortPopover
|
||||
sort={currentSort}
|
||||
onSortChange={handleSortChange}
|
||||
isYear={currentView === "year"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -212,9 +385,7 @@ export function LettersBrowser({
|
||||
<Link key={yg.year} href={`/letters/year/${yg.year}`}>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
|
||||
<CardContent className="py-4 px-4 text-center">
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{yg.year}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">{yg.year}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{yg.count} {yg.count === 1 ? "letter" : "letters"}
|
||||
</p>
|
||||
@@ -227,22 +398,21 @@ export function LettersBrowser({
|
||||
|
||||
{/* Recipient view */}
|
||||
{currentView === "recipient" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{(filteredRecipients.slice(startIdx, endIdx) as RecipientGroup[]).map(
|
||||
(rg) => (
|
||||
<Link
|
||||
key={rg.name}
|
||||
href={`/letters/recipient/${encodeURIComponent(rg.name)}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
|
||||
<CardContent className="py-3 px-5 flex items-center justify-between">
|
||||
<CardContent className="py-4 px-4 text-center">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{rg.name}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs shrink-0 ml-3">
|
||||
{rg.count}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rg.count} {rg.count === 1 ? "letter" : "letters"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -253,7 +423,7 @@ export function LettersBrowser({
|
||||
|
||||
{/* Location view */}
|
||||
{currentView === "location" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{(filteredLocations.slice(startIdx, endIdx) as LocationGroup[]).map(
|
||||
(lg) => (
|
||||
<Link
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
NotebookPen,
|
||||
Newspaper,
|
||||
Mail,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
const allowRtf = process.env.ALLOW_RTF === "true";
|
||||
@@ -140,6 +141,33 @@ export default function Home() {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Disciples */}
|
||||
<Link href="/disciples" className="block group h-full">
|
||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||
<Users className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
Disciples
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
4,700+ initiated disciples
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A directory of disciples initiated by Śrīla Prabhupāda between
|
||||
1966 and 1977 — browse by name, initiation place, or year.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Journals */}
|
||||
<Link href="/journals" className="block group h-full">
|
||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||
|
||||
Reference in New Issue
Block a user