8 Commits
Author SHA1 Message Date
gosangam 3257efb06a feat: private r2 bucket and secure sockets 2026-08-29 01:21:25 +05:30
gosangam 6083a485de fix: carousel image lightbox 2026-08-09 12:40:50 +05:30
gosangam baf36d3d67 fix: carousel image lightbox 2026-08-09 11:11:35 +05:30
gosangam df49e7afcc fix: new user scroll fix, add image lightbox 2026-08-09 10:58:49 +05:30
gosangam ce373e3e03 fix: profile bio 2026-08-08 00:49:39 +05:30
gosangam 9b7758da99 fix: profile bio 2026-08-08 00:12:34 +05:30
gosangam 6ff29db68a feat: profile bio 2026-08-07 23:47:22 +05:30
gosangam 274c735b4d feat: disciple highlight 2026-08-07 22:47:04 +05:30
23 changed files with 991 additions and 115 deletions
@@ -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>
)} )}
+4
View File
@@ -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>
+2
View File
@@ -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:
+4
View File
@@ -129,6 +129,8 @@ 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}
bio={data.bio}
/> />
<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 +176,8 @@ 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}
bio={data.bio}
/> />
<ProfileTabs tabs={tabs} /> <ProfileTabs tabs={tabs} />
<PublicProfileContext.Provider value={data}> <PublicProfileContext.Provider value={data}>
+2
View File
@@ -140,6 +140,8 @@ 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}
bio={user.bio}
editHref="/settings/profile" editHref="/settings/profile"
postCount={counts?.posts} postCount={counts?.posts}
followerCount={counts?.followers} followerCount={counts?.followers}
+8 -3
View File
@@ -89,8 +89,13 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
</div> </div>
</div> </div>
{/* Bleed to the section edges (-mx-5) but keep a px-5 gutter so the {/* Bleed to the page edges, with a matching inner gutter so the first and
first/last tiles have breathing room as they scroll past. */} last tiles still have breathing room as they scroll past.
The negative margin MUST match the feed container's horizontal padding
(`main` is `px-4 sm:px-6`). It was `-mx-5` (20px) against a 16px
mobile padding, so the row hung 4px past each edge and gave the whole
document a horizontal scrollbar. Keep these three in lockstep with the
page padding if it ever changes. */}
<div <div
ref={scrollRef} ref={scrollRef}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
@@ -98,7 +103,7 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerUp} onPointerCancel={onPointerUp}
onClickCapture={onClickCapture} onClickCapture={onClickCapture}
className="-mx-5 flex gap-3 overflow-x-auto px-5 py-1 snap-x scroll-px-5 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" className="-mx-4 sm:-mx-6 flex gap-3 overflow-x-auto px-4 sm:px-6 py-1 snap-x scroll-px-4 sm:scroll-px-6 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
> >
{users.map((u) => ( {users.map((u) => (
<Link <Link
@@ -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> &mdash;
)} </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>
@@ -5,13 +5,18 @@
* WebSocket connection to our Soketi (Pusher-compatible) server so the * WebSocket connection to our Soketi (Pusher-compatible) server so the
* bell badge + a toast update in realtime. * bell badge + a toast update in realtime.
* *
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and * Transport: subscribes to the PRIVATE channel `private-notifications.{userId}`
* binds the `notification` event the BFF triggers on each follow-request / * and binds the `notification` event the BFF triggers on each follow-request /
* follow-accept. The event is a lightweight nudge — we bump the unread * follow-accept. The event is a lightweight nudge — we bump the unread
* count locally and toast — while the authoritative list/count still come * count locally and toast — while the authoritative list/count still come
* from the REST endpoints. (Public channel for now; once Soketi App auth * from the REST endpoints.
* is configured, switch to a `private-` channel + a /broadcasting/auth *
* endpoint — only the channel name and a Pusher `authEndpoint` change.) * The channel is private so a subscription requires a signature the BFF only
* issues to that user; on the old public channel anyone who knew a user's id
* could listen in. Signing goes through `channelAuthorizer` (see
* lib/realtime.ts). The BFF still publishes to the old public channel too, so
* a service-worker-cached bundle running this file's previous version keeps
* working until it updates.
* *
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent * Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
* realtime silently disables and the bell falls back to its fetched count. * realtime silently disables and the bell falls back to its fetched count.
@@ -26,6 +31,7 @@ import {
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { channelAuthorizer } from "@/lib/realtime";
import { feed, notifications } from "@/lib/social"; import { feed, notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna"; import { sadhna } from "@/lib/sadhna";
@@ -70,6 +76,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:
@@ -143,7 +151,7 @@ export function NotificationsProvider({
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443"); const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS = const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${userId}`; const channelName = `private-notifications.${userId}`;
let cancelled = false; let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the // Dynamic import keeps pusher-js out of SSR (it touches window) and the
@@ -160,8 +168,16 @@ export function NotificationsProvider({
disableStats: true, disableStats: true,
// Ignored when wsHost is set, but required by the type. // Ignored when wsHost is set, but required by the type.
cluster: "", cluster: "",
// Private channels are refused without a signature from the BFF.
channelAuthorization: { customHandler: channelAuthorizer },
}); });
const channel = pusher.subscribe(channelName); const channel = pusher.subscribe(channelName);
// Authorization can legitimately fail (expired session, realtime not
// configured). Realtime is a nudge, not the source of truth, so degrade
// quietly — the bell keeps working off its fetched count.
channel.bind("pusher:subscription_error", (status: unknown) => {
console.warn("notifications: realtime subscription failed", status);
});
channel.bind("notification", (data: IncomingEvent) => { channel.bind("notification", (data: IncomingEvent) => {
setUnread((u) => u + 1); setUnread((u) => u + 1);
setLastEventAt(Date.now()); setLastEventAt(Date.now());
+89
View File
@@ -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>
);
}
+53
View File
@@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MentionTextarea } from "@/components/social/mention-textarea";
import { cn } from "@/lib/utils";
/** Profile bio cap. Mirrors the BFF's service.MaxBioRunes. */
const BIO_MAX = 200;
import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
@@ -70,6 +75,7 @@ export function ProfileEditForm({
const [name, setName] = useState(user.name); const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle); const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url); const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [bio, setBio] = useState<string>(user.bio ?? "");
const [country, setCountry] = useState<string>(user.country ?? ""); const [country, setCountry] = useState<string>(user.country ?? "");
// Devotional details. Each "" means "not specified". // Devotional details. Each "" means "not specified".
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? ""); const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
@@ -92,11 +98,15 @@ export function ProfileEditForm({
const origRounds = const origRounds =
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : ""; user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
const effInitiatedAt = initiated ? initiatedAt : ""; const effInitiatedAt = initiated ? initiatedAt : "";
// Spread, not .length — counts code points so emoji and Devanāgarī match
// the server's rune-based cap instead of over-counting UTF-16 units.
const bioCount = [...bio.trim()].length;
const dirty = const dirty =
name.trim() !== user.name || name.trim() !== user.name ||
handle.trim() !== user.handle || handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url || avatarUrl !== user.avatar_url ||
bio.trim() !== (user.bio ?? "") ||
country !== (user.country ?? "") || country !== (user.country ?? "") ||
sampradaya !== (user.sampradaya ?? "") || sampradaya !== (user.sampradaya ?? "") ||
gender !== (user.gender ?? "") || gender !== (user.gender ?? "") ||
@@ -108,6 +118,13 @@ export function ProfileEditForm({
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
// Counted in code points to match the BFF's rune-based cap, so a bio in
// Devanāgarī is measured the same way as one in Latin script.
if ([...bio.trim()].length > BIO_MAX) {
setError(`Bio must be ${BIO_MAX} characters or fewer.`);
return;
}
// Client-side guard for the round count (the BFF re-checks 1–1008). // Client-side guard for the round count (the BFF re-checks 1–1008).
if (rounds !== "") { if (rounds !== "") {
const n = Number(rounds); const n = Number(rounds);
@@ -126,6 +143,8 @@ export function ProfileEditForm({
if (name.trim() !== user.name) patch.name = name.trim(); if (name.trim() !== user.name) patch.name = name.trim();
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? ""; if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
if (handle.trim() !== user.handle) patch.handle = handle.trim(); if (handle.trim() !== user.handle) patch.handle = handle.trim();
// "" clears the bio.
if (bio.trim() !== (user.bio ?? "")) patch.bio = bio.trim();
if (country !== (user.country ?? "")) patch.country = country; if (country !== (user.country ?? "")) patch.country = country;
// "" clears the lineage; a code sets it. // "" clears the lineage; a code sets it.
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya; if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
@@ -221,6 +240,40 @@ export function ProfileEditForm({
/> />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="profile-bio">Bio</Label>
{/* MentionTextarea (shared with the post composer and comment box)
gives us the debounced "@" suggestion list for free — typing an
@token opens a people picker prioritised following → followers. */}
<MentionTextarea
id="profile-bio"
value={bio}
onChange={setBio}
rows={3}
maxLength={BIO_MAX}
placeholder="A little about you… mention devotees with @handle"
// Mirrors the <Input> primitive's chrome so the bio sits flush
// with the other fields (auto height instead of the fixed h-10).
className="w-full min-w-0 resize-y rounded-lg border border-input bg-card px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-card"
/>
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
Type <span className="font-medium text-foreground">@</span> to
mention another devotee — mentions become links on your profile.
</p>
<span
className={cn(
"shrink-0 text-xs tabular-nums",
bioCount > BIO_MAX
? "font-medium text-destructive"
: "text-muted-foreground",
)}
>
{bioCount}/{BIO_MAX}
</span>
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label> <Label htmlFor="profile-handle">Handle</Label>
{/* "@" sits inside the field as a fixed, non-editable prefix — {/* "@" sits inside the field as a fixed, non-editable prefix —
+40 -9
View File
@@ -34,6 +34,8 @@ 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 { MentionText } from "@/components/social/mention-text";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { import {
FollowListDrawer, FollowListDrawer,
@@ -53,6 +55,8 @@ export function ProfileHeader({
enableActions = false, enableActions = false,
country, country,
interactiveCounts = true, interactiveCounts = true,
isDirectDisciple = false,
bio,
}: { }: {
avatarURL: string | null; avatarURL: string | null;
name: string; name: string;
@@ -81,6 +85,14 @@ 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;
/** The profile's bio. Plain text — `@handle` mentions are linkified on
* render. Sits between the stat line and the action buttons, centred like
* the rest of the header. Omitted entirely when empty. */
bio?: string | null;
}) { }) {
// 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 +123,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">
@@ -158,6 +177,18 @@ export function ProfileHeader({
</div> </div>
</div> </div>
{/* Bio — between the stats and the action buttons, centred to match the
rest of the header. `self-stretch` overrides the header's
`items-center` so the text box spans the full column and each
wrapped line centres against the same axis as the name and stats
(without it the box would shrink to its content and centre oddly). */}
{bio?.trim() ? (
<MentionText
text={bio.trim()}
className="self-stretch text-center text-sm sm:text-[15px] leading-relaxed text-foreground"
/>
) : null}
{showOwnerControls ? ( {showOwnerControls ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
+2 -21
View File
@@ -18,29 +18,10 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social"; import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { renderMentions } from "@/components/social/mention-text";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { CommentMenu } from "./comment-menu"; import { CommentMenu } from "./comment-menu";
// Split a comment body into text + clickable @handle links. The capturing
// group keeps the delimiters so they can be turned into profile links.
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
function renderBody(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function CommentRow({ export function CommentRow({
comment, comment,
viewerId, viewerId,
@@ -172,7 +153,7 @@ export function CommentRow({
@{comment.reply_to_handle}{" "} @{comment.reply_to_handle}{" "}
</span> </span>
) : null} ) : null}
{renderBody(comment.body)} {renderMentions(comment.body)}
</p> </p>
{/* Inline action row */} {/* Inline action row */}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* MentionText — renders user-authored plain text with `@handle` tokens turned
* into profile links.
*
* The single place that decides what a mention looks like. Shared by comment
* bodies and profile bios so the two can't drift apart, and so the
* "@handle" grammar lives next to the regex the composer
* (`MentionTextarea`) uses to detect it.
*
* Input is always PLAIN TEXT, never HTML — we split on the token and emit
* React nodes, so nothing user-supplied is ever interpreted as markup.
* `whitespace-pre-line` on the wrapper preserves the author's line breaks
* (bios are multi-line) without needing <br> injection.
*/
import Link from "next/link";
import { cn } from "@/lib/utils";
/** Capturing group keeps the delimiters, so split() yields text and tokens
* alternately. Handle charset matches MentionTextarea's MENTION_RE. */
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
/** Split text into plain runs + clickable @handle links. Exported for callers
* that need the nodes without the wrapper element. */
export function renderMentions(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function MentionText({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<span className={cn("whitespace-pre-line", className)}>
{renderMentions(text)}
</span>
);
}
+59 -9
View File
@@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8;
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/; const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
type Mention = { start: number; query: string }; type Mention = { start: number; query: string };
type PanelPos = { left: number; bottom: number; width: number }; /** Exactly one of `top`/`bottom` is set — see the flip logic in detect(). */
type PanelPos = { left: number; width: number; top?: number; bottom?: number };
/** Keep in sync with the panel's `max-h-56` (14rem). Used to decide whether
* the suggestion list fits above the field or has to flip below it. */
const PANEL_MAX_H = 224;
export const MentionTextarea = forwardRef< export const MentionTextarea = forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
{ {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
/** Called on Enter (no Shift) when the suggestion list is closed. */ /** Called on Enter (no Shift) when the suggestion list is closed. Omit for
* multi-line fields — without it, Enter inserts a newline as usual. */
onSubmit?: () => void; onSubmit?: () => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
maxLength?: number; maxLength?: number;
rows?: number; rows?: number;
/** Forwarded to the <textarea> so a <Label htmlFor> can address it. */
id?: string;
} }
>(function MentionTextarea( >(function MentionTextarea(
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 }, { value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
ref, ref,
) { ) {
const innerRef = useRef<HTMLTextAreaElement>(null); const innerRef = useRef<HTMLTextAreaElement>(null);
@@ -61,6 +69,7 @@ export const MentionTextarea = forwardRef<
const [pos, setPos] = useState<PanelPos | null>(null); const [pos, setPos] = useState<PanelPos | null>(null);
const reqRef = useRef(0); const reqRef = useRef(0);
const blurTimer = useRef<number | null>(null); const blurTimer = useRef<number | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// Detect the active @token from the text up to the caret. // Detect the active @token from the text up to the caret.
function detect(el: HTMLTextAreaElement) { function detect(el: HTMLTextAreaElement) {
@@ -70,14 +79,36 @@ export const MentionTextarea = forwardRef<
setMention(null); setMention(null);
return; return;
} }
setMention({ start: caret - m[2].length - 1, query: m[2] }); const next = { start: caret - m[2].length - 1, query: m[2] };
setActive(0);
// Reposition unconditionally — the field grows as text wraps, so the panel
// has to stay pinned to it even when the token itself hasn't changed.
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
// Prefer opening ABOVE the field: that's what the comment composer needs
// (it sits at the bottom of a drawer, often above the on-screen keyboard).
// But flip BELOW when the field is too near the top of the viewport for the
// panel to fit — otherwise the list renders off-screen and the feature
// looks broken (e.g. the bio field high up on the profile editor).
const fitsAbove = r.top >= PANEL_MAX_H + 8;
setPos({ setPos({
left: r.left, left: r.left,
bottom: window.innerHeight - r.top + 6,
width: r.width, width: r.width,
...(fitsAbove
? { bottom: window.innerHeight - r.top + 6 }
: { top: r.bottom + 6 }),
}); });
// Bail out if the token is byte-for-byte what we already have. detect()
// also runs on keyup and click, so re-seeding an identical mention would:
// • reset `active` to 0 — which cancelled the ↑/↓ highlight the instant
// the key came back up, making arrow navigation look dead; and
// • hand the fetch effect a fresh object identity, re-requesting
// suggestions on every single keystroke.
if (mention && mention.start === next.start && mention.query === next.query) {
return;
}
setMention(next);
setActive(0);
} }
// Fetch suggestions (debounced) whenever the active token changes. // Fetch suggestions (debounced) whenever the active token changes.
@@ -120,6 +151,17 @@ export const MentionTextarea = forwardRef<
const listOpen = !!mention && (loading || items.length > 0); const listOpen = !!mention && (loading || items.length > 0);
// Keep the highlighted row visible: the panel caps at max-h-56, which holds
// ~5 of the 8 suggestions, so arrowing past the fifth would otherwise move
// the highlight out of sight. `block: "nearest"` scrolls the panel the
// minimum amount and never scrolls the page behind it.
useEffect(() => {
if (!listOpen) return;
listRef.current
?.querySelector<HTMLElement>(`[data-idx="${active}"]`)
?.scrollIntoView({ block: "nearest" });
}, [active, listOpen]);
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (listOpen && items.length > 0) { if (listOpen && items.length > 0) {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
@@ -143,9 +185,12 @@ export const MentionTextarea = forwardRef<
return; return;
} }
} }
if (e.key === "Enter" && !e.shiftKey) { // Only hijack Enter when the caller actually wants submit-on-Enter.
// Without the `onSubmit` guard we'd swallow the keystroke for multi-line
// callers (e.g. the profile bio), leaving them unable to type a newline.
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
e.preventDefault(); e.preventDefault();
onSubmit?.(); onSubmit();
} }
} }
@@ -153,6 +198,7 @@ export const MentionTextarea = forwardRef<
<div className="relative flex-1"> <div className="relative flex-1">
<textarea <textarea
ref={innerRef} ref={innerRef}
id={id}
value={value} value={value}
rows={rows} rows={rows}
maxLength={maxLength} maxLength={maxLength}
@@ -175,13 +221,16 @@ export const MentionTextarea = forwardRef<
{listOpen && pos && typeof document !== "undefined" {listOpen && pos && typeof document !== "undefined"
? createPortal( ? createPortal(
<div <div
ref={listRef}
role="listbox" role="listbox"
aria-label="Mention suggestions" aria-label="Mention suggestions"
style={{ style={{
position: "fixed", position: "fixed",
left: pos.left, left: pos.left,
bottom: pos.bottom,
width: pos.width, width: pos.width,
...(pos.top !== undefined
? { top: pos.top }
: { bottom: pos.bottom }),
}} }}
// pointer-events-auto: the comments drawer is a modal vaul sheet // pointer-events-auto: the comments drawer is a modal vaul sheet
// (body gets pointer-events:none); without this the list would be // (body gets pointer-events:none); without this the list would be
@@ -194,6 +243,7 @@ export const MentionTextarea = forwardRef<
key={u.id ?? u.handle} key={u.id ?? u.handle}
type="button" type="button"
role="option" role="option"
data-idx={i}
aria-selected={i === active} aria-selected={i === active}
// Keep textarea focus + cancel the pending blur-close so the // Keep textarea focus + cancel the pending blur-close so the
// click registers before the list would otherwise close. // click registers before the list would otherwise close.
+29 -4
View File
@@ -20,6 +20,7 @@
* posts (or imported data) still scroll to + highlight the text on click. * posts (or imported data) still scroll to + highlight the text on click.
*/ */
import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink, Repeat2 } from "lucide-react"; import { ExternalLink, Repeat2 } from "lucide-react";
@@ -30,6 +31,7 @@ import { UserAvatar } from "@/components/ui/user-avatar";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { PostActionsFooter } from "./post-actions-footer"; import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu"; import { PostCardMenu } from "./post-card-menu";
import { PostImageLightbox } from "./post-image-lightbox";
export function PostCard({ export function PostCard({
post, post,
@@ -38,6 +40,11 @@ export function PostCard({
post: Post; post: Post;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
}) { }) {
// Which attached photo the full-screen viewer is showing (null = closed).
// Owned here rather than inside the lightbox so the thumbnails can open it
// at their own index.
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const sourceHref = const sourceHref =
hasHighlight(post.source_url) || !post.caption hasHighlight(post.source_url) || !post.caption
? post.source_url ? post.source_url
@@ -125,10 +132,20 @@ export function PostCard({
: "grid-cols-3" : "grid-cols-3"
}`} }`}
> >
{post.images.map((img) => ( {post.images.map((img, i) => (
<div // Each thumbnail opens the full-screen viewer at its own index.
// A <button> rather than a click-handled <div> so it's keyboard
// reachable and announced — the grid is the only way in.
<button
key={img.id} key={img.id}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10" type="button"
onClick={() => setLightboxIndex(i)}
aria-label={
post.images.length > 1
? `View photo ${i + 1} of ${post.images.length}`
: "View photo"
}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-pointer transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<Image <Image
src={img.url} src={img.url}
@@ -138,11 +155,19 @@ export function PostCard({
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
</div> </button>
))} ))}
</div> </div>
) : null} ) : null}
<PostImageLightbox
images={post.images ?? []}
index={lightboxIndex}
caption={post.caption}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground"> <div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<Link <Link
href={sourceHref} href={sourceHref}
@@ -0,0 +1,238 @@
"use client";
/**
* PostImageLightbox — full-screen, swipeable viewer for a post's photos.
*
* Opened by tapping any image on a PostCard or PostScrollCard. Controlled by
* the caller: `index` is the photo on screen (null = closed), so the card owns
* the state and this stays presentation-only.
*
* Navigation is a native CSS scroll-snap strip — the same primitive the feed
* carousel uses — so swiping works on touch for free, and the ← / → keys and
* chevrons simply scroll that strip. The scroller is the single source of
* truth: buttons/keys never set `index` directly, they scroll, and the scroll
* handler reports the settled index back up. That one-way flow is what keeps a
* programmatic scroll from fighting the user's own gesture.
*
* Dismiss: tap the photo or the surrounding ring, the ✕, or press Escape. Taps
* are distinguished from swipes by useTapGuard, so navigating never closes.
*
* Rendered through a portal onto <body> so it escapes the feed's virtualised,
* transformed rows — an overlay nested inside one would be positioned against
* that transform and clipped by its stacking context, not the viewport.
*/
import { useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PostImage } from "@/lib/social";
import { useTapGuard } from "./use-tap-guard";
export function PostImageLightbox({
images,
index,
caption,
onIndexChange,
onClose,
}: {
images: PostImage[];
/** Photo currently shown; null closes the viewer. */
index: number | null;
/** The post's caption, shown in a gradient scrim along the bottom.
* Clamped to three lines with an ellipsis so a long passage can't cover
* the photo. Omit (or pass empty) to hide the scrim entirely. */
caption?: string | null;
onIndexChange: (i: number) => void;
onClose: () => void;
}) {
const open = index !== null;
const count = images.length;
const scrollerRef = useRef<HTMLDivElement>(null);
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
/** Scroll the strip to a slide. Clamped, not wrapped: the buttons and keys
* should be able to reach exactly what a swipe can, and you can't swipe
* past the end. */
const scrollToIndex = useCallback(
(i: number, behavior: ScrollBehavior) => {
const el = scrollerRef.current;
if (!el) return;
const target = Math.max(0, Math.min(i, count - 1));
el.scrollTo({ left: target * el.offsetWidth, behavior });
},
[count],
);
const goPrev = useCallback(() => {
if (index === null) return;
scrollToIndex(index - 1, "smooth");
}, [index, scrollToIndex]);
const goNext = useCallback(() => {
if (index === null) return;
scrollToIndex(index + 1, "smooth");
}, [index, scrollToIndex]);
// Jump to the tapped photo when the viewer opens. Deliberately keyed on
// `open` only — re-running on every `index` change would yank the strip back
// mid-swipe, since scrolling is what updates `index` in the first place.
useEffect(() => {
if (!open) return;
scrollToIndex(index ?? 0, "auto");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose, goPrev, goNext]);
// Lock body scroll while open so the feed underneath doesn't drift.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget;
const w = el.offsetWidth;
if (w === 0) return;
const i = Math.round(el.scrollLeft / w);
if (i !== index && i >= 0 && i < count) onIndexChange(i);
}
if (!open || typeof document === "undefined") return null;
if (!images[index]) return null;
return (
createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"}
onClick={onClose}
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
>
{/* The strip sits inset from the edges, leaving a backdrop ring that
also closes. `absolute inset-*` rather than padding because padding
does NOT inset an absolutely-positioned `fill` child. */}
<div
ref={scrollerRef}
onScroll={onScroll}
onPointerDown={onPointerDown}
onClick={(e) => {
// Swiping between photos must never dismiss the viewer; only a
// genuine tap falls through to the backdrop's onClose.
if (!wasTap(e)) e.stopPropagation();
}}
className="absolute inset-4 sm:inset-12 flex overflow-x-auto snap-x snap-mandatory overscroll-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((img, i) => (
<div
key={img.id}
className="relative h-full w-full shrink-0 snap-center"
>
<Image
src={img.url}
alt=""
fill
sizes="100vw"
className="object-contain"
unoptimized
// next/image defaults to loading="lazy". That's right for the
// feed, but wrong here: this only mounts once already open, so
// every slide is on (or one swipe from) screen. Left lazy, the
// browser waits for the intersection heuristic before it even
// starts — which is the pause you'd see on open despite the
// bytes already sitting in cache from the card.
loading="eager"
// The slide being viewed outranks its neighbours in the queue.
fetchPriority={i === index ? "high" : "auto"}
/>
</div>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<X className="size-5" />
</button>
{count > 1 ? (
<>
<button
type="button"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
aria-label="Previous photo"
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
disabled={index === count - 1}
onClick={(e) => {
e.stopPropagation();
goNext();
}}
aria-label="Next photo"
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
<ChevronRight className="size-5" />
</button>
{/* Counter sits top-left so it never collides with the caption
scrim along the bottom. */}
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
{index + 1} / {count}
</span>
{/* Dot indicators, mirroring the in-feed carousel. */}
<div className="pointer-events-none fixed inset-x-0 bottom-1.5 flex items-center justify-center gap-1.5">
{images.map((img, i) => (
<span
key={img.id}
aria-hidden
className={`size-1.5 rounded-full transition-all ${
i === index ? "bg-white" : "bg-white/40"
}`}
/>
))}
</div>
</>
) : null}
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap
meant for the backdrop, and the gradient fades upward so the
photo's lower edge stays readable rather than cut by a hard band. */}
{caption?.trim() ? (
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-8">
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
&ldquo;{caption.trim()}&rdquo;
</p>
</div>
) : null}
</div>,
document.body,
)
);
}
+44 -8
View File
@@ -40,6 +40,8 @@ import { useAuth } from "@/components/auth/auth-provider";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared"; import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer"; import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu"; import { PostCardMenu } from "./post-card-menu";
import { PostImageLightbox } from "./post-image-lightbox";
import { useTapGuard } from "./use-tap-guard";
import { FeedFollowButton } from "./feed-follow-button"; import { FeedFollowButton } from "./feed-follow-button";
import { UpdateBackgroundModal } from "../update-background-modal"; import { UpdateBackgroundModal } from "../update-background-modal";
import { MentionsDrawer } from "../mentions-drawer"; import { MentionsDrawer } from "../mentions-drawer";
@@ -68,6 +70,8 @@ export function PostScrollCard({
const { user } = useAuth(); const { user } = useAuth();
const isAuthor = !!user && user.id === post.user_id; const isAuthor = !!user && user.id === post.user_id;
const [bgModal, setBgModal] = useState(false); const [bgModal, setBgModal] = useState(false);
// Which attached photo the full-screen viewer is showing (null = closed).
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [mentionsOpen, setMentionsOpen] = useState(false); const [mentionsOpen, setMentionsOpen] = useState(false);
// Live mention count — seeded from the hydrated post, adjusted when the // Live mention count — seeded from the hydrated post, adjusted when the
@@ -176,7 +180,7 @@ export function PostScrollCard({
{/* Media — image carousel or gradient-with-quote */} {/* Media — image carousel or gradient-with-quote */}
<div className="relative"> <div className="relative">
{hasImages ? ( {hasImages ? (
<ImageCarousel images={images} /> <ImageCarousel images={images} onOpen={setLightboxIndex} />
) : ( ) : (
<QuotePanel post={post} /> <QuotePanel post={post} />
)} )}
@@ -225,14 +229,33 @@ export function PostScrollCard({
onClose={() => setMentionsOpen(false)} onClose={() => setMentionsOpen(false)}
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))} onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
/> />
<PostImageLightbox
images={images}
index={lightboxIndex}
caption={post.caption}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
</article> </article>
); );
} }
// ─── Pieces ────────────────────────────────────────────────────────────── // ─── Pieces ──────────────────────────────────────────────────────────────
function ImageCarousel({ images }: { images: PostImage[] }) { function ImageCarousel({
images,
onOpen,
}: {
images: PostImage[];
/** Tapping a slide opens the full-screen viewer at that index. */
onOpen: (index: number) => void;
}) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const scrollerRef = useRef<HTMLDivElement>(null);
// Swiping the carousel and tapping a photo have to stay independent — the
// strip scrolls natively, and browsers still fire a click after some swipes.
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
function onScroll(e: React.UIEvent<HTMLDivElement>) { function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget; const el = e.currentTarget;
@@ -242,8 +265,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
} }
if (images.length === 1) { if (images.length === 1) {
// Single image: no scroller, so every click is unambiguously a tap.
return ( return (
<div className="relative aspect-square bg-muted"> <button
type="button"
onClick={() => onOpen(0)}
aria-label="View photo"
className="relative block w-full aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Image <Image
src={images[0].url} src={images[0].url}
alt="" alt=""
@@ -252,13 +281,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
</div> </button>
); );
} }
return ( return (
<div className="relative"> <div className="relative">
<div <div
ref={scrollerRef}
onScroll={onScroll} onScroll={onScroll}
className={cn( className={cn(
"flex overflow-x-auto snap-x snap-mandatory", "flex overflow-x-auto snap-x snap-mandatory",
@@ -266,11 +296,17 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
)} )}
> >
{images.map((img, i) => ( {images.map((img, i) => (
<div <button
key={img.id} key={img.id}
className="relative w-full shrink-0 snap-start aspect-square bg-muted" type="button"
onPointerDown={onPointerDown}
onClick={(e) => {
if (!wasTap(e)) return; // that was a swipe — let it scroll
onOpen(i);
}}
aria-roledescription="slide" aria-roledescription="slide"
aria-label={`${i + 1} of ${images.length}`} aria-label={`View photo ${i + 1} of ${images.length}`}
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
> >
<Image <Image
src={img.url} src={img.url}
@@ -280,7 +316,7 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
</div> </button>
))} ))}
</div> </div>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none"> <div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
+51
View File
@@ -0,0 +1,51 @@
"use client";
/**
* useTapGuard — tells a genuine tap apart from a swipe on a natively-scrolling
* strip (CSS scroll-snap carousels).
*
* Browsers still fire a `click` after some swipe gestures, so a plain onClick
* on a slide would trigger mid-swipe. Snapshot the pointer position AND the
* scroller's offset on pointerdown, then check both when the click arrives:
*
* • pointer travel — catches a drag that didn't scroll (e.g. hit the end)
* • scroll movement — catches an inertial swipe, where the pointer barely
* moves before `click` fires but the strip is already gliding
*
* Either signal alone misses cases, hence both. No staleness risk: a click is
* always immediately preceded by a pointerdown on the same element.
*/
import { useRef, type RefObject } from "react";
/** Pointer travel (px) still considered a tap rather than a drag. */
const MOVE_TOLERANCE = 8;
/** Scroll movement (px) still considered "didn't scroll". */
const SCROLL_TOLERANCE = 4;
type PointerLike = { clientX: number; clientY: number };
export function useTapGuard(scrollerRef: RefObject<HTMLElement | null>) {
const start = useRef({ x: 0, y: 0, scrollLeft: 0 });
function onPointerDown(e: PointerLike) {
start.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
};
}
function wasTap(e: PointerLike) {
const moved =
Math.abs(e.clientX - start.current.x) > MOVE_TOLERANCE ||
Math.abs(e.clientY - start.current.y) > MOVE_TOLERANCE;
const scrolled =
Math.abs(
(scrollerRef.current?.scrollLeft ?? 0) - start.current.scrollLeft,
) > SCROLL_TOLERANCE;
return !moved && !scrolled;
}
return { onPointerDown, wasTap };
}
+8
View File
@@ -33,6 +33,12 @@ 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;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
/** 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;
@@ -222,6 +228,8 @@ export const auth = {
name?: string; name?: string;
avatar_url?: string | null; avatar_url?: string | null;
handle?: string; handle?: string;
/** Plain text, max 200 chars; "" clears it. */
bio?: string;
country?: string | null; country?: string | null;
sampradaya?: string; sampradaya?: string;
gender?: string | null; gender?: string | null;
+49
View File
@@ -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. */
+54
View File
@@ -0,0 +1,54 @@
/**
* Realtime channel authorization.
*
* Our Soketi channels are `private-`/`presence-` prefixed, so Soketi refuses a
* subscription without a signature. Only the BFF holds the app secret, so the
* client asks it to sign each subscription at
* POST /api/v1/broadcasting/auth — which first checks the session cookie
* identifies someone allowed on that channel.
*
* We supply a `customHandler` rather than pusher-js's built-in `authEndpoint`
* because that built-in uses XMLHttpRequest without setting
* `withCredentials`, so it would never send our httpOnly session cookie
* cross-origin (prabhupadasays.org → api.prabhupadasays.org) and every
* subscription would 401. A plain fetch with `credentials: "include"` matches
* how the rest of lib/ talks to the BFF.
*
* The endpoint answers with a BARE {auth, channel_data} body rather than the
* usual {success, data} envelope, because pusher-js reads `auth` off the top
* level of the response.
*/
import type { ChannelAuthorizationHandler } from "pusher-js";
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/**
* Signs private/presence subscriptions. Shared by every realtime consumer, so
* new channel families need no client-side change — only a rule on the BFF.
*/
export const channelAuthorizer: ChannelAuthorizationHandler = (
{ socketId, channelName },
callback,
) => {
fetch(`${BFF_BASE}/api/v1/broadcasting/auth`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ socket_id: socketId, channel_name: channelName }),
})
.then(async (res) => {
if (!res.ok) {
// 403 is a normal outcome (not a participant / not your channel), so
// carry the status rather than presenting it as a network fault.
throw new Error(
`channel authorization failed for ${channelName}: ${res.status}`,
);
}
return (await res.json()) as { auth: string; channel_data?: string };
})
.then((data) => callback(null, data))
.catch((err: unknown) => {
callback(err instanceof Error ? err : new Error(String(err)), null);
});
};
+11 -1
View File
@@ -182,6 +182,9 @@ export type PublicProfile = {
handle: string; handle: string;
name: string; name: string;
avatar_url: string | null; avatar_url: string | null;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
joined_at: string; joined_at: string;
/** ISO-3166-1 alpha-2 account country, or null when unknown. */ /** ISO-3166-1 alpha-2 account country, or null when unknown. */
country: string | null; country: string | null;
@@ -192,6 +195,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 +704,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