305 lines
10 KiB
TypeScript
305 lines
10 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";
|
|
|
|
export function ProfileHeader({
|
|
avatarURL,
|
|
name,
|
|
handle,
|
|
postCount,
|
|
joinedAt,
|
|
editHref,
|
|
followerCount,
|
|
followingCount,
|
|
relationship,
|
|
enableActions = false,
|
|
country,
|
|
}: {
|
|
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;
|
|
}) {
|
|
// 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);
|
|
|
|
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
|
|
src={avatarURL}
|
|
name={name}
|
|
onZoom={avatarURL ? () => setZoomOpen(true) : undefined}
|
|
/>
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<h1 className="font-[family-name:var(--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" />
|
|
<Stat n={following} label="following" />
|
|
</>
|
|
) : 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}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** One inline profile stat — bold count over a muted label. */
|
|
function Stat({ n, label }: { n: number; label: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center leading-tight">
|
|
<span className="font-semibold text-lg text-foreground tabular-nums">{n}</span>
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Avatar({
|
|
src,
|
|
name,
|
|
onZoom,
|
|
}: {
|
|
src: string | null;
|
|
name: string;
|
|
/** Provided when the avatar should be interactive — the parent shows
|
|
* the zoom modal on click. Omit (or pass undefined) to render the
|
|
* initials placeholder as a plain non-clickable element. */
|
|
onZoom?: () => void;
|
|
}) {
|
|
if (src) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onZoom}
|
|
aria-label={`View ${name}'s profile picture`}
|
|
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
>
|
|
<Image
|
|
src={src}
|
|
alt={name}
|
|
width={112}
|
|
height={112}
|
|
className="size-24 sm:size-28 rounded-full object-cover ring-1 ring-foreground/10 hover:ring-foreground/30 transition"
|
|
unoptimized
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
const initials =
|
|
name
|
|
.split(/\s+/)
|
|
.map((p) => p[0])
|
|
.slice(0, 2)
|
|
.join("")
|
|
.toUpperCase() || "?";
|
|
return (
|
|
<div className="size-24 sm:size-28 rounded-full bg-primary/15 text-primary flex items-center justify-center text-3xl font-semibold ring-1 ring-foreground/10">
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
);
|
|
}
|