Files
Nexus/apps/web/src/components/comments/CommentInput.tsx
T

248 lines
8.1 KiB
TypeScript

"use client";
import type { CommentEntityType } from "@capakraken/shared";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
interface CommentInputProps {
entityType: CommentEntityType;
entityId: string;
parentId?: string;
onSubmit: (body: string) => void;
onCancel?: () => void;
isSubmitting?: boolean;
placeholder?: string;
autoFocus?: boolean;
}
interface MentionCandidate {
id: string;
name: string | null;
email: string;
}
export function CommentInput({
entityType,
entityId,
parentId: _parentId,
onSubmit,
onCancel,
isSubmitting = false,
placeholder = "Write a comment... Use @ to mention someone",
autoFocus = false,
}: CommentInputProps) {
const [body, setBody] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const usersQuery = trpc.comment.listMentionCandidates.useQuery({
entityType,
entityId,
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
}, {
enabled: mentionQuery !== null,
staleTime: 60_000,
});
const filteredUsers: MentionCandidate[] =
mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : [];
const mentionOpen = mentionQuery !== null && filteredUsers.length > 0;
const { panelRef, position } = useAnchoredOverlay<HTMLTextAreaElement>({
open: mentionOpen,
onClose: () => setMentionQuery(null),
align: "start",
triggerRef: textareaRef,
});
// Reset mention index when filtered list changes
useEffect(() => {
setMentionIndex(0);
}, [mentionQuery]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setBody(value);
setCursorPosition(cursor);
// Detect if we are in a @mention context
const textBeforeCursor = value.slice(0, cursor);
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]!);
} else {
setMentionQuery(null);
}
},
[],
);
const insertMention = useCallback(
(user: MentionCandidate) => {
const textBeforeCursor = body.slice(0, cursorPosition);
const textAfterCursor = body.slice(cursorPosition);
// Find the @ that triggered this mention
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
if (!atMatch) return;
const atStart = textBeforeCursor.length - atMatch[0].length;
const displayName = user.name ?? user.email;
const mentionText = `@[${displayName}](${user.id}) `;
const newBody =
textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
setBody(newBody);
setMentionQuery(null);
// Focus and set cursor position
const newCursorPos = atStart + mentionText.length;
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.selectionStart = newCursorPos;
ta.selectionEnd = newCursorPos;
}
});
},
[body, cursorPosition],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle mention dropdown navigation
if (mentionQuery !== null && filteredUsers.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0,
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1,
);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insertMention(filteredUsers[mentionIndex]!);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionQuery(null);
return;
}
}
// Submit on Ctrl+Enter / Cmd+Enter
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (body.trim().length > 0 && !isSubmitting) {
onSubmit(body.trim());
setBody("");
}
}
},
[mentionQuery, filteredUsers, mentionIndex, insertMention, body, isSubmitting, onSubmit],
);
function handleSubmitClick() {
if (body.trim().length > 0 && !isSubmitting) {
onSubmit(body.trim());
setBody("");
}
}
return (
<div className="relative">
<textarea
ref={textareaRef}
value={body}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
disabled={isSubmitting}
rows={3}
className="w-full rounded-xl border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:border-brand-400 dark:focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-brand-100 dark:focus:ring-sky-900 disabled:opacity-60 resize-y"
/>
{/* Mention autocomplete dropdown */}
{mentionOpen && typeof document !== "undefined"
? createPortal(
<div
ref={panelRef}
className="fixed z-[9998] w-72 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
style={{
top: position.top,
left: position.left,
}}
>
{filteredUsers.map((user, idx) => (
<button
key={user.id}
type="button"
onMouseDown={(e) => {
e.preventDefault();
insertMention(user);
}}
className={`flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${
idx === mentionIndex
? "bg-brand-50 dark:bg-sky-900/40 text-brand-700 dark:text-sky-200"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700 dark:bg-sky-800 dark:text-sky-200">
{(user.name ?? user.email).charAt(0).toUpperCase()}
</span>
<span className="truncate">
<span className="font-medium">{user.name ?? "—"}</span>
<span className="ml-1 text-xs text-gray-400">{user.email}</span>
</span>
</button>
))}
</div>,
document.body,
)
: null}
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-400">
Ctrl+Enter to submit
</span>
<div className="flex gap-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
>
Cancel
</button>
)}
<button
type="button"
onClick={handleSubmitClick}
disabled={body.trim().length === 0 || isSubmitting}
className="rounded-lg bg-brand-600 dark:bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-brand-700 dark:hover:bg-sky-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSubmitting ? "Sending..." : "Comment"}
</button>
</div>
</div>
</div>
);
}