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 -21
View File
@@ -18,29 +18,10 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar";
import { renderMentions } from "@/components/social/mention-text";
import { timeAgo } from "@/lib/time";
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({
comment,
viewerId,
@@ -172,7 +153,7 @@ export function CommentRow({
@{comment.reply_to_handle}{" "}
</span>
) : null}
{renderBody(comment.body)}
{renderMentions(comment.body)}
</p>
{/* 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})$/;
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 <textarea> so a <Label htmlFor> can address it. */
id?: string;
}
>(function MentionTextarea(
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 },
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
ref,
) {
const innerRef = useRef<HTMLTextAreaElement>(null);
@@ -73,10 +81,18 @@ export const MentionTextarea = forwardRef<
setMention({ start: caret - m[2].length - 1, query: m[2] });
setActive(0);
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({
left: r.left,
bottom: window.innerHeight - r.top + 6,
width: r.width,
...(fitsAbove
? { bottom: window.innerHeight - r.top + 6 }
: { top: r.bottom + 6 }),
});
}
@@ -143,9 +159,12 @@ export const MentionTextarea = forwardRef<
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();
onSubmit?.();
onSubmit();
}
}
@@ -153,6 +172,7 @@ export const MentionTextarea = forwardRef<
<div className="relative flex-1">
<textarea
ref={innerRef}
id={id}
value={value}
rows={rows}
maxLength={maxLength}
@@ -180,8 +200,10 @@ export const MentionTextarea = forwardRef<
style={{
position: "fixed",
left: pos.left,
bottom: pos.bottom,
width: pos.width,
...(pos.top !== undefined
? { top: pos.top }
: { bottom: pos.bottom }),
}}
// pointer-events-auto: the comments drawer is a modal vaul sheet
// (body gets pointer-events:none); without this the list would be