360 lines
12 KiB
TypeScript
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,
|
|
);
|
|
}
|