fix: profile bio
This commit is contained in:
@@ -1,14 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProfileHeader — the header at the top of /u/me and /u/[handle].
|
||||
*
|
||||
* Layout is two stacked blocks:
|
||||
* 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.
|
||||
* 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
|
||||
@@ -95,8 +90,8 @@ export function ProfileHeader({
|
||||
* on the avatar; tapping it explains the mark. */
|
||||
isDirectDisciple?: boolean;
|
||||
/** The profile's bio. Plain text — `@handle` mentions are linkified on
|
||||
* render. Rendered left-aligned across the full column below the centred
|
||||
* identity block; omitted entirely when empty. */
|
||||
* 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
|
||||
@@ -127,19 +122,14 @@ export function ProfileHeader({
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex flex-col gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
|
||||
{/* Identity row — avatar on the left, name + handle + stats stacked to
|
||||
its right. The bio and action row sit below at full width, so long
|
||||
prose gets the whole column instead of a narrow gutter. */}
|
||||
<div className="flex items-center gap-4 sm:gap-6">
|
||||
{/* Avatar, with Śrīla Prabhupāda's portrait tucked into the
|
||||
lower-right for his direct disciples (verification-tick
|
||||
placement). */}
|
||||
<div className="relative shrink-0">
|
||||
<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-20 sm:size-28"
|
||||
className="size-24 sm:size-28"
|
||||
textClassName="text-3xl"
|
||||
interactive
|
||||
onClick={avatarURL ? () => setZoomOpen(true) : undefined}
|
||||
@@ -149,19 +139,16 @@ export function ProfileHeader({
|
||||
<DiscipleBadge className="absolute -bottom-0.5 -right-0.5" />
|
||||
) : 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">
|
||||
<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="mt-0.5 text-sm text-muted-foreground truncate">
|
||||
@{handle}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">@{handle}</p>
|
||||
</div>
|
||||
|
||||
{/* 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" ? (
|
||||
<Stat n={postCount} label={postCount === 1 ? "post" : "posts"} />
|
||||
) : null}
|
||||
@@ -189,15 +176,16 @@ export function ProfileHeader({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio — spans the full column beneath the avatar AND the name/stats
|
||||
block, so a 200-character bio wraps across the whole width instead
|
||||
of being squeezed into the column beside the avatar. */}
|
||||
{/* 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="text-sm sm:text-[15px] leading-relaxed text-foreground"
|
||||
className="self-stretch text-center text-sm sm:text-[15px] leading-relaxed text-foreground"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const MentionTextarea = forwardRef<
|
||||
const [pos, setPos] = useState<PanelPos | null>(null);
|
||||
const reqRef = useRef(0);
|
||||
const blurTimer = useRef<number | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Detect the active @token from the text up to the caret.
|
||||
function detect(el: HTMLTextAreaElement) {
|
||||
@@ -78,8 +79,10 @@ export const MentionTextarea = forwardRef<
|
||||
setMention(null);
|
||||
return;
|
||||
}
|
||||
setMention({ start: caret - m[2].length - 1, query: m[2] });
|
||||
setActive(0);
|
||||
const next = { start: caret - m[2].length - 1, query: m[2] };
|
||||
|
||||
// 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();
|
||||
// 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).
|
||||
@@ -94,6 +97,18 @@ export const MentionTextarea = forwardRef<
|
||||
? { bottom: window.innerHeight - r.top + 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.
|
||||
@@ -136,6 +151,17 @@ export const MentionTextarea = forwardRef<
|
||||
|
||||
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>) {
|
||||
if (listOpen && items.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -195,6 +221,7 @@ export const MentionTextarea = forwardRef<
|
||||
{listOpen && pos && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="Mention suggestions"
|
||||
style={{
|
||||
@@ -216,6 +243,7 @@ export const MentionTextarea = forwardRef<
|
||||
key={u.id ?? u.handle}
|
||||
type="button"
|
||||
role="option"
|
||||
data-idx={i}
|
||||
aria-selected={i === active}
|
||||
// Keep textarea focus + cancel the pending blur-close so the
|
||||
// click registers before the list would otherwise close.
|
||||
|
||||
Reference in New Issue
Block a user