feat: profile bio
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user