feat: implement inline editing for comments

This commit is contained in:
CREDO23 2026-01-16 20:27:00 +02:00
parent 13135ec51b
commit 9d11446553
5 changed files with 111 additions and 15 deletions

View file

@ -86,9 +86,11 @@ export function CommentComposer({
onSubmit,
onCancel,
autoFocus = false,
initialValue = "",
}: CommentComposerProps) {
const [displayContent, setDisplayContent] = useState("");
const [displayContent, setDisplayContent] = useState(initialValue);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
const [mentionsInitialized, setMentionsInitialized] = useState(false);
const [mentionState, setMentionState] = useState<MentionState>({
isActive: false,
query: "",
@ -200,6 +202,33 @@ export function CommentComposer({
setInsertedMentions([]);
};
// Pre-populate insertedMentions from initialValue when members are loaded
useEffect(() => {
if (mentionsInitialized || !initialValue || members.length === 0) return;
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
const foundMentions: InsertedMention[] = [];
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(initialValue)) !== null) {
const displayName = match[1];
const member = members.find(
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
);
if (member) {
const exists = foundMentions.some((m) => m.id === member.id);
if (!exists) {
foundMentions.push({ id: member.id, displayName });
}
}
}
if (foundMentions.length > 0) {
setInsertedMentions(foundMentions);
}
setMentionsInitialized(true);
}, [initialValue, members, mentionsInitialized]);
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();

View file

@ -9,6 +9,7 @@ export interface CommentComposerProps {
onSubmit: (content: string) => void;
onCancel?: () => void;
autoFocus?: boolean;
initialValue?: string;
}
export interface MentionState {

View file

@ -6,6 +6,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentActions } from "./comment-actions";
import type { CommentItemProps } from "./types";
@ -67,6 +68,11 @@ function formatTimestamp(dateString: string): string {
);
}
function convertRenderedToDisplay(contentRendered: string): string {
// Convert @{DisplayName} format to @DisplayName for editing
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
}
function renderMentions(content: string): React.ReactNode {
// Match @{DisplayName} format from backend
const mentionPattern = /@\{([^}]+)\}/g;
@ -99,9 +105,15 @@ function renderMentions(content: string): React.ReactNode {
export function CommentItem({
comment,
onEdit,
onEditSubmit,
onEditCancel,
onDelete,
onReply,
isReply = false,
isEditing = false,
isSubmitting = false,
members = [],
membersLoading = false,
}: CommentItemProps) {
const [{ data: currentUser }] = useAtom(currentUserAtom);
@ -111,6 +123,10 @@ export function CommentItem({
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || "";
const handleEditSubmit = (content: string) => {
onEditSubmit?.(comment.id, content);
};
return (
<div className={cn("group flex gap-3")}>
<Avatar className="size-8 shrink-0">
@ -131,21 +147,39 @@ export function CommentItem({
{comment.isEdited && (
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
)}
<div className="ml-auto">
<CommentActions
canEdit={comment.canEdit}
canDelete={comment.canDelete}
onEdit={() => onEdit?.(comment.id)}
onDelete={() => onDelete?.(comment.id)}
{!isEditing && (
<div className="ml-auto">
<CommentActions
canEdit={comment.canEdit}
canDelete={comment.canDelete}
onEdit={() => onEdit?.(comment.id)}
onDelete={() => onDelete?.(comment.id)}
/>
</div>
)}
</div>
{isEditing ? (
<div className="mt-1">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Edit your comment..."
submitLabel="Save"
isSubmitting={isSubmitting}
onSubmit={handleEditSubmit}
onCancel={onEditCancel}
initialValue={convertRenderedToDisplay(comment.contentRendered)}
autoFocus
/>
</div>
</div>
) : (
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap wrap-break-word">
{renderMentions(comment.contentRendered)}
</div>
)}
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap wrap-break-word">
{renderMentions(comment.contentRendered)}
</div>
{!isReply && onReply && (
{!isReply && onReply && !isEditing && (
<Button
variant="ghost"
size="sm"

View file

@ -20,9 +20,15 @@ export interface CommentData {
export interface CommentItemProps {
comment: CommentData;
onEdit?: (commentId: number) => void;
onEditSubmit?: (commentId: number, content: string) => void;
onEditCancel?: () => void;
onDelete?: (commentId: number) => void;
onReply?: (commentId: number) => void;
isReply?: boolean;
isEditing?: boolean;
isSubmitting?: boolean;
members?: Array<{ id: string; displayName: string | null; email: string; avatarUrl?: string | null }>;
membersLoading?: boolean;
}
export interface CommentActionsProps {

View file

@ -18,6 +18,7 @@ export function CommentThread({
}: CommentThreadProps) {
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const parentComment = {
id: thread.id,
@ -45,6 +46,19 @@ export function CommentThread({
setIsReplyComposerOpen(false);
};
const handleEditStart = (commentId: number) => {
setEditingCommentId(commentId);
};
const handleEditSubmit = (commentId: number, content: string) => {
onEditComment(commentId, content);
setEditingCommentId(null);
};
const handleEditCancel = () => {
setEditingCommentId(null);
};
const hasReplies = thread.replies.length > 0;
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
@ -53,8 +67,14 @@ export function CommentThread({
{/* Parent comment */}
<CommentItem
comment={parentComment}
onEdit={(id) => onEditComment(id, "")}
onEdit={handleEditStart}
onEditSubmit={handleEditSubmit}
onEditCancel={handleEditCancel}
onDelete={onDeleteComment}
isEditing={editingCommentId === parentComment.id}
isSubmitting={isSubmitting}
members={members}
membersLoading={membersLoading}
/>
{/* Replies and actions - using flex layout with connector */}
@ -92,8 +112,14 @@ export function CommentThread({
key={reply.id}
comment={reply}
isReply
onEdit={(id) => onEditComment(id, "")}
onEdit={handleEditStart}
onEditSubmit={handleEditSubmit}
onEditCancel={handleEditCancel}
onDelete={onDeleteComment}
isEditing={editingCommentId === reply.id}
isSubmitting={isSubmitting}
members={members}
membersLoading={membersLoading}
/>
))}
</div>