feat(web): add comment panel component

This commit is contained in:
CREDO23 2026-01-16 10:59:22 +02:00
parent 0e8bdf7ace
commit 66275f1b53
3 changed files with 316 additions and 3 deletions

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { CommentComposer } from "@/components/chat-comments/comment-composer/comment-composer";
import { CommentItem } from "@/components/chat-comments/comment-item/comment-item";
import type { CommentData } from "@/components/chat-comments/comment-item/types";
import { CommentPanel } from "@/components/chat-comments/comment-panel/comment-panel";
import { CommentThread } from "@/components/chat-comments/comment-thread/comment-thread";
import type { CommentThreadData } from "@/components/chat-comments/comment-thread/types";
import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker";
@ -182,6 +183,145 @@ const fakeThreadsData: CommentThreadData[] = [
},
],
},
{
id: 6,
messageId: 101,
content: "I think we should also consider edge cases here. What happens if the input is empty?",
contentRendered: "I think we should also consider edge cases here. What happens if the input is empty?",
author: {
id: "550e8400-e29b-41d4-a716-446655440001",
displayName: "Alice Smith",
email: "alice@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 10800000).toISOString(),
updatedAt: new Date(Date.now() - 10800000).toISOString(),
isEdited: false,
canEdit: false,
canDelete: true,
replyCount: 3,
replies: [
{
id: 7,
content: "Good point! We should add validation.",
contentRendered: "Good point! We should add validation.",
author: {
id: "550e8400-e29b-41d4-a716-446655440002",
displayName: "Bob Johnson",
email: "bob.johnson@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 10000000).toISOString(),
updatedAt: new Date(Date.now() - 10000000).toISOString(),
isEdited: false,
canEdit: true,
canDelete: true,
},
{
id: 8,
content: "I'll handle the validation logic @Alice Smith",
contentRendered: "I'll handle the validation logic @Alice Smith",
author: {
id: "550e8400-e29b-41d4-a716-446655440003",
displayName: "Charlie Brown",
email: "charlie@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 9500000).toISOString(),
updatedAt: new Date(Date.now() - 9500000).toISOString(),
isEdited: false,
canEdit: false,
canDelete: true,
},
{
id: 9,
content: "Thanks @Charlie Brown!",
contentRendered: "Thanks @Charlie Brown!",
author: {
id: "550e8400-e29b-41d4-a716-446655440001",
displayName: "Alice Smith",
email: "alice@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 9000000).toISOString(),
updatedAt: new Date(Date.now() - 9000000).toISOString(),
isEdited: false,
canEdit: false,
canDelete: true,
},
],
},
{
id: 10,
messageId: 101,
content: "The performance looks great in the benchmarks. Nice work everyone!",
contentRendered: "The performance looks great in the benchmarks. Nice work everyone!",
author: {
id: "550e8400-e29b-41d4-a716-446655440005",
displayName: "Emma Davis",
email: "emma@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 14400000).toISOString(),
updatedAt: new Date(Date.now() - 14400000).toISOString(),
isEdited: false,
canEdit: false,
canDelete: true,
replyCount: 0,
replies: [],
},
{
id: 11,
messageId: 101,
content: "Should we schedule a review meeting for this?",
contentRendered: "Should we schedule a review meeting for this?",
author: {
id: "550e8400-e29b-41d4-a716-446655440004",
displayName: null,
email: "david.wilson@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 18000000).toISOString(),
updatedAt: new Date(Date.now() - 18000000).toISOString(),
isEdited: true,
canEdit: true,
canDelete: true,
replyCount: 2,
replies: [
{
id: 12,
content: "Yes, let's do it tomorrow at 10am",
contentRendered: "Yes, let's do it tomorrow at 10am",
author: {
id: "550e8400-e29b-41d4-a716-446655440002",
displayName: "Bob Johnson",
email: "bob.johnson@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 17000000).toISOString(),
updatedAt: new Date(Date.now() - 17000000).toISOString(),
isEdited: false,
canEdit: true,
canDelete: true,
},
{
id: 13,
content: "Works for me!",
contentRendered: "Works for me!",
author: {
id: "550e8400-e29b-41d4-a716-446655440005",
displayName: "Emma Davis",
email: "emma@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 16000000).toISOString(),
updatedAt: new Date(Date.now() - 16000000).toISOString(),
isEdited: false,
canEdit: false,
canDelete: true,
},
],
},
];
export default function ChatCommentsPreviewPage() {
@ -203,7 +343,8 @@ export default function ChatCommentsPreviewPage() {
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Comment Composer</h2>
<p className="text-sm text-muted-foreground">
Type @ to trigger mention picker. Use Tab/Shift+Tab/Arrow keys to navigate, Enter to select.
Type @ to trigger mention picker. Use Tab/Shift+Tab/Arrow keys to navigate, Enter to
select.
</p>
<div className="max-w-md rounded-lg border p-4">
@ -226,11 +367,62 @@ export default function ChatCommentsPreviewPage() {
)}
</section>
{/* Comment Panel Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Comment Panel</h2>
<p className="text-sm text-muted-foreground">
Full panel with scrollable threads and composer. Shows alongside AI responses.
</p>
<div className="flex gap-8">
<div className="space-y-2">
<h3 className="text-sm font-medium">With comments</h3>
<CommentPanel
messageId={101}
threads={fakeThreadsData}
members={fakeMembersData}
onCreateComment={(content) => alert(`Create: ${content}`)}
onCreateReply={(id, content) => alert(`Reply ${id}: ${content}`)}
onEditComment={(id) => alert(`Edit ${id}`)}
onDeleteComment={(id) => alert(`Delete ${id}`)}
/>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Empty state</h3>
<CommentPanel
messageId={102}
threads={[]}
members={fakeMembersData}
onCreateComment={(content) => alert(`Create: ${content}`)}
onCreateReply={(id, content) => alert(`Reply ${id}: ${content}`)}
onEditComment={(id) => alert(`Edit ${id}`)}
onDeleteComment={(id) => alert(`Delete ${id}`)}
/>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Loading</h3>
<CommentPanel
messageId={103}
threads={[]}
members={[]}
isLoading
onCreateComment={() => {}}
onCreateReply={() => {}}
onEditComment={() => {}}
onDeleteComment={() => {}}
/>
</div>
</div>
</section>
{/* Comment Thread Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Comment Thread</h2>
<p className="text-sm text-muted-foreground">
Two top-level comments with replies. Click Reply to open composer. Click the replies count to collapse/expand.
Two top-level comments with replies. Click Reply to open composer. Click the replies
count to collapse/expand.
</p>
<div className="max-w-lg space-y-6 rounded-lg border p-4">
@ -266,7 +458,9 @@ export default function ChatCommentsPreviewPage() {
{/* Member Mention Picker Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Member Mention Picker (Standalone)</h2>
<h2 className="text-xl font-semibold border-b pb-2">
Member Mention Picker (Standalone)
</h2>
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-4">

View file

@ -0,0 +1,103 @@
"use client";
import { MessageSquarePlus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread";
import type { CommentPanelProps } from "./types";
export function CommentPanel({
threads,
members,
membersLoading = false,
isLoading = false,
onCreateComment,
onCreateReply,
onEditComment,
onDeleteComment,
isSubmitting = false,
maxHeight = 400,
}: CommentPanelProps) {
const [isComposerOpen, setIsComposerOpen] = useState(false);
const handleCommentSubmit = (content: string) => {
onCreateComment(content);
setIsComposerOpen(false);
};
const handleComposerCancel = () => {
setIsComposerOpen(false);
};
if (isLoading) {
return (
<div className="flex min-h-[120px] w-80 items-center justify-center rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Loading comments...
</div>
</div>
);
}
const hasThreads = threads.length > 0;
return (
<div className="flex w-80 flex-col rounded-lg border bg-card">
{hasThreads ? (
<div
className="overflow-y-auto"
style={{ maxHeight }}
>
<div className="space-y-4 p-4">
{threads.map((thread) => (
<CommentThread
key={thread.id}
thread={thread}
members={members}
membersLoading={membersLoading}
onCreateReply={onCreateReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
isSubmitting={isSubmitting}
/>
))}
</div>
</div>
) : (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground/70">
Start a conversation about this response
</p>
</div>
)}
<div className="border-t p-3">
{isComposerOpen ? (
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a comment..."
submitLabel="Comment"
isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit}
onCancel={handleComposerCancel}
autoFocus
/>
) : (
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground"
onClick={() => setIsComposerOpen(true)}
>
<MessageSquarePlus className="mr-2 size-4" />
Add a comment...
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import type { CommentThreadData } from "../comment-thread/types";
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentPanelProps {
messageId: number;
threads: CommentThreadData[];
members: MemberOption[];
membersLoading?: boolean;
isLoading?: boolean;
onCreateComment: (content: string) => void;
onCreateReply: (commentId: number, content: string) => void;
onEditComment: (commentId: number, content: string) => void;
onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean;
maxHeight?: number;
}