diff --git a/app/(app)/u/[handle]/layout.tsx b/app/(app)/u/[handle]/layout.tsx index 212d64c2..af0fa244 100644 --- a/app/(app)/u/[handle]/layout.tsx +++ b/app/(app)/u/[handle]/layout.tsx @@ -130,6 +130,7 @@ export default function PublicProfileLayout(props: { joinedAt={data.joined_at} country={data.country} isDirectDisciple={!!data.is_direct_disciple} + bio={data.bio} />

@@ -176,6 +177,7 @@ export default function PublicProfileLayout(props: { joinedAt={data.joined_at} country={data.country} isDirectDisciple={!!data.is_direct_disciple} + bio={data.bio} /> diff --git a/app/(app)/u/me/layout.tsx b/app/(app)/u/me/layout.tsx index 701f532d..b8a1a6e4 100644 --- a/app/(app)/u/me/layout.tsx +++ b/app/(app)/u/me/layout.tsx @@ -141,6 +141,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { handle={user.handle} joinedAt={user.created_at} isDirectDisciple={!!user.is_direct_disciple} + bio={user.bio} editHref="/settings/profile" postCount={counts?.posts} followerCount={counts?.followers} diff --git a/components/profile/profile-edit-form.tsx b/components/profile/profile-edit-form.tsx index 540eeecb..134062ab 100644 --- a/components/profile/profile-edit-form.tsx +++ b/components/profile/profile-edit-form.tsx @@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; 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 { DatePicker } from "@/components/ui/date-picker"; @@ -70,6 +75,7 @@ export function ProfileEditForm({ const [name, setName] = useState(user.name); const [handle, setHandle] = useState(user.handle); const [avatarUrl, setAvatarUrl] = useState(user.avatar_url); + const [bio, setBio] = useState(user.bio ?? ""); const [country, setCountry] = useState(user.country ?? ""); // Devotional details. Each "" means "not specified". const [sampradaya, setSampradaya] = useState(user.sampradaya ?? ""); @@ -92,11 +98,15 @@ export function ProfileEditForm({ const origRounds = user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : ""; 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 = name.trim() !== user.name || handle.trim() !== user.handle || avatarUrl !== user.avatar_url || + bio.trim() !== (user.bio ?? "") || country !== (user.country ?? "") || sampradaya !== (user.sampradaya ?? "") || gender !== (user.gender ?? "") || @@ -108,6 +118,13 @@ export function ProfileEditForm({ e.preventDefault(); 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). if (rounds !== "") { const n = Number(rounds); @@ -126,6 +143,8 @@ export function ProfileEditForm({ if (name.trim() !== user.name) patch.name = name.trim(); if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? ""; 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; // "" clears the lineage; a code sets it. if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya; @@ -221,6 +240,40 @@ export function ProfileEditForm({ />

+
+ + {/* 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. */} + 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" + /> +
+

+ Type @ to + mention another devotee — mentions become links on your profile. +

+ BIO_MAX + ? "font-medium text-destructive" + : "text-muted-foreground", + )} + > + {bioCount}/{BIO_MAX} + +
+
+
{/* "@" sits inside the field as a fixed, non-editable prefix — diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index d935a457..d55d8568 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -35,6 +35,7 @@ import { cn } from "@/lib/utils"; import type { RelationshipResult, RelationshipState } from "@/lib/social"; import { ProfileActions } from "@/components/profile/profile-actions"; import { DiscipleBadge } from "@/components/profile/disciple-badge"; +import { MentionText } from "@/components/social/mention-text"; import { UserAvatar } from "@/components/ui/user-avatar"; import { FollowListDrawer, @@ -55,6 +56,7 @@ export function ProfileHeader({ country, interactiveCounts = true, isDirectDisciple = false, + bio, }: { avatarURL: string | null; name: string; @@ -87,6 +89,10 @@ export function ProfileHeader({ * one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait * 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. */ + bio?: string | null; }) { // Owner affordances ride on `editHref`; the follow-action row rides on // `enableActions`. Mutually exclusive by construction. @@ -203,6 +209,17 @@ export function ProfileHeader({ onResult={handleResult} /> ) : 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() ? ( + + ) : null} {avatarURL ? ( { - if (part.length > 1 && part[0] === "@") { - return ( - - {part} - - ); - } - return part; - }); -} - export function CommentRow({ comment, viewerId, @@ -172,7 +153,7 @@ export function CommentRow({ @{comment.reply_to_handle}{" "} ) : null} - {renderBody(comment.body)} + {renderMentions(comment.body)}

{/* Inline action row */} diff --git a/components/social/mention-text.tsx b/components/social/mention-text.tsx new file mode 100644 index 00000000..0419a661 --- /dev/null +++ b/components/social/mention-text.tsx @@ -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
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 ( + + {part} + + ); + } + return part; + }); +} + +export function MentionText({ + text, + className, +}: { + text: string; + className?: string; +}) { + return ( + + {renderMentions(text)} + + ); +} diff --git a/components/social/mention-textarea.tsx b/components/social/mention-textarea.tsx index 8d262e9f..da16145e 100644 --- a/components/social/mention-textarea.tsx +++ b/components/social/mention-textarea.tsx @@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8; const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/; 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< HTMLTextAreaElement, { value: string; 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; placeholder?: string; className?: string; maxLength?: number; rows?: number; + /** Forwarded to the