feat: profile bio

This commit is contained in:
gosangam
2026-08-07 23:47:22 +05:30
parent 274c735b4d
commit 6ff29db68a
9 changed files with 168 additions and 28 deletions
+2
View File
@@ -130,6 +130,7 @@ export default function PublicProfileLayout(props: {
joinedAt={data.joined_at} joinedAt={data.joined_at}
country={data.country} country={data.country}
isDirectDisciple={!!data.is_direct_disciple} isDirectDisciple={!!data.is_direct_disciple}
bio={data.bio}
/> />
<div className="py-12 text-center"> <div className="py-12 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -176,6 +177,7 @@ export default function PublicProfileLayout(props: {
joinedAt={data.joined_at} joinedAt={data.joined_at}
country={data.country} country={data.country}
isDirectDisciple={!!data.is_direct_disciple} isDirectDisciple={!!data.is_direct_disciple}
bio={data.bio}
/> />
<ProfileTabs tabs={tabs} /> <ProfileTabs tabs={tabs} />
<PublicProfileContext.Provider value={data}> <PublicProfileContext.Provider value={data}>
+1
View File
@@ -141,6 +141,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
handle={user.handle} handle={user.handle}
joinedAt={user.created_at} joinedAt={user.created_at}
isDirectDisciple={!!user.is_direct_disciple} isDirectDisciple={!!user.is_direct_disciple}
bio={user.bio}
editHref="/settings/profile" editHref="/settings/profile"
postCount={counts?.posts} postCount={counts?.posts}
followerCount={counts?.followers} followerCount={counts?.followers}
+53
View File
@@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MentionTextarea } from "@/components/social/mention-textarea";
import { cn } from "@/lib/utils";
/** Profile bio cap. Mirrors the BFF's service.MaxBioRunes. */
const BIO_MAX = 200;
import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
@@ -70,6 +75,7 @@ export function ProfileEditForm({
const [name, setName] = useState(user.name); const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle); const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url); const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [bio, setBio] = useState<string>(user.bio ?? "");
const [country, setCountry] = useState<string>(user.country ?? ""); const [country, setCountry] = useState<string>(user.country ?? "");
// Devotional details. Each "" means "not specified". // Devotional details. Each "" means "not specified".
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? ""); const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
@@ -92,11 +98,15 @@ export function ProfileEditForm({
const origRounds = const origRounds =
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : ""; user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
const effInitiatedAt = initiated ? initiatedAt : ""; const effInitiatedAt = initiated ? initiatedAt : "";
// Spread, not .length — counts code points so emoji and Devanāgarī match
// the server's rune-based cap instead of over-counting UTF-16 units.
const bioCount = [...bio.trim()].length;
const dirty = const dirty =
name.trim() !== user.name || name.trim() !== user.name ||
handle.trim() !== user.handle || handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url || avatarUrl !== user.avatar_url ||
bio.trim() !== (user.bio ?? "") ||
country !== (user.country ?? "") || country !== (user.country ?? "") ||
sampradaya !== (user.sampradaya ?? "") || sampradaya !== (user.sampradaya ?? "") ||
gender !== (user.gender ?? "") || gender !== (user.gender ?? "") ||
@@ -108,6 +118,13 @@ export function ProfileEditForm({
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
// Counted in code points to match the BFF's rune-based cap, so a bio in
// Devanāgarī is measured the same way as one in Latin script.
if ([...bio.trim()].length > BIO_MAX) {
setError(`Bio must be ${BIO_MAX} characters or fewer.`);
return;
}
// Client-side guard for the round count (the BFF re-checks 1–1008). // Client-side guard for the round count (the BFF re-checks 1–1008).
if (rounds !== "") { if (rounds !== "") {
const n = Number(rounds); const n = Number(rounds);
@@ -126,6 +143,8 @@ export function ProfileEditForm({
if (name.trim() !== user.name) patch.name = name.trim(); if (name.trim() !== user.name) patch.name = name.trim();
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? ""; if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
if (handle.trim() !== user.handle) patch.handle = handle.trim(); if (handle.trim() !== user.handle) patch.handle = handle.trim();
// "" clears the bio.
if (bio.trim() !== (user.bio ?? "")) patch.bio = bio.trim();
if (country !== (user.country ?? "")) patch.country = country; if (country !== (user.country ?? "")) patch.country = country;
// "" clears the lineage; a code sets it. // "" clears the lineage; a code sets it.
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya; if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
@@ -221,6 +240,40 @@ export function ProfileEditForm({
/> />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="profile-bio">Bio</Label>
{/* MentionTextarea (shared with the post composer and comment box)
gives us the debounced "@" suggestion list for free — typing an
@token opens a people picker prioritised following → followers. */}
<MentionTextarea
id="profile-bio"
value={bio}
onChange={setBio}
rows={3}
maxLength={BIO_MAX}
placeholder="A little about you… mention devotees with @handle"
// Mirrors the <Input> primitive's chrome so the bio sits flush
// with the other fields (auto height instead of the fixed h-10).
className="w-full min-w-0 resize-y rounded-lg border border-input bg-card px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-card"
/>
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
Type <span className="font-medium text-foreground">@</span> to
mention another devotee — mentions become links on your profile.
</p>
<span
className={cn(
"shrink-0 text-xs tabular-nums",
bioCount > BIO_MAX
? "font-medium text-destructive"
: "text-muted-foreground",
)}
>
{bioCount}/{BIO_MAX}
</span>
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label> <Label htmlFor="profile-handle">Handle</Label>
{/* "@" sits inside the field as a fixed, non-editable prefix — {/* "@" sits inside the field as a fixed, non-editable prefix —
+17
View File
@@ -35,6 +35,7 @@ import { cn } from "@/lib/utils";
import type { RelationshipResult, RelationshipState } from "@/lib/social"; import type { RelationshipResult, RelationshipState } from "@/lib/social";
import { ProfileActions } from "@/components/profile/profile-actions"; import { ProfileActions } from "@/components/profile/profile-actions";
import { DiscipleBadge } from "@/components/profile/disciple-badge"; import { DiscipleBadge } from "@/components/profile/disciple-badge";
import { MentionText } from "@/components/social/mention-text";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { import {
FollowListDrawer, FollowListDrawer,
@@ -55,6 +56,7 @@ export function ProfileHeader({
country, country,
interactiveCounts = true, interactiveCounts = true,
isDirectDisciple = false, isDirectDisciple = false,
bio,
}: { }: {
avatarURL: string | null; avatarURL: string | null;
name: string; name: string;
@@ -87,6 +89,10 @@ export function ProfileHeader({
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait * one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
* 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
* render. Rendered left-aligned across the full column below the centred
* identity block; omitted entirely when empty. */
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
// `enableActions`. Mutually exclusive by construction. // `enableActions`. Mutually exclusive by construction.
@@ -203,6 +209,17 @@ export function ProfileHeader({
onResult={handleResult} onResult={handleResult}
/> />
) : null} ) : null}
{/* Bio — left-aligned across the full column, unlike the centred
identity block above it. Prose reads badly centred once it wraps
past one line, and `self-stretch` overrides the header's
`items-center` so it spans edge to edge. */}
{bio?.trim() ? (
<MentionText
text={bio.trim()}
className="self-stretch text-left text-sm sm:text-[15px] leading-relaxed text-foreground"
/>
) : null}
</header> </header>
{avatarURL ? ( {avatarURL ? (
<AvatarZoomModal <AvatarZoomModal
+2 -21
View File
@@ -18,29 +18,10 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social"; import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { renderMentions } from "@/components/social/mention-text";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { CommentMenu } from "./comment-menu"; import { CommentMenu } from "./comment-menu";
// Split a comment body into text + clickable @handle links. The capturing
// group keeps the delimiters so they can be turned into profile links.
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
function renderBody(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function CommentRow({ export function CommentRow({
comment, comment,
viewerId, viewerId,
@@ -172,7 +153,7 @@ export function CommentRow({
@{comment.reply_to_handle}{" "} @{comment.reply_to_handle}{" "}
</span> </span>
) : null} ) : null}
{renderBody(comment.body)} {renderMentions(comment.body)}
</p> </p>
{/* Inline action row */} {/* Inline action row */}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* MentionText — renders user-authored plain text with `@handle` tokens turned
* into profile links.
*
* The single place that decides what a mention looks like. Shared by comment
* bodies and profile bios so the two can't drift apart, and so the
* "@handle" grammar lives next to the regex the composer
* (`MentionTextarea`) uses to detect it.
*
* Input is always PLAIN TEXT, never HTML — we split on the token and emit
* React nodes, so nothing user-supplied is ever interpreted as markup.
* `whitespace-pre-line` on the wrapper preserves the author's line breaks
* (bios are multi-line) without needing <br> injection.
*/
import Link from "next/link";
import { cn } from "@/lib/utils";
/** Capturing group keeps the delimiters, so split() yields text and tokens
* alternately. Handle charset matches MentionTextarea's MENTION_RE. */
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
/** Split text into plain runs + clickable @handle links. Exported for callers
* that need the nodes without the wrapper element. */
export function renderMentions(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function MentionText({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<span className={cn("whitespace-pre-line", className)}>
{renderMentions(text)}
</span>
);
}
+29 -7
View File
@@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8;
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/; const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
type Mention = { start: number; query: string }; type Mention = { start: number; query: string };
type PanelPos = { left: number; bottom: number; width: number }; /** Exactly one of `top`/`bottom` is set — see the flip logic in detect(). */
type PanelPos = { left: number; width: number; top?: number; bottom?: number };
/** Keep in sync with the panel's `max-h-56` (14rem). Used to decide whether
* the suggestion list fits above the field or has to flip below it. */
const PANEL_MAX_H = 224;
export const MentionTextarea = forwardRef< export const MentionTextarea = forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
{ {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
/** Called on Enter (no Shift) when the suggestion list is closed. */ /** Called on Enter (no Shift) when the suggestion list is closed. Omit for
* multi-line fields — without it, Enter inserts a newline as usual. */
onSubmit?: () => void; onSubmit?: () => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
maxLength?: number; maxLength?: number;
rows?: number; rows?: number;
/** Forwarded to the <textarea> so a <Label htmlFor> can address it. */
id?: string;
} }
>(function MentionTextarea( >(function MentionTextarea(
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 }, { value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
ref, ref,
) { ) {
const innerRef = useRef<HTMLTextAreaElement>(null); const innerRef = useRef<HTMLTextAreaElement>(null);
@@ -73,10 +81,18 @@ export const MentionTextarea = forwardRef<
setMention({ start: caret - m[2].length - 1, query: m[2] }); setMention({ start: caret - m[2].length - 1, query: m[2] });
setActive(0); setActive(0);
const r = el.getBoundingClientRect(); 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).
// But flip BELOW when the field is too near the top of the viewport for the
// panel to fit — otherwise the list renders off-screen and the feature
// looks broken (e.g. the bio field high up on the profile editor).
const fitsAbove = r.top >= PANEL_MAX_H + 8;
setPos({ setPos({
left: r.left, left: r.left,
bottom: window.innerHeight - r.top + 6,
width: r.width, width: r.width,
...(fitsAbove
? { bottom: window.innerHeight - r.top + 6 }
: { top: r.bottom + 6 }),
}); });
} }
@@ -143,9 +159,12 @@ export const MentionTextarea = forwardRef<
return; return;
} }
} }
if (e.key === "Enter" && !e.shiftKey) { // Only hijack Enter when the caller actually wants submit-on-Enter.
// Without the `onSubmit` guard we'd swallow the keystroke for multi-line
// callers (e.g. the profile bio), leaving them unable to type a newline.
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
e.preventDefault(); e.preventDefault();
onSubmit?.(); onSubmit();
} }
} }
@@ -153,6 +172,7 @@ export const MentionTextarea = forwardRef<
<div className="relative flex-1"> <div className="relative flex-1">
<textarea <textarea
ref={innerRef} ref={innerRef}
id={id}
value={value} value={value}
rows={rows} rows={rows}
maxLength={maxLength} maxLength={maxLength}
@@ -180,8 +200,10 @@ export const MentionTextarea = forwardRef<
style={{ style={{
position: "fixed", position: "fixed",
left: pos.left, left: pos.left,
bottom: pos.bottom,
width: pos.width, width: pos.width,
...(pos.top !== undefined
? { top: pos.top }
: { bottom: pos.bottom }),
}} }}
// pointer-events-auto: the comments drawer is a modal vaul sheet // pointer-events-auto: the comments drawer is a modal vaul sheet
// (body gets pointer-events:none); without this the list would be // (body gets pointer-events:none); without this the list would be
+5
View File
@@ -36,6 +36,9 @@ export type User = {
/** True when an admin has linked this account to the disciples directory — /** True when an admin has linked this account to the disciples directory —
* one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */ * one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */
is_direct_disciple?: boolean; is_direct_disciple?: boolean;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null /** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
* when unknown. Captured best-effort at signup; user-editable. */ * when unknown. Captured best-effort at signup; user-editable. */
country: string | null; country: string | null;
@@ -225,6 +228,8 @@ export const auth = {
name?: string; name?: string;
avatar_url?: string | null; avatar_url?: string | null;
handle?: string; handle?: string;
/** Plain text, max 200 chars; "" clears it. */
bio?: string;
country?: string | null; country?: string | null;
sampradaya?: string; sampradaya?: string;
gender?: string | null; gender?: string | null;
+3
View File
@@ -182,6 +182,9 @@ export type PublicProfile = {
handle: string; handle: string;
name: string; name: string;
avatar_url: string | null; avatar_url: string | null;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
joined_at: string; joined_at: string;
/** ISO-3166-1 alpha-2 account country, or null when unknown. */ /** ISO-3166-1 alpha-2 account country, or null when unknown. */
country: string | null; country: string | null;