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:
mukimovd 2025-05-10 21:47:23 +00:00
parent 3b21cb5163
commit 5fc570e3f2
4 changed files with 1170 additions and 5 deletions

View File

@ -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` | |

View File

@ -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;

View File

@ -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
*/ */

View File

@ -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;