Files
2026-06-13 02:24:42 +05:30

329 lines
11 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 { 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,
}: {
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;
}) {
// 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">
<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`}
/>
<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>
{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,
);
}