feat: disciple highlight
This commit is contained in:
@@ -5,6 +5,7 @@ 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 { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -32,6 +33,14 @@ export type DiscipleSummary = {
|
|||||||
isVerified: boolean;
|
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 LetterCount = { letter: string; count: number };
|
||||||
export type PlaceGroup = { name: string; count: number };
|
export type PlaceGroup = { name: string; count: number };
|
||||||
export type YearGroup = { year: number; count: number };
|
export type YearGroup = { year: number; count: number };
|
||||||
@@ -49,6 +58,9 @@ type Props = {
|
|||||||
years: YearGroup[];
|
years: YearGroup[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
filteredCount: 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";
|
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
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -318,6 +421,7 @@ export function DisciplesBrowser({
|
|||||||
suffixCounts,
|
suffixCounts,
|
||||||
totalCount,
|
totalCount,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
|
links,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortMode>("name-asc");
|
const [sort, setSort] = useState<SortMode>("name-asc");
|
||||||
@@ -334,6 +438,13 @@ export function DisciplesBrowser({
|
|||||||
setPage(1);
|
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
|
// Build a lookup map for letter counts
|
||||||
const countByLetter = useMemo(() => {
|
const countByLetter = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@@ -539,41 +650,13 @@ export function DisciplesBrowser({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
{pageData.map((disciple) => {
|
{pageData.map((disciple) => (
|
||||||
const locationYear = formatLocationYear(
|
<DiscipleCard
|
||||||
disciple.initiationPlace,
|
key={disciple.id}
|
||||||
disciple.initiationDate,
|
disciple={disciple}
|
||||||
);
|
link={linkByDiscipleId.get(disciple.id)}
|
||||||
|
/>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getDiscipleYears,
|
getDiscipleYears,
|
||||||
getDiscipleSuffixCounts,
|
getDiscipleSuffixCounts,
|
||||||
getFilteredDiscipleCount,
|
getFilteredDiscipleCount,
|
||||||
|
getDiscipleLinks,
|
||||||
} from "@/lib/disciples";
|
} from "@/lib/disciples";
|
||||||
import type { SuffixFilter } from "@/lib/disciples";
|
import type { SuffixFilter } from "@/lib/disciples";
|
||||||
import { DisciplesBrowser } from "./disciples-browser";
|
import { DisciplesBrowser } from "./disciples-browser";
|
||||||
@@ -62,6 +63,7 @@ export default async function DisciplesPage({
|
|||||||
places,
|
places,
|
||||||
years,
|
years,
|
||||||
suffixCounts,
|
suffixCounts,
|
||||||
|
links,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getTotalDiscipleCount(),
|
getTotalDiscipleCount(),
|
||||||
getFilteredDisciples(letterFilter, activeSuffix),
|
getFilteredDisciples(letterFilter, activeSuffix),
|
||||||
@@ -70,6 +72,7 @@ export default async function DisciplesPage({
|
|||||||
getDisciplePlaces(),
|
getDisciplePlaces(),
|
||||||
getDiscipleYears(),
|
getDiscipleYears(),
|
||||||
getDiscipleSuffixCounts(),
|
getDiscipleSuffixCounts(),
|
||||||
|
getDiscipleLinks(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Serialize dates to ISO strings for the client component
|
// Serialize dates to ISO strings for the client component
|
||||||
@@ -105,6 +108,7 @@ export default async function DisciplesPage({
|
|||||||
years={years}
|
years={years}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
filteredCount={filteredCount}
|
filteredCount={filteredCount}
|
||||||
|
links={links}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -350,6 +350,8 @@ function messageFor(n: AppNotification): string {
|
|||||||
return "replied to your comment.";
|
return "replied to your comment.";
|
||||||
case "comment_mention":
|
case "comment_mention":
|
||||||
return "mentioned you in a comment.";
|
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":
|
case "admin_message":
|
||||||
return n.body ?? "sent you a message.";
|
return n.body ?? "sent you a message.";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export default function PublicProfileLayout(props: {
|
|||||||
interactiveCounts={false}
|
interactiveCounts={false}
|
||||||
joinedAt={data.joined_at}
|
joinedAt={data.joined_at}
|
||||||
country={data.country}
|
country={data.country}
|
||||||
|
isDirectDisciple={!!data.is_direct_disciple}
|
||||||
/>
|
/>
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -174,6 +175,7 @@ export default function PublicProfileLayout(props: {
|
|||||||
enableActions
|
enableActions
|
||||||
joinedAt={data.joined_at}
|
joinedAt={data.joined_at}
|
||||||
country={data.country}
|
country={data.country}
|
||||||
|
isDirectDisciple={!!data.is_direct_disciple}
|
||||||
/>
|
/>
|
||||||
<ProfileTabs tabs={tabs} />
|
<ProfileTabs tabs={tabs} />
|
||||||
<PublicProfileContext.Provider value={data}>
|
<PublicProfileContext.Provider value={data}>
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
|
|||||||
name={user.name}
|
name={user.name}
|
||||||
handle={user.handle}
|
handle={user.handle}
|
||||||
joinedAt={user.created_at}
|
joinedAt={user.created_at}
|
||||||
|
isDirectDisciple={!!user.is_direct_disciple}
|
||||||
editHref="/settings/profile"
|
editHref="/settings/profile"
|
||||||
postCount={counts?.posts}
|
postCount={counts?.posts}
|
||||||
followerCount={counts?.followers}
|
followerCount={counts?.followers}
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ import {
|
|||||||
pushSupported,
|
pushSupported,
|
||||||
} from "@/lib/push";
|
} from "@/lib/push";
|
||||||
|
|
||||||
const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
|
const EVENTS: {
|
||||||
|
key: PrefEvent;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
/** Channels this event can actually be delivered on. Omit for all three.
|
||||||
|
* A cell outside this set renders as "—" rather than a live toggle, so we
|
||||||
|
* never show a switch that silently does nothing. */
|
||||||
|
channels?: PrefChannel[];
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "follows",
|
key: "follows",
|
||||||
label: "Follows",
|
label: "Follows",
|
||||||
@@ -42,6 +50,14 @@ const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
|
|||||||
label: "Sādhana reminders",
|
label: "Sādhana reminders",
|
||||||
description: "Daily nudges to chant your rounds and share a verse.",
|
description: "Daily nudges to chant your rounds and share a verse.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "community",
|
||||||
|
label: "Community",
|
||||||
|
description:
|
||||||
|
"Rare announcements — such as a direct disciple of Śrīla Prabhupāda joining.",
|
||||||
|
// Announcements are in-app/push only; we deliberately don't email them.
|
||||||
|
channels: ["in_app", "push"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHANNELS: { key: PrefChannel; label: string }[] = [
|
const CHANNELS: { key: PrefChannel; label: string }[] = [
|
||||||
@@ -73,7 +89,9 @@ export function NotificationMatrix() {
|
|||||||
|
|
||||||
async function toggle(event: PrefEvent, channel: PrefChannel) {
|
async function toggle(event: PrefEvent, channel: PrefChannel) {
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
const next = !prefs[event][channel];
|
// Default-on when the key is absent: prefs are stored as opt-outs, and a
|
||||||
|
// BFF that predates this category simply omits it.
|
||||||
|
const next = !(prefs[event]?.[channel] ?? true);
|
||||||
setBusyCell(`${event}/${channel}`);
|
setBusyCell(`${event}/${channel}`);
|
||||||
try {
|
try {
|
||||||
// Turning a push cell ON only helps if this device can receive push —
|
// Turning a push cell ON only helps if this device can receive push —
|
||||||
@@ -132,23 +150,34 @@ export function NotificationMatrix() {
|
|||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{CHANNELS.map((channel) => (
|
{CHANNELS.map((channel) => {
|
||||||
<div
|
const deliverable =
|
||||||
key={channel.key}
|
!event.channels || event.channels.includes(channel.key);
|
||||||
className="flex justify-center border-t border-border py-3"
|
return (
|
||||||
>
|
<div
|
||||||
{prefs ? (
|
key={channel.key}
|
||||||
<ToggleSwitch
|
className="flex justify-center border-t border-border py-3"
|
||||||
checked={prefs[event.key][channel.key]}
|
>
|
||||||
disabled={busyCell === `${event.key}/${channel.key}`}
|
{!deliverable ? (
|
||||||
onToggle={() => toggle(event.key, channel.key)}
|
<span
|
||||||
label={`${event.label} — ${channel.label}`}
|
className="text-xs text-muted-foreground"
|
||||||
/>
|
title={`${event.label} isn't delivered by ${channel.label.toLowerCase()}`}
|
||||||
) : (
|
>
|
||||||
<span className="text-xs text-muted-foreground">…</span>
|
—
|
||||||
)}
|
</span>
|
||||||
</div>
|
) : prefs ? (
|
||||||
))}
|
<ToggleSwitch
|
||||||
|
checked={prefs[event.key]?.[channel.key] ?? true}
|
||||||
|
disabled={busyCell === `${event.key}/${channel.key}`}
|
||||||
|
onToggle={() => toggle(event.key, channel.key)}
|
||||||
|
label={`${event.label} — ${channel.label}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ function messageFor(e: IncomingEvent): string {
|
|||||||
return `${who} replied to your comment`;
|
return `${who} replied to your comment`;
|
||||||
case "comment_mention":
|
case "comment_mention":
|
||||||
return `${who} mentioned you in a comment`;
|
return `${who} mentioned you in a comment`;
|
||||||
|
case "disciple_joined":
|
||||||
|
return `${who}, a direct disciple of Śrīla Prabhupāda, has joined`;
|
||||||
case "admin_message":
|
case "admin_message":
|
||||||
return e.body || "A message from Prabhupāda Says";
|
return e.body || "A message from Prabhupāda Says";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DiscipleBadge — the small circular Śrīla Prabhupāda portrait that marks a
|
||||||
|
* profile as belonging to one of his own initiated disciples.
|
||||||
|
*
|
||||||
|
* Positioned by the caller (the profile header overlaps it on the avatar's
|
||||||
|
* lower-right, like a verification tick). Tapping it toggles a tooltip reading
|
||||||
|
* "Śrīla Prabhupāda's Direct Disciple" — click rather than hover, so it works
|
||||||
|
* identically on touch, where there is no hover state.
|
||||||
|
*
|
||||||
|
* The tooltip closes on a second tap, on Escape, or on any outside click.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const LABEL = "Śrīla Prabhupāda's Direct Disciple";
|
||||||
|
|
||||||
|
export function DiscipleBadge({ className }: { className?: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
// Dismiss on outside click / Escape. Bound only while open so we don't keep
|
||||||
|
// document-level listeners around for every profile view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={wrapRef} className={cn("relative inline-flex", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-label={LABEL}
|
||||||
|
aria-expanded={open}
|
||||||
|
// Native tooltip as a desktop-hover bonus; the popover below is the
|
||||||
|
// real (touch-friendly) affordance.
|
||||||
|
title={LABEL}
|
||||||
|
className={cn(
|
||||||
|
"size-8 sm:size-9 overflow-hidden rounded-full",
|
||||||
|
"bg-card ring-2 ring-card shadow-md",
|
||||||
|
"transition-transform hover:scale-105 active:scale-95",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/prabhupada-disciple-badge.png"
|
||||||
|
alt=""
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className={cn(
|
||||||
|
"absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2",
|
||||||
|
"w-max max-w-[70vw] rounded-lg px-2.5 py-1.5",
|
||||||
|
"bg-foreground text-background text-xs font-medium text-center",
|
||||||
|
"shadow-lg animate-in fade-in-0 zoom-in-95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Caret pointing back at the badge. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 bg-foreground"
|
||||||
|
/>
|
||||||
|
{LABEL}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { Settings, X } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
||||||
import { ProfileActions } from "@/components/profile/profile-actions";
|
import { ProfileActions } from "@/components/profile/profile-actions";
|
||||||
|
import { DiscipleBadge } from "@/components/profile/disciple-badge";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import {
|
import {
|
||||||
FollowListDrawer,
|
FollowListDrawer,
|
||||||
@@ -53,6 +54,7 @@ export function ProfileHeader({
|
|||||||
enableActions = false,
|
enableActions = false,
|
||||||
country,
|
country,
|
||||||
interactiveCounts = true,
|
interactiveCounts = true,
|
||||||
|
isDirectDisciple = false,
|
||||||
}: {
|
}: {
|
||||||
avatarURL: string | null;
|
avatarURL: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -81,6 +83,10 @@ export function ProfileHeader({
|
|||||||
* click target) and the follow-list drawer isn't mounted — used on a
|
* click target) and the follow-list drawer isn't mounted — used on a
|
||||||
* blocked profile, where the lists endpoint is gated server-side. */
|
* blocked profile, where the lists endpoint is gated server-side. */
|
||||||
interactiveCounts?: boolean;
|
interactiveCounts?: boolean;
|
||||||
|
/** Set when an admin has linked this account to the disciples directory —
|
||||||
|
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
|
||||||
|
* on the avatar; tapping it explains the mark. */
|
||||||
|
isDirectDisciple?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Owner affordances ride on `editHref`; the follow-action row rides on
|
// Owner affordances ride on `editHref`; the follow-action row rides on
|
||||||
// `enableActions`. Mutually exclusive by construction.
|
// `enableActions`. Mutually exclusive by construction.
|
||||||
@@ -111,15 +117,22 @@ export function ProfileHeader({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
|
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
|
||||||
<UserAvatar
|
{/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right
|
||||||
src={avatarURL}
|
for his direct disciples (verification-tick placement). */}
|
||||||
name={name}
|
<div className="relative">
|
||||||
className="size-24 sm:size-28"
|
<UserAvatar
|
||||||
textClassName="text-3xl"
|
src={avatarURL}
|
||||||
interactive
|
name={name}
|
||||||
onClick={avatarURL ? () => setZoomOpen(true) : undefined}
|
className="size-24 sm:size-28"
|
||||||
ariaLabel={`View ${name}'s profile picture`}
|
textClassName="text-3xl"
|
||||||
/>
|
interactive
|
||||||
|
onClick={avatarURL ? () => setZoomOpen(true) : undefined}
|
||||||
|
ariaLabel={`View ${name}'s profile picture`}
|
||||||
|
/>
|
||||||
|
{isDirectDisciple ? (
|
||||||
|
<DiscipleBadge className="absolute -bottom-0.5 -right-0.5" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export type User = {
|
|||||||
* the "Change password" modal and the "Set a password" flow.
|
* the "Change password" modal and the "Set a password" flow.
|
||||||
*/
|
*/
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
|
/** True when an admin has linked this account to the disciples directory —
|
||||||
|
* one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */
|
||||||
|
is_direct_disciple?: boolean;
|
||||||
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
|
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
|
||||||
* when unknown. Captured best-effort at signup; user-editable. */
|
* when unknown. Captured best-effort at signup; user-editable. */
|
||||||
country: string | null;
|
country: string | null;
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ export type YearGroup = {
|
|||||||
|
|
||||||
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
|
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A disciple who has a platform account, linked by an admin.
|
||||||
|
*
|
||||||
|
* The disciple records live in Mongo while accounts live in Postgres, so the
|
||||||
|
* link is resolved server-side by the BFF and handed over as this flat pair.
|
||||||
|
*/
|
||||||
|
export type DiscipleLink = {
|
||||||
|
discipleId: string;
|
||||||
|
handle: string;
|
||||||
|
name: string;
|
||||||
|
avatarURL: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// --- BFF response types (snake_case from Go API) ---
|
// --- BFF response types (snake_case from Go API) ---
|
||||||
|
|
||||||
type BffDisciple = {
|
type BffDisciple = {
|
||||||
@@ -76,6 +89,42 @@ function mapDisciple(d: BffDisciple): DiscipleSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Linked platform accounts ---
|
||||||
|
|
||||||
|
type BffDiscipleLink = {
|
||||||
|
disciple_id: string;
|
||||||
|
handle: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get every disciple who has a platform account.
|
||||||
|
*
|
||||||
|
* Deliberately unpaged — only Śrīla Prabhupāda's own disciples are ever
|
||||||
|
* linked, so this is a few dozen rows at most and the browser fetches the
|
||||||
|
* whole set once to highlight the matching cards.
|
||||||
|
*
|
||||||
|
* Fails soft: the highlight is decorative, so a BFF hiccup (or a rolling
|
||||||
|
* deploy where the frontend is ahead of the API) must not take the whole
|
||||||
|
* directory page down with it. `bff` already maps 404 → null; here we also
|
||||||
|
* swallow 5xx and network errors and fall back to "no highlights".
|
||||||
|
*/
|
||||||
|
export async function getDiscipleLinks(): Promise<DiscipleLink[]> {
|
||||||
|
try {
|
||||||
|
const links = await bffList<BffDiscipleLink>("/disciples/linked");
|
||||||
|
return links.map((l) => ({
|
||||||
|
discipleId: l.disciple_id,
|
||||||
|
handle: l.handle,
|
||||||
|
name: l.name,
|
||||||
|
avatarURL: l.avatar_url ?? null,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("getDiscipleLinks failed; rendering without highlights", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Count ---
|
// --- Count ---
|
||||||
|
|
||||||
/** Get total number of disciples in the collection. */
|
/** Get total number of disciples in the collection. */
|
||||||
|
|||||||
+8
-1
@@ -192,6 +192,9 @@ export type PublicProfile = {
|
|||||||
follower_count: number;
|
follower_count: number;
|
||||||
following_count: number;
|
following_count: number;
|
||||||
relationship: RelationshipState | null;
|
relationship: RelationshipState | null;
|
||||||
|
/** True when an admin has linked this account to the disciples directory —
|
||||||
|
* i.e. one of Śrīla Prabhupāda's own initiated disciples. */
|
||||||
|
is_direct_disciple?: boolean;
|
||||||
public_collections: Collection[];
|
public_collections: Collection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -698,7 +701,11 @@ export const notifications = {
|
|||||||
|
|
||||||
// ─── Notification preferences ──────────────────────────────────────────
|
// ─── Notification preferences ──────────────────────────────────────────
|
||||||
|
|
||||||
export type PrefEvent = "follows" | "post_interactions" | "sadhna_reminders";
|
export type PrefEvent =
|
||||||
|
| "follows"
|
||||||
|
| "post_interactions"
|
||||||
|
| "sadhna_reminders"
|
||||||
|
| "community";
|
||||||
export type PrefChannel = "in_app" | "email" | "push";
|
export type PrefChannel = "in_app" | "email" | "push";
|
||||||
/** event → channel → enabled, as resolved by the BFF (defaults on). */
|
/** event → channel → enabled, as resolved by the BFF (defaults on). */
|
||||||
export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>;
|
export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Reference in New Issue
Block a user