"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( relationship ?? null, ); // Which follow list (if any) is open in the drawer. const [followList, setFollowList] = useState(null); const handleResult = (r: RelationshipResult) => { setRel(r.relationship); setFollowers(r.follower_count); setFollowing(r.following_count); }; const showCounts = typeof followerCount === "number"; return ( <>
{/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right for his direct disciples (verification-tick placement). */}
setZoomOpen(true) : undefined} ariaLabel={`View ${name}'s profile picture`} /> {isDirectDisciple ? ( ) : null}

{name}

@{handle}

{/* Stat line: posts · followers · following */}
{typeof postCount === "number" ? ( ) : null} {showCounts ? ( <> setFollowList("followers") : undefined } /> setFollowList("following") : undefined } /> ) : null}
{/* 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() ? ( ) : null} {showOwnerControls ? (
Edit profile
) : enableActions ? ( ) : null}
{avatarURL ? ( setZoomOpen(false)} /> ) : null} {showCounts && interactiveCounts ? ( 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 = ( <> {n} {label} ); if (!onClick) { return (
{inner}
); } return ( ); } /** * 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(
{/* Stop-propagation so clicks on the image itself don't dismiss the modal — only the surrounding backdrop closes. */}
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)", }} > {name}
, document.body, ); }