feat: disciple highlight

This commit is contained in:
gosangam
2026-08-07 22:47:04 +05:30
parent 1984797ad3
commit 274c735b4d
13 changed files with 348 additions and 64 deletions
@@ -5,6 +5,7 @@ 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 { UserAvatar } from "@/components/ui/user-avatar";
import { cn } from "@/lib/utils";
import {
Search,
@@ -32,6 +33,14 @@ export type DiscipleSummary = {
isVerified: boolean;
};
/** A disciple who has a platform account (linked by an admin). */
export type DiscipleLink = {
discipleId: string;
handle: string;
name: string;
avatarURL: string | null;
};
export type LetterCount = { letter: string; count: number };
export type PlaceGroup = { name: string; count: number };
export type YearGroup = { year: number; count: number };
@@ -49,6 +58,9 @@ type Props = {
years: YearGroup[];
totalCount: number;
filteredCount: number;
/** Disciples who have joined the platform — their cards are highlighted and
* link through to the profile. Small set (dozens at most). */
links: DiscipleLink[];
};
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
@@ -306,6 +318,97 @@ function Pagination({
);
}
// ---------------------------------------------------------------------------
// Disciple card
// ---------------------------------------------------------------------------
/**
* One disciple in the grid.
*
* When `link` is set the disciple has joined the platform, and the card is
* highlighted and becomes a link to their profile. The highlight is drawn
* entirely from theme tokens (`--primary`, `--ring`) rather than fixed colours,
* so it picks up whichever palette the reader has chosen — peacock green under
* Tulasī Garden, deep indigo under Yamunā Mist, and so on.
*/
function DiscipleCard({
disciple,
link,
}: {
disciple: DiscipleSummary;
link?: DiscipleLink;
}) {
const locationYear = formatLocationYear(
disciple.initiationPlace,
disciple.initiationDate,
);
const card = (
<Card
size="sm"
className={cn(
"h-full transition-all",
link
? // On the platform: tinted surface + primary ring so it reads as
// "one of us" at a glance across the grid.
"bg-primary/[0.06] ring-2 ring-primary/40 hover:ring-primary/70 hover:shadow-md"
: "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={cn(
"font-semibold leading-snug truncate",
link ? "text-primary" : "text-foreground",
)}
>
{disciple.initiatedName}
</p>
{disciple.suffix && (
<Badge
variant={link ? "default" : "secondary"}
className="shrink-0 text-[10px]"
>
{disciple.suffix}
</Badge>
)}
</div>
{locationYear && (
<p className="text-xs text-muted-foreground truncate">
{locationYear}
</p>
)}
{link && (
<div className="flex items-center gap-1.5 pt-0.5">
<UserAvatar
src={link.avatarURL}
name={link.name}
className="size-5"
textClassName="text-[9px]"
/>
<span className="truncate text-xs font-medium text-primary">
@{link.handle}
</span>
</div>
)}
</CardContent>
</Card>
);
if (!link) return card;
return (
<Link
href={`/u/${link.handle}`}
aria-label={`${disciple.initiatedName} — view @${link.handle}'s profile`}
className="block rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{card}
</Link>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -318,6 +421,7 @@ export function DisciplesBrowser({
suffixCounts,
totalCount,
filteredCount,
links,
}: Props) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("name-asc");
@@ -334,6 +438,13 @@ export function DisciplesBrowser({
setPage(1);
};
// Disciple id → their platform account, for the highlight + profile link.
const linkByDiscipleId = useMemo(() => {
const map = new Map<string, DiscipleLink>();
for (const l of links) map.set(l.discipleId, l);
return map;
}, [links]);
// Build a lookup map for letter counts
const countByLetter = useMemo(() => {
const map = new Map<string, number>();
@@ -539,41 +650,13 @@ export function DisciplesBrowser({
</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>
);
})}
{pageData.map((disciple) => (
<DiscipleCard
key={disciple.id}
disciple={disciple}
link={linkByDiscipleId.get(disciple.id)}
/>
))}
</div>
)}
+4
View File
@@ -12,6 +12,7 @@ import {
getDiscipleYears,
getDiscipleSuffixCounts,
getFilteredDiscipleCount,
getDiscipleLinks,
} from "@/lib/disciples";
import type { SuffixFilter } from "@/lib/disciples";
import { DisciplesBrowser } from "./disciples-browser";
@@ -62,6 +63,7 @@ export default async function DisciplesPage({
places,
years,
suffixCounts,
links,
] = await Promise.all([
getTotalDiscipleCount(),
getFilteredDisciples(letterFilter, activeSuffix),
@@ -70,6 +72,7 @@ export default async function DisciplesPage({
getDisciplePlaces(),
getDiscipleYears(),
getDiscipleSuffixCounts(),
getDiscipleLinks(),
]);
// Serialize dates to ISO strings for the client component
@@ -105,6 +108,7 @@ export default async function DisciplesPage({
years={years}
totalCount={totalCount}
filteredCount={filteredCount}
links={links}
/>
</main>
</div>
+2
View File
@@ -350,6 +350,8 @@ function messageFor(n: AppNotification): string {
return "replied to your comment.";
case "comment_mention":
return "mentioned you in a comment.";
case "disciple_joined":
return "— a direct disciple of Śrīla Prabhupāda — has joined Prabhupāda Says.";
case "admin_message":
return n.body ?? "sent you a message.";
default:
+2
View File
@@ -129,6 +129,7 @@ export default function PublicProfileLayout(props: {
interactiveCounts={false}
joinedAt={data.joined_at}
country={data.country}
isDirectDisciple={!!data.is_direct_disciple}
/>
<div className="py-12 text-center">
<p className="text-sm text-muted-foreground">
@@ -174,6 +175,7 @@ export default function PublicProfileLayout(props: {
enableActions
joinedAt={data.joined_at}
country={data.country}
isDirectDisciple={!!data.is_direct_disciple}
/>
<ProfileTabs tabs={tabs} />
<PublicProfileContext.Provider value={data}>
+1
View File
@@ -140,6 +140,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
name={user.name}
handle={user.handle}
joinedAt={user.created_at}
isDirectDisciple={!!user.is_direct_disciple}
editHref="/settings/profile"
postCount={counts?.posts}
followerCount={counts?.followers}