feat(web): add comment item component

This commit is contained in:
CREDO23 2026-01-15 20:45:16 +02:00
parent 8bfcfdd084
commit 8a1e0fb013
4 changed files with 327 additions and 0 deletions

View file

@ -2,6 +2,8 @@
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 { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker";
import type { MemberOption } from "@/components/chat-comments/member-mention-picker/types";
@ -38,6 +40,57 @@ const fakeMembersData: MemberOption[] = [
},
];
const fakeCommentsData: CommentData[] = [
{
id: 1,
content: "This is a great response! @Alice Smith can you review?",
contentRendered: "This is a great response! @Alice Smith can you review?",
author: {
id: "550e8400-e29b-41d4-a716-446655440002",
displayName: "Bob Johnson",
email: "bob.johnson@example.com",
avatarUrl: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isEdited: false,
canEdit: true,
canDelete: true,
},
{
id: 2,
content: "I checked this yesterday and it looks good.",
contentRendered: "I checked this yesterday and it looks good.",
author: {
id: "550e8400-e29b-41d4-a716-446655440001",
displayName: "Alice Smith",
email: "alice@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
isEdited: true,
canEdit: false,
canDelete: true,
},
{
id: 3,
content: "Thanks @Bob Johnson and @Alice Smith for the quick turnaround!",
contentRendered: "Thanks @Bob Johnson and @Alice Smith for the quick turnaround!",
author: {
id: "550e8400-e29b-41d4-a716-446655440004",
displayName: null,
email: "david.wilson@example.com",
avatarUrl: null,
},
createdAt: new Date(Date.now() - 3600000 * 3).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 3).toISOString(),
isEdited: false,
canEdit: true,
canDelete: false,
},
];
export default function ChatCommentsPreviewPage() {
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [selectedMember, setSelectedMember] = useState<MemberOption | null>(null);
@ -80,6 +133,40 @@ export default function ChatCommentsPreviewPage() {
)}
</section>
{/* Comment Item Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Comment Item</h2>
<p className="text-sm text-muted-foreground">
Hover over comments to see the action menu. Mentions are highlighted.
</p>
<div className="max-w-lg space-y-4 rounded-lg border p-4">
{/* Comment with replies */}
<div className="space-y-3">
<CommentItem
comment={fakeCommentsData[0]}
onEdit={(id) => alert(`Edit comment ${id}`)}
onDelete={(id) => alert(`Delete comment ${id}`)}
onReply={(id) => alert(`Reply to comment ${id}`)}
/>
<CommentItem
comment={fakeCommentsData[1]}
isReply
onEdit={(id) => alert(`Edit reply ${id}`)}
onDelete={(id) => alert(`Delete reply ${id}`)}
/>
</div>
{/* Standalone comment */}
<CommentItem
comment={fakeCommentsData[2]}
onEdit={(id) => alert(`Edit comment ${id}`)}
onDelete={(id) => alert(`Delete comment ${id}`)}
onReply={(id) => alert(`Reply to comment ${id}`)}
/>
</div>
</section>
{/* Member Mention Picker Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Member Mention Picker (Standalone)</h2>

View file

@ -0,0 +1,51 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CommentActionsProps } from "./types";
export function CommentActions({
canEdit,
canDelete,
onEdit,
onDelete,
}: CommentActionsProps) {
if (!canEdit && !canDelete) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,155 @@
"use client";
import { MessageSquare } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CommentActions } from "./comment-actions";
import type { CommentItemProps } from "./types";
function getInitials(name: string | null, email: string): string {
if (name) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return email[0].toUpperCase();
}
function formatTimestamp(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (diffMins < 1) {
return "Just now";
}
if (diffMins < 60) {
return `${diffMins}m ago`;
}
if (diffHours < 24 && date.getDate() === now.getDate()) {
return `Today at ${timeStr}`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.getDate() === yesterday.getDate() && diffDays < 2) {
return `Yesterday at ${timeStr}`;
}
if (diffDays < 7) {
const dayName = date.toLocaleDateString("en-US", { weekday: "long" });
return `${dayName} at ${timeStr}`;
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
}) + ` at ${timeStr}`;
}
function renderMentions(content: string): React.ReactNode {
const mentionPattern = /@(\w+(?:\s+\w+)*)/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
parts.push(
<span
key={match.index}
className="rounded bg-primary/10 px-1 font-medium text-primary"
>
{match[0]}
</span>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
return parts.length > 0 ? parts : content;
}
export function CommentItem({
comment,
onEdit,
onDelete,
onReply,
isReply = false,
}: CommentItemProps) {
const displayName = comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || "";
return (
<div className={cn("group flex gap-3", isReply && "ml-10")}>
<Avatar className="size-8 shrink-0">
{comment.author?.avatarUrl && (
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
)}
<AvatarFallback className="text-xs">
{getInitials(comment.author?.displayName ?? null, email || "U")}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(comment.createdAt)}
</span>
{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)}
/>
</div>
</div>
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap break-words">
{renderMentions(comment.contentRendered)}
</div>
{!isReply && onReply && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onReply(comment.id)}
>
<MessageSquare className="mr-1 size-3" />
Reply
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,34 @@
export interface CommentAuthor {
id: string;
displayName: string | null;
email: string;
avatarUrl?: string | null;
}
export interface CommentData {
id: number;
content: string;
contentRendered: string;
author: CommentAuthor | null;
createdAt: string;
updatedAt: string;
isEdited: boolean;
canEdit: boolean;
canDelete: boolean;
}
export interface CommentItemProps {
comment: CommentData;
onEdit?: (commentId: number) => void;
onDelete?: (commentId: number) => void;
onReply?: (commentId: number) => void;
isReply?: boolean;
}
export interface CommentActionsProps {
canEdit: boolean;
canDelete: boolean;
onEdit: () => void;
onDelete: () => void;
}