mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
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
519 lines
16 KiB
TypeScript
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; |