mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +00:00
Implement improved comment sections and work header display features
Implements CommentThread component with nested replies and updates WorkHeader component with metadata and actions. Replit-Commit-Author: Agent Replit-Commit-Session-Id: bddfbb2b-6d6b-457b-b18c-05792cdaa035 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/a8471e18-0cc8-4305-a4ab-93c16de251bb.jpg
This commit is contained in:
parent
3b21cb5163
commit
5fc570e3f2
@ -28,7 +28,7 @@ This document tracks the implementation status of all components for the Tercul
|
||||
|-----------|--------|-----------|-------|
|
||||
| Work Preview | ✅ Implemented | `client/src/components/work/work-preview.tsx` | Complete with multiple display variants |
|
||||
| Work Editor | ⬜️ Planned | `client/src/components/work/work-editor.tsx` | |
|
||||
| Work Header | ⬜️ Planned | `client/src/components/work/work-header.tsx` | |
|
||||
| Work Header | ✅ Implemented | `client/src/components/work/work-header.tsx` | Complete with metadata, actions, and stats display |
|
||||
| Comparison View | ⬜️ Planned | `client/src/components/work/comparison-view.tsx` | |
|
||||
|
||||
### Phase 4: Author Components
|
||||
@ -43,7 +43,7 @@ This document tracks the implementation status of all components for the Tercul
|
||||
|
||||
| Component | Status | File Path | Notes |
|
||||
|-----------|--------|-----------|-------|
|
||||
| Comment Thread | ⬜️ Planned | `client/src/components/comment/comment-thread.tsx` | |
|
||||
| Comment Thread | ✅ Implemented | `client/src/components/comment/comment-thread.tsx` | Complete with nested replies, moderation, and reactions |
|
||||
| Annotation Editor | ⬜️ Planned | `client/src/components/annotation/annotation-editor.tsx` | |
|
||||
| Annotation Browser | ⬜️ Planned | `client/src/components/annotation/annotation-browser.tsx` | |
|
||||
|
||||
|
||||
@ -0,0 +1,646 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Heart,
|
||||
Reply,
|
||||
MoreVertical,
|
||||
AlertTriangle,
|
||||
Edit,
|
||||
Trash2,
|
||||
ThumbsUp,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Comment Thread component for displaying hierarchical comments
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CommentThread
|
||||
* comments={comments}
|
||||
* currentUserId={currentUser.id}
|
||||
* onAddComment={(text, parentId) => handleAddComment(text, parentId)}
|
||||
* onEditComment={(id, text) => handleEditComment(id, text)}
|
||||
* onDeleteComment={(id) => handleDeleteComment(id)}
|
||||
* onLikeComment={(id) => handleLikeComment(id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: string | number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string | number;
|
||||
user: User;
|
||||
text: string;
|
||||
timestamp: string | Date;
|
||||
parentId?: string | number | null;
|
||||
entityId: string | number;
|
||||
entityType: string;
|
||||
likes: number;
|
||||
isLiked?: boolean;
|
||||
replies?: number;
|
||||
status?: 'published' | 'pending' | 'flagged' | 'deleted';
|
||||
isPinned?: boolean;
|
||||
isEdited?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CommentThreadProps {
|
||||
/**
|
||||
* Array of comments to display
|
||||
*/
|
||||
comments: Comment[];
|
||||
/**
|
||||
* ID of the current user
|
||||
*/
|
||||
currentUserId?: string | number;
|
||||
/**
|
||||
* Whether the thread is loading
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Maximum nesting level for replies (default: 3)
|
||||
*/
|
||||
maxNestingLevel?: number;
|
||||
/**
|
||||
* Whether to allow adding new comments
|
||||
*/
|
||||
allowComments?: boolean;
|
||||
/**
|
||||
* Whether to show reply form by default
|
||||
*/
|
||||
showReplyForm?: boolean;
|
||||
/**
|
||||
* Placeholder text for the comment input
|
||||
*/
|
||||
commentPlaceholder?: string;
|
||||
/**
|
||||
* Whether to collapse long comments
|
||||
*/
|
||||
collapseComments?: boolean;
|
||||
/**
|
||||
* Maximum comment length before collapsing
|
||||
*/
|
||||
maxCommentLength?: number;
|
||||
/**
|
||||
* Whether to auto-expand the newest comment
|
||||
*/
|
||||
autoExpandNewest?: boolean;
|
||||
/**
|
||||
* Callback for adding a new comment
|
||||
*/
|
||||
onAddComment?: (text: string, parentId?: string | number | null) => void;
|
||||
/**
|
||||
* Callback for editing a comment
|
||||
*/
|
||||
onEditComment?: (id: string | number, text: string) => void;
|
||||
/**
|
||||
* Callback for deleting a comment
|
||||
*/
|
||||
onDeleteComment?: (id: string | number) => void;
|
||||
/**
|
||||
* Callback for liking a comment
|
||||
*/
|
||||
onLikeComment?: (id: string | number, isLiked: boolean) => void;
|
||||
/**
|
||||
* Callback for reporting a comment
|
||||
*/
|
||||
onReportComment?: (id: string | number, reason?: string) => void;
|
||||
/**
|
||||
* Callback for moderating a comment
|
||||
*/
|
||||
onModerateComment?: (id: string | number, action: 'approve' | 'reject' | 'flag') => void;
|
||||
/**
|
||||
* Callback for pinning a comment
|
||||
*/
|
||||
onPinComment?: (id: string | number, isPinned: boolean) => void;
|
||||
/**
|
||||
* CSS class for the container
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Recursive function to build the comment tree
|
||||
const buildCommentTree = (comments: Comment[], parentId: string | number | null = null): Comment[] => {
|
||||
return comments
|
||||
.filter(comment => comment.parentId === parentId)
|
||||
.sort((a, b) => {
|
||||
// Sort pinned comments first
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
|
||||
// Then sort by timestamp (newest first)
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Format timestamps
|
||||
const formatTimestamp = (date: string | Date): string => {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
||||
};
|
||||
|
||||
export function CommentThread({
|
||||
comments = [],
|
||||
currentUserId,
|
||||
isLoading = false,
|
||||
maxNestingLevel = 3,
|
||||
allowComments = true,
|
||||
showReplyForm = false,
|
||||
commentPlaceholder = "Add a comment...",
|
||||
collapseComments = true,
|
||||
maxCommentLength = 300,
|
||||
autoExpandNewest = true,
|
||||
onAddComment,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onLikeComment,
|
||||
onReportComment,
|
||||
onModerateComment,
|
||||
onPinComment,
|
||||
className,
|
||||
}: CommentThreadProps) {
|
||||
const [replyingTo, setReplyingTo] = useState<string | number | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||
const [topLevelText, setTopLevelText] = useState("");
|
||||
const [editText, setEditText] = useState("");
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [expandedComments, setExpandedComments] = useState<Record<string | number, boolean>>({});
|
||||
const [expandedThreads, setExpandedThreads] = useState<Record<string | number, boolean>>({});
|
||||
|
||||
// Get the newest comment ID for auto-expanding if needed
|
||||
const newestCommentId = comments.length > 0
|
||||
? comments.reduce((newest, comment) =>
|
||||
new Date(comment.timestamp) > new Date(newest.timestamp) ? comment : newest
|
||||
).id
|
||||
: null;
|
||||
|
||||
// Initialize expanded states if not already set
|
||||
if (autoExpandNewest && newestCommentId && comments.length > 0) {
|
||||
expandedComments[newestCommentId] = true;
|
||||
}
|
||||
|
||||
// Handle submitting a top-level comment
|
||||
const handleSubmitTopLevelComment = () => {
|
||||
if (topLevelText.trim() && onAddComment) {
|
||||
onAddComment(topLevelText.trim());
|
||||
setTopLevelText("");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submitting a reply
|
||||
const handleSubmitReply = (parentId: string | number) => {
|
||||
if (replyText.trim() && onAddComment) {
|
||||
onAddComment(replyText.trim(), parentId);
|
||||
setReplyText("");
|
||||
setReplyingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submitting an edit
|
||||
const handleSubmitEdit = (id: string | number) => {
|
||||
if (editText.trim() && onEditComment) {
|
||||
onEditComment(id, editText.trim());
|
||||
setEditText("");
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle liking a comment
|
||||
const handleLikeComment = (id: string | number, isLiked: boolean) => {
|
||||
onLikeComment?.(id, !isLiked);
|
||||
};
|
||||
|
||||
// Toggle comment expansion
|
||||
const toggleCommentExpansion = (id: string | number) => {
|
||||
setExpandedComments(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
// Toggle thread expansion
|
||||
const toggleThreadExpansion = (id: string | number) => {
|
||||
setExpandedThreads(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
// Check if user can edit/delete a comment
|
||||
const canManageComment = (comment: Comment) => {
|
||||
if (!currentUserId) return false;
|
||||
return comment.user.id === currentUserId;
|
||||
};
|
||||
|
||||
// Check if user is a moderator
|
||||
const isModerator = () => {
|
||||
// In a real app, you'd check the user's role
|
||||
return false;
|
||||
};
|
||||
|
||||
// Render a single comment
|
||||
const renderComment = (comment: Comment, level: number = 0) => {
|
||||
const isExpanded = expandedComments[comment.id] || !collapseComments;
|
||||
const isThreadExpanded = expandedThreads[comment.id] !== false; // Default to expanded
|
||||
const isEditing = editingId === comment.id;
|
||||
const isReplying = replyingTo === comment.id;
|
||||
|
||||
// Get replies for this comment
|
||||
const replies = buildCommentTree(comments, comment.id);
|
||||
|
||||
// Check if comment text needs truncation
|
||||
const needsTruncation = comment.text.length > maxCommentLength;
|
||||
const displayText = needsTruncation && !isExpanded
|
||||
? `${comment.text.substring(0, maxCommentLength)}...`
|
||||
: comment.text;
|
||||
|
||||
// Determine if nesting should continue
|
||||
const shouldNestReplies = level < maxNestingLevel;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"group",
|
||||
level > 0 && "ml-6 mt-3",
|
||||
comment.isPinned && "relative bg-muted/20 p-3 rounded-md border-l-2 border-primary"
|
||||
)} key={comment.id}>
|
||||
{/* Pinned indicator */}
|
||||
{comment.isPinned && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<Badge variant="outline" className="bg-background text-xs px-2 py-0">
|
||||
Pinned
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{/* User avatar */}
|
||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||
<AvatarImage src={comment.user.avatar} alt={comment.user.name} />
|
||||
<AvatarFallback>{comment.user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Comment content */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
{/* Comment header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{comment.user.name}
|
||||
</span>
|
||||
|
||||
{/* User role badge */}
|
||||
{comment.user.role && (
|
||||
<Badge variant="outline" className="text-xs px-1 h-4">
|
||||
{comment.user.role}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Comment status */}
|
||||
{comment.status === 'pending' && (
|
||||
<Badge variant="outline" className="text-xs px-1 h-4 bg-amber-50 text-amber-700 border-amber-200">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{comment.status === 'flagged' && (
|
||||
<Badge variant="outline" className="text-xs px-1 h-4 bg-red-50 text-red-700 border-red-200">
|
||||
Flagged
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(comment.timestamp)}
|
||||
</span>
|
||||
|
||||
{comment.isEdited && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canManageComment(comment) && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setEditingId(comment.id);
|
||||
setEditText(comment.text);
|
||||
}}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteComment?.(comment.id)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isModerator() && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onPinComment?.(comment.id, !comment.isPinned)}>
|
||||
<ThumbsUp className="h-4 w-4 mr-2" />
|
||||
{comment.isPinned ? "Unpin" : "Pin"}
|
||||
</DropdownMenuItem>
|
||||
{comment.status === 'pending' && (
|
||||
<DropdownMenuItem onClick={() => onModerateComment?.(comment.id, 'approve')}>
|
||||
<ThumbsUp className="h-4 w-4 mr-2" />
|
||||
Approve
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onModerateComment?.(comment.id, 'flag')}>
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
Flag
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={() => onReportComment?.(comment.id)}>
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Comment text */}
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
placeholder="Edit your comment..."
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSubmitEdit(comment.id)}
|
||||
disabled={!editText.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed">
|
||||
<p className="whitespace-pre-line">{displayText}</p>
|
||||
|
||||
{/* Expand/collapse button */}
|
||||
{needsTruncation && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-1 text-xs"
|
||||
onClick={() => toggleCommentExpansion(comment.id)}
|
||||
>
|
||||
{isExpanded ? "Show less" : "Read more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment actions */}
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5"
|
||||
onClick={() => handleLikeComment(comment.id, !!comment.isLiked)}
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
comment.isLiked && "fill-current text-destructive"
|
||||
)}
|
||||
/>
|
||||
<span>{comment.likes || 0}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5"
|
||||
onClick={() => setReplyingTo(isReplying ? null : comment.id)}
|
||||
>
|
||||
<Reply className="h-3.5 w-3.5" />
|
||||
<span>Reply</span>
|
||||
</Button>
|
||||
|
||||
{/* Show/hide replies button */}
|
||||
{replies.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5"
|
||||
onClick={() => toggleThreadExpansion(comment.id)}
|
||||
>
|
||||
{isThreadExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
<span>Hide replies</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
<span>Show {replies.length} {replies.length === 1 ? 'reply' : 'replies'}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form */}
|
||||
{isReplying && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Write a reply..."
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReplyingTo(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSubmitReply(comment.id)}
|
||||
disabled={!replyText.trim()}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nested replies */}
|
||||
{replies.length > 0 && isThreadExpanded && (
|
||||
<div className={cn(
|
||||
"mt-3",
|
||||
shouldNestReplies ? "" : "ml-0 border-l-2 border-border pl-3"
|
||||
)}>
|
||||
{replies.map(reply => (
|
||||
shouldNestReplies
|
||||
? renderComment(reply, level + 1)
|
||||
: renderFlatReply(reply, comment)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a flat reply (used when max nesting level is reached)
|
||||
const renderFlatReply = (reply: Comment, parentComment: Comment) => {
|
||||
return (
|
||||
<div className="py-2" key={reply.id}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={reply.user.avatar} alt={reply.user.name} />
|
||||
<AvatarFallback>{reply.user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-sm">{reply.user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(reply.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-8 text-sm">
|
||||
<p>{reply.text}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1"
|
||||
onClick={() => handleLikeComment(reply.id, !!reply.isLiked)}
|
||||
>
|
||||
<Heart className={cn("h-3 w-3", reply.isLiked && "fill-current text-destructive")} />
|
||||
<span>{reply.likes || 0}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Comment count and sort controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
Comments ({comments.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Top-level comment form */}
|
||||
{allowComments && currentUserId && (
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||
{/* This would show current user's avatar */}
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Textarea
|
||||
value={topLevelText}
|
||||
onChange={(e) => setTopLevelText(e.target.value)}
|
||||
placeholder={commentPlaceholder}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitTopLevelComment}
|
||||
disabled={!topLevelText.trim()}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
// Loading skeleton
|
||||
<>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div className="flex gap-3 animate-pulse" key={i}>
|
||||
<div className="h-8 w-8 rounded-full bg-muted"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
<div className="h-4 w-16 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="h-12 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : comments.length === 0 ? (
|
||||
// Empty state
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<MessageSquare className="mx-auto h-8 w-8 mb-2 text-muted-foreground/50" />
|
||||
<p>No comments yet</p>
|
||||
{allowComments && (
|
||||
<p className="text-sm">Be the first to share your thoughts</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Render comment threads
|
||||
buildCommentTree(comments).map(comment => renderComment(comment))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentThread;
|
||||
@ -86,16 +86,16 @@ const timelineItemVariants = cva(
|
||||
);
|
||||
|
||||
export interface TimelineItemProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'>,
|
||||
VariantProps<typeof timelineItemVariants> {
|
||||
/**
|
||||
* The date or time of the event
|
||||
*/
|
||||
date?: string;
|
||||
/**
|
||||
* The title of the event
|
||||
* The title of the event (can be text or a React component)
|
||||
*/
|
||||
title?: string;
|
||||
title?: React.ReactNode;
|
||||
/**
|
||||
* Optional description of the event
|
||||
*/
|
||||
|
||||
@ -0,0 +1,519 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
BookOpen,
|
||||
Heart,
|
||||
Bookmark,
|
||||
Share2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Globe,
|
||||
MoreHorizontal,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquare
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
/**
|
||||
* Work Header component for displaying work information at the top of work detail pages
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <WorkHeader
|
||||
* work={{
|
||||
* id: 1,
|
||||
* title: "Crime and Punishment",
|
||||
* subtitle: "A Novel in Six Parts",
|
||||
* author: {
|
||||
* id: 2,
|
||||
* name: "Fyodor Dostoevsky",
|
||||
* avatar: "/images/dostoevsky.jpg",
|
||||
* },
|
||||
* publicationYear: 1866,
|
||||
* language: "Russian",
|
||||
* genres: ["Novel", "Psychological Fiction", "Philosophical Fiction"],
|
||||
* stats: {
|
||||
* likes: 1247,
|
||||
* bookmarks: 538,
|
||||
* comments: 89,
|
||||
* },
|
||||
* }}
|
||||
* variant="detailed"
|
||||
* onLike={() => console.log("Liked")}
|
||||
* onBookmark={() => console.log("Bookmarked")}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
const workHeaderVariants = cva(
|
||||
"w-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "py-6",
|
||||
compact: "py-4",
|
||||
detailed: "py-8",
|
||||
minimal: "py-3",
|
||||
},
|
||||
border: {
|
||||
none: "",
|
||||
bottom: "border-b",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
border: "bottom",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface Author {
|
||||
id: number | string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export interface WorkStats {
|
||||
likes?: number;
|
||||
bookmarks?: number;
|
||||
comments?: number;
|
||||
views?: number;
|
||||
reads?: number;
|
||||
}
|
||||
|
||||
export interface Work {
|
||||
id: number | string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
slug?: string;
|
||||
author: Author;
|
||||
coverImage?: string;
|
||||
publicationYear?: number;
|
||||
language?: string;
|
||||
genres?: string[];
|
||||
translators?: string[];
|
||||
description?: string;
|
||||
stats?: WorkStats;
|
||||
isLiked?: boolean;
|
||||
isBookmarked?: boolean;
|
||||
status?: 'published' | 'draft' | 'review' | 'archived';
|
||||
translationCount?: number;
|
||||
readingTime?: number;
|
||||
}
|
||||
|
||||
export interface WorkHeaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof workHeaderVariants> {
|
||||
/**
|
||||
* Work data to display
|
||||
*/
|
||||
work: Work;
|
||||
/**
|
||||
* Whether to show action buttons
|
||||
*/
|
||||
showActions?: boolean;
|
||||
/**
|
||||
* Whether to show the like button
|
||||
*/
|
||||
showLike?: boolean;
|
||||
/**
|
||||
* Whether to show the bookmark button
|
||||
*/
|
||||
showBookmark?: boolean;
|
||||
/**
|
||||
* Whether to show the share button
|
||||
*/
|
||||
showShare?: boolean;
|
||||
/**
|
||||
* Whether to collapse the description by default
|
||||
*/
|
||||
collapseDescription?: boolean;
|
||||
/**
|
||||
* Maximum length of description before truncating
|
||||
*/
|
||||
descriptionLength?: number;
|
||||
/**
|
||||
* Maximum number of genres to display
|
||||
*/
|
||||
maxGenres?: number;
|
||||
/**
|
||||
* Click handler for like button
|
||||
*/
|
||||
onLike?: (work: Work) => void;
|
||||
/**
|
||||
* Click handler for bookmark button
|
||||
*/
|
||||
onBookmark?: (work: Work) => void;
|
||||
/**
|
||||
* Click handler for share button
|
||||
*/
|
||||
onShare?: (work: Work) => void;
|
||||
/**
|
||||
* Click handler for author
|
||||
*/
|
||||
onAuthorClick?: (author: Author) => void;
|
||||
/**
|
||||
* Whether the component is in loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function WorkHeader({
|
||||
className,
|
||||
variant,
|
||||
border,
|
||||
work,
|
||||
showActions = true,
|
||||
showLike = true,
|
||||
showBookmark = true,
|
||||
showShare = true,
|
||||
collapseDescription = true,
|
||||
descriptionLength = 280,
|
||||
maxGenres = 5,
|
||||
onLike,
|
||||
onBookmark,
|
||||
onShare,
|
||||
onAuthorClick,
|
||||
isLoading = false,
|
||||
...props
|
||||
}: WorkHeaderProps) {
|
||||
const [isLiked, setIsLiked] = useState(work.isLiked || false);
|
||||
const [isBookmarked, setIsBookmarked] = useState(work.isBookmarked || false);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(!collapseDescription);
|
||||
|
||||
// Handle truncation of description
|
||||
const hasLongDescription = work.description && work.description.length > descriptionLength;
|
||||
const displayDescription = hasLongDescription && !isDescriptionExpanded && work.description
|
||||
? `${work.description.substring(0, descriptionLength)}...`
|
||||
: work.description;
|
||||
|
||||
// Handle like button click
|
||||
const handleLike = () => {
|
||||
setIsLiked(!isLiked);
|
||||
onLike?.(work);
|
||||
};
|
||||
|
||||
// Handle bookmark button click
|
||||
const handleBookmark = () => {
|
||||
setIsBookmarked(!isBookmarked);
|
||||
onBookmark?.(work);
|
||||
};
|
||||
|
||||
// Handle share button click
|
||||
const handleShare = () => {
|
||||
onShare?.(work);
|
||||
};
|
||||
|
||||
// Handle author click
|
||||
const handleAuthorClick = () => {
|
||||
onAuthorClick?.(work.author);
|
||||
};
|
||||
|
||||
// Handle description toggle
|
||||
const toggleDescription = () => {
|
||||
setIsDescriptionExpanded(!isDescriptionExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
workHeaderVariants({ variant, border, className })
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Main title section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title and actions */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 max-w-[80%]">
|
||||
{/* Work title */}
|
||||
<h1 className={cn(
|
||||
"font-serif font-bold tracking-tight",
|
||||
variant === "compact" || variant === "minimal" ? "text-2xl" : "text-3xl md:text-4xl"
|
||||
)}>
|
||||
{work.title}
|
||||
</h1>
|
||||
|
||||
{/* Work subtitle */}
|
||||
{work.subtitle && (
|
||||
<h2 className={cn(
|
||||
"text-muted-foreground font-serif",
|
||||
variant === "compact" || variant === "minimal" ? "text-lg" : "text-xl md:text-2xl"
|
||||
)}>
|
||||
{work.subtitle}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons (desktop) */}
|
||||
{showActions && variant !== "minimal" && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{showLike && (
|
||||
<Button
|
||||
variant={isLiked ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleLike}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Heart className={cn(
|
||||
"h-4 w-4",
|
||||
isLiked && "fill-current"
|
||||
)} />
|
||||
{work.stats?.likes && (
|
||||
<span>{work.stats.likes}</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showBookmark && (
|
||||
<Button
|
||||
variant={isBookmarked ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleBookmark}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
{isBookmarked ? "Saved" : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showShare && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Add to collection</DropdownMenuItem>
|
||||
<DropdownMenuItem>Download</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Report issue</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author information */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={onAuthorClick ? handleAuthorClick : undefined}
|
||||
>
|
||||
<Avatar
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
onAuthorClick && "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<AvatarImage
|
||||
src={work.author.avatar}
|
||||
alt={work.author.name}
|
||||
/>
|
||||
<AvatarFallback>{work.author.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
onAuthorClick && "cursor-pointer hover:underline"
|
||||
)}
|
||||
>
|
||||
by {work.author.name}
|
||||
</span>
|
||||
|
||||
{/* Publication year, language */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{work.publicationYear && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>{work.publicationYear}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{work.language && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>{work.language}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{work.readingTime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{work.readingTime} min read</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{work.translationCount && work.translationCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
<span>{work.translationCount} translation{work.translationCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work status badge */}
|
||||
{work.status && work.status !== 'published' && (
|
||||
<Badge variant={
|
||||
work.status === 'draft' ? 'outline' :
|
||||
work.status === 'review' ? 'secondary' :
|
||||
'destructive'
|
||||
}>
|
||||
{work.status}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Genres */}
|
||||
{work.genres && work.genres.length > 0 && variant !== "minimal" && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{work.genres.slice(0, maxGenres).map((genre, index) => (
|
||||
<Badge key={index} variant="secondary" className="px-2 py-0.5">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{work.genres.length > maxGenres && (
|
||||
<Badge variant="outline" className="px-2 py-0.5">
|
||||
+{work.genres.length - maxGenres} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{work.description && variant === "detailed" && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{displayDescription}
|
||||
</p>
|
||||
|
||||
{hasLongDescription && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 -ml-2"
|
||||
onClick={toggleDescription}
|
||||
>
|
||||
{isDescriptionExpanded ? (
|
||||
<span className="flex items-center gap-1">
|
||||
Show less <ChevronUp className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
Read more <ChevronDown className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats bar and mobile actions */}
|
||||
{(showActions || (work.stats && (work.stats.likes || work.stats.comments || work.stats.bookmarks))) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
{/* Stats */}
|
||||
{work.stats && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{typeof work.stats.likes === 'number' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
<span>{work.stats.likes}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{typeof work.stats.comments === 'number' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span>{work.stats.comments}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{typeof work.stats.bookmarks === 'number' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Bookmark className="h-4 w-4" />
|
||||
<span>{work.stats.bookmarks}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile actions */}
|
||||
{showActions && (
|
||||
<div className="sm:hidden flex items-center gap-2">
|
||||
{showLike && (
|
||||
<Button
|
||||
variant={isLiked ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleLike}
|
||||
className="gap-1.5 h-8 px-2"
|
||||
>
|
||||
<Heart className={cn(
|
||||
"h-4 w-4",
|
||||
isLiked && "fill-current"
|
||||
)} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showBookmark && (
|
||||
<Button
|
||||
variant={isBookmarked ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleBookmark}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showShare && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleShare}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkHeader;
|
||||
Loading…
Reference in New Issue
Block a user