tercul-frontend/client/src/components/work/work-header.tsx
mukimovd 5fc570e3f2 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
2025-05-10 21:47:23 +00:00

519 lines
16 KiB
TypeScript

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;