mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: implement inline editing for comments
This commit is contained in:
parent
13135ec51b
commit
9d11446553
5 changed files with 111 additions and 15 deletions
|
|
@ -86,9 +86,11 @@ export function CommentComposer({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
initialValue = "",
|
||||||
}: CommentComposerProps) {
|
}: CommentComposerProps) {
|
||||||
const [displayContent, setDisplayContent] = useState("");
|
const [displayContent, setDisplayContent] = useState(initialValue);
|
||||||
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
||||||
|
const [mentionsInitialized, setMentionsInitialized] = useState(false);
|
||||||
const [mentionState, setMentionState] = useState<MentionState>({
|
const [mentionState, setMentionState] = useState<MentionState>({
|
||||||
isActive: false,
|
isActive: false,
|
||||||
query: "",
|
query: "",
|
||||||
|
|
@ -200,6 +202,33 @@ export function CommentComposer({
|
||||||
setInsertedMentions([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && textareaRef.current) {
|
if (autoFocus && textareaRef.current) {
|
||||||
textareaRef.current.focus();
|
textareaRef.current.focus();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface CommentComposerProps {
|
||||||
onSubmit: (content: string) => void;
|
onSubmit: (content: string) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
initialValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MentionState {
|
export interface MentionState {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||||
import { CommentActions } from "./comment-actions";
|
import { CommentActions } from "./comment-actions";
|
||||||
import type { CommentItemProps } from "./types";
|
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 {
|
function renderMentions(content: string): React.ReactNode {
|
||||||
// Match @{DisplayName} format from backend
|
// Match @{DisplayName} format from backend
|
||||||
const mentionPattern = /@\{([^}]+)\}/g;
|
const mentionPattern = /@\{([^}]+)\}/g;
|
||||||
|
|
@ -99,9 +105,15 @@ function renderMentions(content: string): React.ReactNode {
|
||||||
export function CommentItem({
|
export function CommentItem({
|
||||||
comment,
|
comment,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onEditSubmit,
|
||||||
|
onEditCancel,
|
||||||
onDelete,
|
onDelete,
|
||||||
onReply,
|
onReply,
|
||||||
isReply = false,
|
isReply = false,
|
||||||
|
isEditing = false,
|
||||||
|
isSubmitting = false,
|
||||||
|
members = [],
|
||||||
|
membersLoading = false,
|
||||||
}: CommentItemProps) {
|
}: CommentItemProps) {
|
||||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
|
|
@ -111,6 +123,10 @@ export function CommentItem({
|
||||||
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
||||||
const email = comment.author?.email || "";
|
const email = comment.author?.email || "";
|
||||||
|
|
||||||
|
const handleEditSubmit = (content: string) => {
|
||||||
|
onEditSubmit?.(comment.id, content);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("group flex gap-3")}>
|
<div className={cn("group flex gap-3")}>
|
||||||
<Avatar className="size-8 shrink-0">
|
<Avatar className="size-8 shrink-0">
|
||||||
|
|
@ -131,21 +147,39 @@ export function CommentItem({
|
||||||
{comment.isEdited && (
|
{comment.isEdited && (
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
|
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto">
|
{!isEditing && (
|
||||||
<CommentActions
|
<div className="ml-auto">
|
||||||
canEdit={comment.canEdit}
|
<CommentActions
|
||||||
canDelete={comment.canDelete}
|
canEdit={comment.canEdit}
|
||||||
onEdit={() => onEdit?.(comment.id)}
|
canDelete={comment.canDelete}
|
||||||
onDelete={() => onDelete?.(comment.id)}
|
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>
|
) : (
|
||||||
|
<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">
|
{!isReply && onReply && !isEditing && (
|
||||||
{renderMentions(comment.contentRendered)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isReply && onReply && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@ export interface CommentData {
|
||||||
export interface CommentItemProps {
|
export interface CommentItemProps {
|
||||||
comment: CommentData;
|
comment: CommentData;
|
||||||
onEdit?: (commentId: number) => void;
|
onEdit?: (commentId: number) => void;
|
||||||
|
onEditSubmit?: (commentId: number, content: string) => void;
|
||||||
|
onEditCancel?: () => void;
|
||||||
onDelete?: (commentId: number) => void;
|
onDelete?: (commentId: number) => void;
|
||||||
onReply?: (commentId: number) => void;
|
onReply?: (commentId: number) => void;
|
||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
|
isEditing?: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
members?: Array<{ id: string; displayName: string | null; email: string; avatarUrl?: string | null }>;
|
||||||
|
membersLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentActionsProps {
|
export interface CommentActionsProps {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export function CommentThread({
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
|
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
|
||||||
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
|
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||||
|
|
||||||
const parentComment = {
|
const parentComment = {
|
||||||
id: thread.id,
|
id: thread.id,
|
||||||
|
|
@ -45,6 +46,19 @@ export function CommentThread({
|
||||||
setIsReplyComposerOpen(false);
|
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 hasReplies = thread.replies.length > 0;
|
||||||
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
|
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
|
||||||
|
|
||||||
|
|
@ -53,8 +67,14 @@ export function CommentThread({
|
||||||
{/* Parent comment */}
|
{/* Parent comment */}
|
||||||
<CommentItem
|
<CommentItem
|
||||||
comment={parentComment}
|
comment={parentComment}
|
||||||
onEdit={(id) => onEditComment(id, "")}
|
onEdit={handleEditStart}
|
||||||
|
onEditSubmit={handleEditSubmit}
|
||||||
|
onEditCancel={handleEditCancel}
|
||||||
onDelete={onDeleteComment}
|
onDelete={onDeleteComment}
|
||||||
|
isEditing={editingCommentId === parentComment.id}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
members={members}
|
||||||
|
membersLoading={membersLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Replies and actions - using flex layout with connector */}
|
{/* Replies and actions - using flex layout with connector */}
|
||||||
|
|
@ -92,8 +112,14 @@ export function CommentThread({
|
||||||
key={reply.id}
|
key={reply.id}
|
||||||
comment={reply}
|
comment={reply}
|
||||||
isReply
|
isReply
|
||||||
onEdit={(id) => onEditComment(id, "")}
|
onEdit={handleEditStart}
|
||||||
|
onEditSubmit={handleEditSubmit}
|
||||||
|
onEditCancel={handleEditCancel}
|
||||||
onDelete={onDeleteComment}
|
onDelete={onDeleteComment}
|
||||||
|
isEditing={editingCommentId === reply.id}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
members={members}
|
||||||
|
membersLoading={membersLoading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue