feat: profile bio
This commit is contained in:
@@ -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<string | null>(user.avatar_url);
|
||||
const [bio, setBio] = useState<string>(user.bio ?? "");
|
||||
const [country, setCountry] = useState<string>(user.country ?? "");
|
||||
// Devotional details. Each "" means "not specified".
|
||||
const [sampradaya, setSampradaya] = useState<string>(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({
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="profile-handle">Handle</Label>
|
||||
{/* "@" sits inside the field as a fixed, non-editable prefix —
|
||||
|
||||
@@ -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() ? (
|
||||
<MentionText
|
||||
text={bio.trim()}
|
||||
className="self-stretch text-left text-sm sm:text-[15px] leading-relaxed text-foreground"
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
{avatarURL ? (
|
||||
<AvatarZoomModal
|
||||
|
||||
Reference in New Issue
Block a user