mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +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 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 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` | |
|
| Comparison View | ⬜️ Planned | `client/src/components/work/comparison-view.tsx` | |
|
||||||
|
|
||||||
### Phase 4: Author Components
|
### 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 |
|
| 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 Editor | ⬜️ Planned | `client/src/components/annotation/annotation-editor.tsx` | |
|
||||||
| Annotation Browser | ⬜️ Planned | `client/src/components/annotation/annotation-browser.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
|
export interface TimelineItemProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'>,
|
||||||
VariantProps<typeof timelineItemVariants> {
|
VariantProps<typeof timelineItemVariants> {
|
||||||
/**
|
/**
|
||||||
* The date or time of the event
|
* The date or time of the event
|
||||||
*/
|
*/
|
||||||
date?: string;
|
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
|
* 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