fix: profile bio

This commit is contained in:
gosangam
2026-08-08 00:49:39 +05:30
parent 9b7758da99
commit ce373e3e03
2 changed files with 90 additions and 74 deletions
+60 -72
View File
@@ -1,14 +1,9 @@
"use client"; "use client";
/** /**
* ProfileHeader — the header at the top of /u/me and /u/[handle]. * ProfileHeader — Instagram-style centered header used at the top of
* * /u/me and /u/[handle]. Avatar, display name, @handle, a single stat
* Layout is two stacked blocks: * line, and an action row.
* 1. An identity ROW — avatar on the left, with display name, @handle, and
* the posts/followers/following stat line stacked to its right.
* 2. Full-width rows beneath it — the bio, then the action row. Both span the
* avatar's width too, so a 200-character bio gets the whole column rather
* than the narrow gutter beside the avatar.
* *
* The action row hosts: * The action row hosts:
* • "Edit profile" link — only on the owner's own profile (via the * • "Edit profile" link — only on the owner's own profile (via the
@@ -95,8 +90,8 @@ export function ProfileHeader({
* on the avatar; tapping it explains the mark. */ * on the avatar; tapping it explains the mark. */
isDirectDisciple?: boolean; isDirectDisciple?: boolean;
/** The profile's bio. Plain text — `@handle` mentions are linkified on /** The profile's bio. Plain text — `@handle` mentions are linkified on
* render. Rendered left-aligned across the full column below the centred * render. Sits between the stat line and the action buttons, centred like
* identity block; omitted entirely when empty. */ * the rest of the header. Omitted entirely when empty. */
bio?: string | null; bio?: string | null;
}) { }) {
// Owner affordances ride on `editHref`; the follow-action row rides on // Owner affordances ride on `editHref`; the follow-action row rides on
@@ -127,77 +122,70 @@ export function ProfileHeader({
return ( return (
<> <>
<header className="flex flex-col gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8"> <header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
{/* Identity row — avatar on the left, name + handle + stats stacked to {/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right
its right. The bio and action row sit below at full width, so long for his direct disciples (verification-tick placement). */}
prose gets the whole column instead of a narrow gutter. */} <div className="relative">
<div className="flex items-center gap-4 sm:gap-6"> <UserAvatar
{/* Avatar, with Śrīla Prabhupāda's portrait tucked into the src={avatarURL}
lower-right for his direct disciples (verification-tick name={name}
placement). */} className="size-24 sm:size-28"
<div className="relative shrink-0"> textClassName="text-3xl"
<UserAvatar interactive
src={avatarURL} onClick={avatarURL ? () => setZoomOpen(true) : undefined}
name={name} ariaLabel={`View ${name}'s profile picture`}
className="size-20 sm:size-28" />
textClassName="text-3xl" {isDirectDisciple ? (
interactive <DiscipleBadge className="absolute -bottom-0.5 -right-0.5" />
onClick={avatarURL ? () => setZoomOpen(true) : undefined} ) : null}
ariaLabel={`View ${name}'s profile picture`} </div>
/> <div className="space-y-2">
{isDirectDisciple ? ( <div className="space-y-1">
<DiscipleBadge className="absolute -bottom-0.5 -right-0.5" /> <h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
) : null}
</div>
{/* min-w-0 lets the long-name truncation actually kick in — without
it the flex child refuses to shrink below its content width. */}
<div className="min-w-0 flex-1">
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight truncate">
{name} {name}
</h1> </h1>
<p className="mt-0.5 text-sm text-muted-foreground truncate"> <p className="text-sm text-muted-foreground">@{handle}</p>
@{handle} </div>
</p>
{/* Stat line: posts · followers · following */} {/* Stat line: posts · followers · following */}
<div className="mt-3 flex items-center gap-5 sm:gap-7"> <div className="flex items-center justify-center gap-5 sm:gap-7 pt-1 my-4">
{typeof postCount === "number" ? ( {typeof postCount === "number" ? (
<Stat n={postCount} label={postCount === 1 ? "post" : "posts"} /> <Stat n={postCount} label={postCount === 1 ? "post" : "posts"} />
) : null} ) : null}
{showCounts ? ( {showCounts ? (
<> <>
<Stat <Stat
n={followers} n={followers}
label="followers" label="followers"
onClick={ onClick={
interactiveCounts interactiveCounts
? () => setFollowList("followers") ? () => setFollowList("followers")
: undefined : undefined
} }
/> />
<Stat <Stat
n={following} n={following}
label="following" label="following"
onClick={ onClick={
interactiveCounts interactiveCounts
? () => setFollowList("following") ? () => setFollowList("following")
: undefined : undefined
} }
/> />
</> </>
) : null} ) : null}
</div>
</div> </div>
</div> </div>
{/* Bio — spans the full column beneath the avatar AND the name/stats {/* Bio — between the stats and the action buttons, centred to match the
block, so a 200-character bio wraps across the whole width instead rest of the header. `self-stretch` overrides the header's
of being squeezed into the column beside the avatar. */} `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() ? ( {bio?.trim() ? (
<MentionText <MentionText
text={bio.trim()} text={bio.trim()}
className="text-sm sm:text-[15px] leading-relaxed text-foreground" className="self-stretch text-center text-sm sm:text-[15px] leading-relaxed text-foreground"
/> />
) : null} ) : null}
+30 -2
View File
@@ -69,6 +69,7 @@ export const MentionTextarea = forwardRef<
const [pos, setPos] = useState<PanelPos | null>(null); const [pos, setPos] = useState<PanelPos | null>(null);
const reqRef = useRef(0); const reqRef = useRef(0);
const blurTimer = useRef<number | null>(null); const blurTimer = useRef<number | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// Detect the active @token from the text up to the caret. // Detect the active @token from the text up to the caret.
function detect(el: HTMLTextAreaElement) { function detect(el: HTMLTextAreaElement) {
@@ -78,8 +79,10 @@ export const MentionTextarea = forwardRef<
setMention(null); setMention(null);
return; return;
} }
setMention({ start: caret - m[2].length - 1, query: m[2] }); const next = { start: caret - m[2].length - 1, query: m[2] };
setActive(0);
// Reposition unconditionally — the field grows as text wraps, so the panel
// has to stay pinned to it even when the token itself hasn't changed.
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
// Prefer opening ABOVE the field: that's what the comment composer needs // Prefer opening ABOVE the field: that's what the comment composer needs
// (it sits at the bottom of a drawer, often above the on-screen keyboard). // (it sits at the bottom of a drawer, often above the on-screen keyboard).
@@ -94,6 +97,18 @@ export const MentionTextarea = forwardRef<
? { bottom: window.innerHeight - r.top + 6 } ? { bottom: window.innerHeight - r.top + 6 }
: { top: r.bottom + 6 }), : { top: r.bottom + 6 }),
}); });
// Bail out if the token is byte-for-byte what we already have. detect()
// also runs on keyup and click, so re-seeding an identical mention would:
// • reset `active` to 0 — which cancelled the ↑/↓ highlight the instant
// the key came back up, making arrow navigation look dead; and
// • hand the fetch effect a fresh object identity, re-requesting
// suggestions on every single keystroke.
if (mention && mention.start === next.start && mention.query === next.query) {
return;
}
setMention(next);
setActive(0);
} }
// Fetch suggestions (debounced) whenever the active token changes. // Fetch suggestions (debounced) whenever the active token changes.
@@ -136,6 +151,17 @@ export const MentionTextarea = forwardRef<
const listOpen = !!mention && (loading || items.length > 0); const listOpen = !!mention && (loading || items.length > 0);
// Keep the highlighted row visible: the panel caps at max-h-56, which holds
// ~5 of the 8 suggestions, so arrowing past the fifth would otherwise move
// the highlight out of sight. `block: "nearest"` scrolls the panel the
// minimum amount and never scrolls the page behind it.
useEffect(() => {
if (!listOpen) return;
listRef.current
?.querySelector<HTMLElement>(`[data-idx="${active}"]`)
?.scrollIntoView({ block: "nearest" });
}, [active, listOpen]);
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (listOpen && items.length > 0) { if (listOpen && items.length > 0) {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
@@ -195,6 +221,7 @@ export const MentionTextarea = forwardRef<
{listOpen && pos && typeof document !== "undefined" {listOpen && pos && typeof document !== "undefined"
? createPortal( ? createPortal(
<div <div
ref={listRef}
role="listbox" role="listbox"
aria-label="Mention suggestions" aria-label="Mention suggestions"
style={{ style={{
@@ -216,6 +243,7 @@ export const MentionTextarea = forwardRef<
key={u.id ?? u.handle} key={u.id ?? u.handle}
type="button" type="button"
role="option" role="option"
data-idx={i}
aria-selected={i === active} aria-selected={i === active}
// Keep textarea focus + cancel the pending blur-close so the // Keep textarea focus + cancel the pending blur-close so the
// click registers before the list would otherwise close. // click registers before the list would otherwise close.