Files
spsays-training/components/profile/profile-header.tsx
T
2026-08-08 00:49:39 +05:30

360 lines
12 KiB
TypeScript

"use client";
/**
* ProfileHeader — Instagram-style centered header used at the top of
* /u/me and /u/[handle]. Avatar, display name, @handle, a single stat
* line, and an action row.
*
* The action row hosts:
* • "Edit profile" link — only on the owner's own profile (via the
* `editHref` prop set by /u/me layout).
* • Reading-settings gear — also owner-only. We use `editHref` as the
* "is this my profile?" signal (the /u/me layout passes it; the
* /u/[handle] layout does not), so the two buttons stay in lockstep
* and visitors on someone else's profile see a clean row with no
* personal-settings affordances. The gear links to /settings (the
* dedicated settings page) rather than opening a popover —
* centred-row popovers tend to spill off the left edge on narrow
* viewports.
*
* Clicking the avatar (when there's an uploaded image) opens a portal
* modal showing the full-size image inside a circular crop. Tap the
* backdrop or press Escape to close.
*
* Auth users on someone else's profile reach reading settings via the
* sidebar rail on desktop. Anonymous visitors use SiteHeader's mobile
* gear.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import Link from "next/link";
import { Settings, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RelationshipResult, RelationshipState } from "@/lib/social";
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 {
FollowListDrawer,
type FollowMode,
} from "@/components/profile/follow-list-drawer";
export function ProfileHeader({
avatarURL,
name,
handle,
postCount,
joinedAt,
editHref,
followerCount,
followingCount,
relationship,
enableActions = false,
country,
interactiveCounts = true,
isDirectDisciple = false,
bio,
}: {
avatarURL: string | null;
name: string;
handle: string;
postCount?: number;
joinedAt?: string;
/** ISO-2 account country, surfaced in the "More info" modal. */
country?: string | null;
/** When provided, shows an "Edit profile" CTA below the handle AND
* the reading-settings gear. Acts as the canonical "this is my own
* profile" signal across the action row. */
editHref?: string;
/** Accepted-follower / accepted-following totals for the stat line.
* Omit to hide those two stats. */
followerCount?: number;
followingCount?: number;
/** The viewer's relationship to this profile (null for anonymous
* viewers). Only consumed when `enableActions` is set. */
relationship?: RelationshipState | null;
/** Render the follow / block / report action row — set on OTHER users'
* profiles (not the owner's /u/me, which shows Edit/Settings). Mount
* this header with key={handle} so the live count + relationship state
* below re-seeds cleanly when navigating between profiles. */
enableActions?: boolean;
/** When false, the follower/following stats render as plain text (no
* click target) and the follow-list drawer isn't mounted — used on a
* blocked profile, where the lists endpoint is gated server-side. */
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
// `enableActions`. Mutually exclusive by construction.
const showOwnerControls = !!editHref;
const [zoomOpen, setZoomOpen] = useState(false);
// Live counts + relationship — seeded from props, updated in place after
// a follow/accept/block action so the stat line and buttons stay in sync
// without a refetch. Re-seeds per profile via the call-site key.
const [followers, setFollowers] = useState(followerCount ?? 0);
const [following, setFollowing] = useState(followingCount ?? 0);
const [rel, setRel] = useState<RelationshipState | null>(
relationship ?? null,
);
// Which follow list (if any) is open in the drawer.
const [followList, setFollowList] = useState<FollowMode | null>(null);
const handleResult = (r: RelationshipResult) => {
setRel(r.relationship);
setFollowers(r.follower_count);
setFollowing(r.following_count);
};
const showCounts = typeof followerCount === "number";
return (
<>
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
{/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right
for his direct disciples (verification-tick placement). */}
<div className="relative">
<UserAvatar
src={avatarURL}
name={name}
className="size-24 sm:size-28"
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-1">
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
{name}
</h1>
<p className="text-sm text-muted-foreground">@{handle}</p>
</div>
{/* Stat line: posts · followers · following */}
<div className="flex items-center justify-center gap-5 sm:gap-7 pt-1 my-4">
{typeof postCount === "number" ? (
<Stat n={postCount} label={postCount === 1 ? "post" : "posts"} />
) : null}
{showCounts ? (
<>
<Stat
n={followers}
label="followers"
onClick={
interactiveCounts
? () => setFollowList("followers")
: undefined
}
/>
<Stat
n={following}
label="following"
onClick={
interactiveCounts
? () => setFollowList("following")
: undefined
}
/>
</>
) : null}
</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 ? (
<div className="flex items-center gap-2">
<Link
href={editHref!}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
<Link
href="/settings"
aria-label="Settings"
title="Settings"
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"border border-border text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-4" />
</Link>
</div>
) : enableActions ? (
<ProfileActions
handle={handle}
name={name}
joinedAt={joinedAt}
country={country}
relationship={rel}
onResult={handleResult}
/>
) : null}
</header>
{avatarURL ? (
<AvatarZoomModal
src={avatarURL}
name={name}
open={zoomOpen}
onClose={() => setZoomOpen(false)}
/>
) : null}
{showCounts && interactiveCounts ? (
<FollowListDrawer
open={followList !== null}
mode={followList ?? "followers"}
handle={handle}
onClose={() => setFollowList(null)}
/>
) : null}
</>
);
}
/** One inline profile stat — bold count over a muted label. Renders as a
* button (opening the follow list) when `onClick` is provided, else a plain
* div (e.g. the posts count). */
function Stat({
n,
label,
onClick,
}: {
n: number;
label: string;
onClick?: () => void;
}) {
const inner = (
<>
<span className="font-semibold text-lg text-foreground tabular-nums">
{n}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
</>
);
if (!onClick) {
return (
<div className="flex flex-col items-center leading-tight">{inner}</div>
);
}
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center leading-tight rounded-md px-2 -mx-1 py-0.5 transition-colors hover:bg-muted/60 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{inner}
</button>
);
}
/**
* AvatarZoomModal — portaled full-screen overlay with the avatar shown
* inside a large circular crop. Click the backdrop or press Escape to
* close. Body scroll is locked while open so the page underneath
* doesn't jitter when the modal mounts.
*/
function AvatarZoomModal({
src,
name,
open,
onClose,
}: {
src: string;
name: string;
open: boolean;
onClose: () => void;
}) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={`${name}'s profile picture`}
onClick={onClose}
className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in-0"
>
{/* Stop-propagation so clicks on the image itself don't dismiss
the modal — only the surrounding backdrop closes. */}
<div
onClick={(e) => e.stopPropagation()}
className="relative rounded-full overflow-hidden ring-1 ring-white/20 shadow-2xl"
style={{
// Square box capped at 80% of the smaller viewport dimension —
// keeps the circle large but always fully visible with a
// comfortable margin on tiny screens.
width: "min(80vw, 80vh, 28rem)",
height: "min(80vw, 80vh, 28rem)",
}}
>
<Image
src={src}
alt={name}
fill
sizes="(max-width: 640px) 80vw, 28rem"
className="object-cover"
unoptimized
/>
</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>
</div>,
document.body,
);
}