mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
feat(web): add comment item component
This commit is contained in:
parent
8bfcfdd084
commit
8a1e0fb013
4 changed files with 327 additions and 0 deletions
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CommentComposer } from "@/components/chat-comments/comment-composer/comment-composer";
|
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 { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker";
|
||||||
import type { MemberOption } from "@/components/chat-comments/member-mention-picker/types";
|
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() {
|
export default function ChatCommentsPreviewPage() {
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
const [selectedMember, setSelectedMember] = useState<MemberOption | null>(null);
|
const [selectedMember, setSelectedMember] = useState<MemberOption | null>(null);
|
||||||
|
|
@ -80,6 +133,40 @@ export default function ChatCommentsPreviewPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 */}
|
{/* Member Mention Picker Section */}
|
||||||
<section className="space-y-4">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
34
surfsense_web/components/chat-comments/comment-item/types.ts
Normal file
34
surfsense_web/components/chat-comments/comment-item/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue