fix: profile bio
This commit is contained in:
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user