mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 02:31:34 +00:00
Enhance activity tracking and add author profiles to the literary platform
Implement an activity feed with filters and author cards with detailed information. Refactor: Implemented Activity Feed with filtering and expandable entries, and AuthorCard with biography, stats, and follow functionality. 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
950077fe11
commit
3b21cb5163
@ -19,7 +19,7 @@ This document tracks the implementation status of all components for the Tercul
|
||||
| Component | Status | File Path | Notes |
|
||||
|-----------|--------|-----------|-------|
|
||||
| Dashboard Header | ✅ Implemented | `client/src/components/dashboard/dashboard-header.tsx` | Complete with title, description, and actions |
|
||||
| Activity Feed | ✅ Implemented | `client/src/components/dashboard/activity-feed.tsx` | Implemented with grouped timeline |
|
||||
| Activity Feed | ✅ Implemented | `client/src/components/dashboard/activity-feed.tsx` | Complete with timeline, filters, and expandable entries |
|
||||
| Content Queue | ⬜️ Planned | `client/src/components/dashboard/content-queue.tsx` | |
|
||||
|
||||
### Phase 3: Work Management Components
|
||||
@ -36,7 +36,7 @@ This document tracks the implementation status of all components for the Tercul
|
||||
| Component | Status | File Path | Notes |
|
||||
|-----------|--------|-----------|-------|
|
||||
| Author Editor | ⬜️ Planned | `client/src/components/authors/author-editor.tsx` | |
|
||||
| Author Card | ⬜️ Planned | `client/src/components/authors/author-card.tsx` | |
|
||||
| Author Card | ✅ Implemented | `client/src/components/authors/author-card.tsx` | Complete with biography, stats, and follow functionality |
|
||||
| Author Header | ⬜️ Planned | `client/src/components/authors/author-header.tsx` | |
|
||||
|
||||
### Phase 5: Comment and Annotation Components
|
||||
|
||||
@ -0,0 +1,448 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { ExternalLink, BookOpen, Calendar, Globe, MapPin, Users, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Author Card component for displaying author information
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AuthorCard
|
||||
* author={{
|
||||
* id: 1,
|
||||
* name: "Fyodor Dostoevsky",
|
||||
* bio: "Russian novelist, philosopher, and short story writer...",
|
||||
* avatar: "/images/dostoevsky.jpg",
|
||||
* birthYear: 1821,
|
||||
* deathYear: 1881,
|
||||
* nationality: "Russian",
|
||||
* era: "19th Century",
|
||||
* genres: ["Novel", "Short Story", "Philosophical fiction"],
|
||||
* influences: ["Gogol", "Dickens"],
|
||||
* location: "Saint Petersburg, Russia",
|
||||
* works: 12,
|
||||
* followers: 345
|
||||
* }}
|
||||
* variant="detailed"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
const authorCardVariants = cva(
|
||||
"overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border rounded-lg shadow-sm",
|
||||
compact: "border rounded-md shadow-sm",
|
||||
detailed: "border rounded-lg shadow-sm",
|
||||
minimal: "bg-transparent border-0 shadow-none",
|
||||
featured: "border-2 border-primary rounded-lg shadow-md",
|
||||
},
|
||||
orientation: {
|
||||
vertical: "flex flex-col",
|
||||
horizontal: "flex flex-row",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface Author {
|
||||
/**
|
||||
* Unique identifier for the author
|
||||
*/
|
||||
id: string | number;
|
||||
/**
|
||||
* Author's name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Author's biography
|
||||
*/
|
||||
bio?: string;
|
||||
/**
|
||||
* URL to the author's avatar/portrait
|
||||
*/
|
||||
avatar?: string;
|
||||
/**
|
||||
* Author's birth year
|
||||
*/
|
||||
birthYear?: number;
|
||||
/**
|
||||
* Author's death year (if applicable)
|
||||
*/
|
||||
deathYear?: number;
|
||||
/**
|
||||
* Author's nationality
|
||||
*/
|
||||
nationality?: string;
|
||||
/**
|
||||
* Literary era(s) the author is associated with
|
||||
*/
|
||||
era?: string;
|
||||
/**
|
||||
* Author's primary genres
|
||||
*/
|
||||
genres?: string[];
|
||||
/**
|
||||
* Literary influences
|
||||
*/
|
||||
influences?: string[];
|
||||
/**
|
||||
* Geographic location associated with the author
|
||||
*/
|
||||
location?: string;
|
||||
/**
|
||||
* Number of works by the author
|
||||
*/
|
||||
works?: number;
|
||||
/**
|
||||
* Number of followers/readers
|
||||
*/
|
||||
followers?: number;
|
||||
/**
|
||||
* URL to the author's profile page
|
||||
*/
|
||||
url?: string;
|
||||
/**
|
||||
* Whether the author is followed by the current user
|
||||
*/
|
||||
isFollowed?: boolean;
|
||||
/**
|
||||
* Whether the author is featured
|
||||
*/
|
||||
isFeatured?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorCardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof authorCardVariants> {
|
||||
/**
|
||||
* Author data
|
||||
*/
|
||||
author: Author;
|
||||
/**
|
||||
* Whether to show a follow button
|
||||
*/
|
||||
showFollowButton?: boolean;
|
||||
/**
|
||||
* Whether to show the author's stats
|
||||
*/
|
||||
showStats?: boolean;
|
||||
/**
|
||||
* Whether to expand the bio by default
|
||||
*/
|
||||
expandBio?: boolean;
|
||||
/**
|
||||
* Maximum length of bio before truncating
|
||||
*/
|
||||
bioLength?: number;
|
||||
/**
|
||||
* Maximum number of genres to display
|
||||
*/
|
||||
maxGenres?: number;
|
||||
/**
|
||||
* Whether the card should link to the author's page
|
||||
*/
|
||||
linkToAuthor?: boolean;
|
||||
/**
|
||||
* Click handler for the card
|
||||
*/
|
||||
onAuthorClick?: (author: Author) => void;
|
||||
/**
|
||||
* Follow button click handler
|
||||
*/
|
||||
onFollowClick?: (author: Author, isFollowed: boolean) => void;
|
||||
/**
|
||||
* Whether the component is in a loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function AuthorCard({
|
||||
className,
|
||||
variant,
|
||||
orientation,
|
||||
author,
|
||||
showFollowButton = true,
|
||||
showStats = true,
|
||||
expandBio = false,
|
||||
bioLength = 150,
|
||||
maxGenres = 3,
|
||||
linkToAuthor = true,
|
||||
onAuthorClick,
|
||||
onFollowClick,
|
||||
isLoading = false,
|
||||
...props
|
||||
}: AuthorCardProps) {
|
||||
const [bioExpanded, setBioExpanded] = useState(expandBio);
|
||||
const [isFollowed, setIsFollowed] = useState(author.isFollowed || false);
|
||||
|
||||
// Compact years display
|
||||
const yearsDisplay = author.birthYear
|
||||
? `${author.birthYear}${author.deathYear ? ` - ${author.deathYear}` : ''}`
|
||||
: '';
|
||||
|
||||
// Format bio with truncation if needed
|
||||
const hasTruncatedBio = author.bio && author.bio.length > bioLength && !bioExpanded;
|
||||
const displayBio = hasTruncatedBio && author.bio
|
||||
? `${author.bio.substring(0, bioLength)}...`
|
||||
: author.bio;
|
||||
|
||||
// Handle follow button click
|
||||
const handleFollowClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const newFollowState = !isFollowed;
|
||||
setIsFollowed(newFollowState);
|
||||
onFollowClick?.(author, newFollowState);
|
||||
};
|
||||
|
||||
// Handle card click
|
||||
const handleCardClick = () => {
|
||||
onAuthorClick?.(author);
|
||||
};
|
||||
|
||||
// Handle expand bio click
|
||||
const handleExpandBio = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setBioExpanded(!bioExpanded);
|
||||
};
|
||||
|
||||
// Determine if the card is clickable
|
||||
const isClickable = linkToAuthor || onAuthorClick;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
authorCardVariants({ variant, orientation, className }),
|
||||
isClickable && "cursor-pointer hover:bg-accent/5 transition-colors",
|
||||
author.isFeatured && variant !== "featured" && "border-primary/50"
|
||||
)}
|
||||
onClick={isClickable ? handleCardClick : undefined}
|
||||
{...props}
|
||||
>
|
||||
<CardHeader className={cn(
|
||||
"space-y-0",
|
||||
variant === "compact" || variant === "minimal" ? "p-3" : "p-6",
|
||||
orientation === "horizontal" && "flex-shrink-0 w-1/3"
|
||||
)}>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Author avatar */}
|
||||
<Avatar className={cn(
|
||||
"rounded-full border bg-muted flex-shrink-0",
|
||||
variant === "compact" || variant === "minimal" ? "h-10 w-10" : "h-16 w-16",
|
||||
variant === "featured" && "h-20 w-20 border-2 border-primary"
|
||||
)}>
|
||||
<AvatarImage src={author.avatar} alt={author.name} />
|
||||
<AvatarFallback>
|
||||
{author.name.split(' ').map(n => n[0]).join('').substring(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Author name and metadata */}
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={cn(
|
||||
"font-medium leading-none truncate",
|
||||
variant === "compact" || variant === "minimal" ? "text-base" : "text-lg"
|
||||
)}>
|
||||
{author.name}
|
||||
</h3>
|
||||
|
||||
{/* Featured badge */}
|
||||
{author.isFeatured && (
|
||||
<Badge variant="default" className="flex-shrink-0">
|
||||
Featured
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Era/Years */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||||
{author.era && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>{author.era}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{yearsDisplay && (
|
||||
<div className="flex items-center gap-1">
|
||||
{!author.era && <Calendar className="h-3.5 w-3.5" />}
|
||||
<span>{yearsDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nationality */}
|
||||
{author.nationality && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>{author.nationality}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{author.location && variant !== "compact" && variant !== "minimal" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{author.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{author.genres && author.genres.length > 0 && variant !== "minimal" && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{author.genres.slice(0, maxGenres).map((genre, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
{author.genres.length > maxGenres && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{author.genres.length - maxGenres}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Bio and content */}
|
||||
{(displayBio || variant === "detailed") && (
|
||||
<CardContent className={cn(
|
||||
variant === "compact" || variant === "minimal" ? "p-3 pt-0" : "px-6 pb-6 pt-0",
|
||||
orientation === "horizontal" && "flex-1"
|
||||
)}>
|
||||
{/* Author bio */}
|
||||
{displayBio && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{displayBio}
|
||||
</p>
|
||||
|
||||
{author.bio && author.bio.length > bioLength && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 -ml-2 text-xs"
|
||||
onClick={handleExpandBio}
|
||||
>
|
||||
{bioExpanded ? (
|
||||
<span className="flex items-center gap-1">
|
||||
Show less <ChevronUp className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
Read more <ChevronDown className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Influences */}
|
||||
{author.influences && author.influences.length > 0 && variant === "detailed" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium mb-1">Influences:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{author.influences.map((influence, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{influence}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Card footer with stats and actions */}
|
||||
{(showStats || showFollowButton) && (
|
||||
<CardFooter className={cn(
|
||||
"flex items-center justify-between border-t bg-muted/10",
|
||||
variant === "compact" || variant === "minimal" ? "p-3" : "p-4",
|
||||
orientation === "horizontal" && "flex-shrink-0"
|
||||
)}>
|
||||
{/* Stats */}
|
||||
{showStats && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<TooltipProvider>
|
||||
{typeof author.works === 'number' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>{author.works}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{author.works} works</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{typeof author.followers === 'number' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{author.followers}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{author.followers} followers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{author.url && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
window.open(author.url, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showFollowButton && (
|
||||
<Button
|
||||
variant={isFollowed ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleFollowClick}
|
||||
>
|
||||
{isFollowed ? "Following" : "Follow"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthorCard;
|
||||
@ -1,243 +1,480 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LucideIcon, Clock, User } from "lucide-react";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Timeline, TimelineItem } from "@/components/ui/timeline";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import {
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
Heart,
|
||||
PenSquare,
|
||||
FileEdit,
|
||||
UserPlus,
|
||||
Bookmark,
|
||||
Star,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
MoreHorizontal,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Tag,
|
||||
Layers
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Activity feed component for displaying recent activities
|
||||
* Activity Feed component for displaying user activity timelines
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActivityFeed
|
||||
* activities={activities}
|
||||
* title="Recent Activity"
|
||||
* emptyMessage="No recent activities"
|
||||
* isLoading={isLoading}
|
||||
* onLoadMore={handleLoadMore}
|
||||
* hasMore={hasMoreActivities}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface ActivityItem {
|
||||
export interface Activity {
|
||||
/**
|
||||
* Unique identifier
|
||||
* Unique ID for the activity
|
||||
*/
|
||||
id: string | number;
|
||||
/**
|
||||
* Activity type
|
||||
* Type of activity
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Activity description
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* When the activity occurred
|
||||
*/
|
||||
timestamp: Date | string;
|
||||
/**
|
||||
* User who performed the activity
|
||||
*/
|
||||
user?: {
|
||||
user: {
|
||||
id: string | number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
};
|
||||
/**
|
||||
* Icon to display for this activity
|
||||
* Timestamp when the activity occurred
|
||||
*/
|
||||
icon?: LucideIcon;
|
||||
timestamp: string | Date;
|
||||
/**
|
||||
* Link URL for this activity
|
||||
* Associated entity of the activity (work, comment, etc)
|
||||
*/
|
||||
href?: string;
|
||||
entity?: {
|
||||
id: string | number;
|
||||
type: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
/**
|
||||
* Optional metadata
|
||||
* Secondary entity involved in the activity (e.g., the tag that was added)
|
||||
*/
|
||||
secondaryEntity?: {
|
||||
id: string | number;
|
||||
type: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
/**
|
||||
* Additional metadata specific to the activity type
|
||||
*/
|
||||
meta?: Record<string, any>;
|
||||
/**
|
||||
* Optional group identifier for grouping related activities
|
||||
*/
|
||||
groupId?: string | number;
|
||||
}
|
||||
|
||||
export interface ActivityFeedProps {
|
||||
/**
|
||||
* List of activities to display
|
||||
* Array of activity items to display
|
||||
*/
|
||||
activities: ActivityItem[];
|
||||
activities: Activity[];
|
||||
/**
|
||||
* Optional title for the feed
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Message to display when there are no activities
|
||||
*/
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* Whether the feed is loading
|
||||
* Whether activities are currently loading
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Maximum number of items to show
|
||||
* Callback to load more activities
|
||||
*/
|
||||
onLoadMore?: () => void;
|
||||
/**
|
||||
* Whether there are more activities to load
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* Callback when an activity is clicked
|
||||
*/
|
||||
onActivityClick?: (activity: Activity) => void;
|
||||
/**
|
||||
* Maximum number of activities to display before scrolling
|
||||
*/
|
||||
maxItems?: number;
|
||||
/**
|
||||
* Maximum height of the feed
|
||||
*/
|
||||
maxHeight?: string | number;
|
||||
/**
|
||||
* Whether to group activities by date
|
||||
*/
|
||||
groupByDate?: boolean;
|
||||
/**
|
||||
* Whether to use a compact layout
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Optional filter by activity type
|
||||
*/
|
||||
filter?: string[];
|
||||
/**
|
||||
* Whether to hide the filter controls
|
||||
*/
|
||||
hideFilters?: boolean;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityFeed({
|
||||
activities,
|
||||
title,
|
||||
emptyMessage = "No recent activity",
|
||||
isLoading = false,
|
||||
maxItems,
|
||||
className,
|
||||
}: ActivityFeedProps) {
|
||||
// Limit number of activities if maxItems is specified
|
||||
const displayActivities = maxItems
|
||||
? activities.slice(0, maxItems)
|
||||
: activities;
|
||||
interface ActivityGroup {
|
||||
date: string;
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
// Group activities by date
|
||||
const groupedActivities: Record<string, ActivityItem[]> = {};
|
||||
/**
|
||||
* Get icon for activity type
|
||||
*/
|
||||
const getActivityIcon = (type: string): React.ReactNode => {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
comment: <MessageSquare className="h-4 w-4" />,
|
||||
like: <Heart className="h-4 w-4" />,
|
||||
create: <PenSquare className="h-4 w-4" />,
|
||||
edit: <FileEdit className="h-4 w-4" />,
|
||||
join: <UserPlus className="h-4 w-4" />,
|
||||
bookmark: <Bookmark className="h-4 w-4" />,
|
||||
rate: <Star className="h-4 w-4" />,
|
||||
alert: <AlertCircle className="h-4 w-4" />,
|
||||
complete: <CheckCircle className="h-4 w-4" />,
|
||||
schedule: <Clock className="h-4 w-4" />,
|
||||
read: <BookOpen className="h-4 w-4" />,
|
||||
follow: <Users className="h-4 w-4" />,
|
||||
tag: <Tag className="h-4 w-4" />,
|
||||
collection: <Layers className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
displayActivities.forEach(activity => {
|
||||
return icons[type] || <MoreHorizontal className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color variant for activity type
|
||||
*/
|
||||
const getActivityVariant = (type: string): string => {
|
||||
const variants: Record<string, string> = {
|
||||
comment: "bg-blue-100 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400",
|
||||
like: "bg-pink-100 dark:bg-pink-950/30 text-pink-700 dark:text-pink-400",
|
||||
create: "bg-green-100 dark:bg-green-950/30 text-green-700 dark:text-green-400",
|
||||
edit: "bg-amber-100 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400",
|
||||
join: "bg-indigo-100 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-400",
|
||||
bookmark: "bg-purple-100 dark:bg-purple-950/30 text-purple-700 dark:text-purple-400",
|
||||
rate: "bg-yellow-100 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400",
|
||||
alert: "bg-red-100 dark:bg-red-950/30 text-red-700 dark:text-red-400",
|
||||
complete: "bg-emerald-100 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400",
|
||||
schedule: "bg-cyan-100 dark:bg-cyan-950/30 text-cyan-700 dark:text-cyan-400",
|
||||
read: "bg-violet-100 dark:bg-violet-950/30 text-violet-700 dark:text-violet-400",
|
||||
follow: "bg-indigo-100 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-400",
|
||||
tag: "bg-orange-100 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400",
|
||||
collection: "bg-teal-100 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400",
|
||||
};
|
||||
|
||||
return variants[type] || "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400";
|
||||
};
|
||||
|
||||
/**
|
||||
* Get formatted activity message
|
||||
*/
|
||||
const getActivityMessage = (activity: Activity): string => {
|
||||
const { type, entity, secondaryEntity, meta } = activity;
|
||||
|
||||
const entityName = entity?.name || "item";
|
||||
const secondaryName = secondaryEntity?.name || "";
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
comment: `commented on ${entityName}`,
|
||||
like: `liked ${entityName}`,
|
||||
create: `created ${entityName}`,
|
||||
edit: `edited ${entityName}`,
|
||||
join: `joined the platform`,
|
||||
bookmark: `bookmarked ${entityName}`,
|
||||
rate: `rated ${entityName}`,
|
||||
read: `read ${entityName}`,
|
||||
follow: `followed ${entityName}`,
|
||||
tag: `tagged ${entityName} with ${secondaryName}`,
|
||||
collection: `added ${entityName} to collection ${secondaryName}`,
|
||||
};
|
||||
|
||||
return messages[type] || `performed action on ${entityName}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to group activities by date
|
||||
*/
|
||||
const groupActivitiesByDate = (activities: Activity[]): ActivityGroup[] => {
|
||||
const groups: Record<string, Activity[]> = {};
|
||||
|
||||
activities.forEach(activity => {
|
||||
const date = new Date(activity.timestamp);
|
||||
const dateKey = format(date, 'yyyy-MM-dd');
|
||||
|
||||
if (!groupedActivities[dateKey]) {
|
||||
groupedActivities[dateKey] = [];
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
|
||||
groupedActivities[dateKey].push(activity);
|
||||
groups[dateKey].push(activity);
|
||||
});
|
||||
|
||||
// Sort date keys in descending order
|
||||
const sortedDateKeys = Object.keys(groupedActivities).sort((a, b) => b.localeCompare(a));
|
||||
return Object.entries(groups).map(([date, activities]) => ({
|
||||
date,
|
||||
activities,
|
||||
})).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
};
|
||||
|
||||
// Helper function to format date groups
|
||||
const formatDateGroup = (dateKey: string) => {
|
||||
const date = new Date(dateKey);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
/**
|
||||
* Format date for group headers
|
||||
*/
|
||||
const formatGroupDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (format(date, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')) {
|
||||
return 'Today';
|
||||
} else if (format(date, 'yyyy-MM-dd') === format(yesterday, 'yyyy-MM-dd')) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return format(date, 'MMMM d, yyyy');
|
||||
}
|
||||
if (format(date, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')) {
|
||||
return 'Today';
|
||||
} else if (format(date, 'yyyy-MM-dd') === format(yesterday, 'yyyy-MM-dd')) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return format(date, 'MMMM d, yyyy');
|
||||
}
|
||||
};
|
||||
|
||||
export function ActivityFeed({
|
||||
activities,
|
||||
isLoading = false,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
onActivityClick,
|
||||
maxItems = 50,
|
||||
maxHeight = "500px",
|
||||
groupByDate = true,
|
||||
compact = false,
|
||||
filter = [],
|
||||
hideFilters = false,
|
||||
className,
|
||||
}: ActivityFeedProps) {
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>(filter);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Apply filters to activities
|
||||
const filteredActivities = activeFilters.length > 0
|
||||
? activities.filter(activity => activeFilters.includes(activity.type))
|
||||
: activities;
|
||||
|
||||
// Group activities by date if needed
|
||||
const groupedActivities = groupByDate
|
||||
? groupActivitiesByDate(filteredActivities)
|
||||
: [{ date: 'all', activities: filteredActivities }];
|
||||
|
||||
// Function to handle toggling activity expansion
|
||||
const toggleExpanded = (id: string | number) => {
|
||||
setExpanded(prev => ({
|
||||
...prev,
|
||||
[id.toString()]: !prev[id.toString()]
|
||||
}));
|
||||
};
|
||||
|
||||
// Format the relative time
|
||||
const formatTime = (timestamp: Date | string) => {
|
||||
const date = new Date(timestamp);
|
||||
return format(date, 'h:mm a');
|
||||
// Toggle a filter
|
||||
const toggleFilter = (type: string) => {
|
||||
setActiveFilters(prev =>
|
||||
prev.includes(type)
|
||||
? prev.filter(t => t !== type)
|
||||
: [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
// Generate list of available types from activities
|
||||
const availableTypes = Array.from(new Set(activities.map(activity => activity.type)));
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Title */}
|
||||
{title && <h3 className="font-medium text-lg">{title}</h3>}
|
||||
{/* Filter controls */}
|
||||
{!hideFilters && availableTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{availableTypes.map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={activeFilters.includes(type) ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleFilter(type)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{getActivityIcon(type)}
|
||||
<span className="capitalize">{type}</span>
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Activity List */}
|
||||
<div className="space-y-6">
|
||||
{isLoading ? (
|
||||
// Loading state
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<ActivityItemSkeleton key={index} />
|
||||
))
|
||||
) : displayActivities.length === 0 ? (
|
||||
// Empty state
|
||||
<p className="text-muted-foreground text-center py-6">{emptyMessage}</p>
|
||||
) : (
|
||||
// Grouped activities
|
||||
sortedDateKeys.map(dateKey => (
|
||||
<div key={dateKey} className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{formatDateGroup(dateKey)}
|
||||
</h4>
|
||||
<ul className="space-y-4">
|
||||
{groupedActivities[dateKey].map(activity => (
|
||||
<li key={activity.id} className="relative pl-6">
|
||||
{/* Timeline connector */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border" />
|
||||
|
||||
{/* Activity dot */}
|
||||
<div className="absolute left-[-4px] top-1.5 w-2 h-2 rounded-full bg-primary" />
|
||||
|
||||
{/* Activity content */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Activity icon or user avatar */}
|
||||
{activity.user ? (
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={activity.user.avatar} alt={activity.user.name} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{activity.user.name.split(' ').map(n => n[0]).join('')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : activity.icon ? (
|
||||
<div className="rounded-full bg-muted p-1.5 text-muted-foreground">
|
||||
<activity.icon className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Activity details */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">
|
||||
{activity.user && (
|
||||
<span className="font-medium">{activity.user.name}</span>
|
||||
)}{' '}
|
||||
{activity.description}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(activity.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show more link */}
|
||||
{maxItems && activities.length > maxItems && (
|
||||
<div className="text-center pt-2">
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Handle showing more activities or navigation
|
||||
}}
|
||||
>
|
||||
View all activities
|
||||
</a>
|
||||
{activeFilters.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveFilters([])}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity list */}
|
||||
<ScrollArea className={cn("w-full", typeof maxHeight === 'number' ? `max-h-[${maxHeight}px]` : `max-h-[${maxHeight}]`)}>
|
||||
{groupedActivities.length === 0 || filteredActivities.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No activities to display
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedActivities.map((group) => (
|
||||
<div key={group.date} className="space-y-4">
|
||||
{/* Date header (if grouping by date) */}
|
||||
{groupByDate && (
|
||||
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm pb-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatGroupDate(group.date)}
|
||||
</h3>
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activities list */}
|
||||
<Timeline>
|
||||
{group.activities.map((activity) => {
|
||||
const timestamp = new Date(activity.timestamp);
|
||||
const isExpanded = expanded[activity.id.toString()];
|
||||
|
||||
return (
|
||||
<TimelineItem
|
||||
key={activity.id.toString()}
|
||||
date={compact ? undefined : format(timestamp, 'h:mm a').toString()}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={activity.user.avatar} alt={activity.user.name} />
|
||||
<AvatarFallback>{activity.user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium truncate">{activity.user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
{formatDistance(timestamp, new Date(), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex items-center justify-center h-6 w-6 rounded-full",
|
||||
getActivityVariant(activity.type)
|
||||
)}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
<span>{getActivityMessage(activity)}</span>
|
||||
</div>
|
||||
|
||||
{activity.entity && (
|
||||
<div className="pl-8">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm p-2 rounded bg-muted/50 border border-border/50 cursor-pointer",
|
||||
isExpanded ? "mb-2" : ""
|
||||
)}
|
||||
onClick={() => toggleExpanded(activity.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{activity.entity.name}</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
isExpanded ? "transform rotate-180" : ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && activity.meta && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{activity.meta.excerpt && (
|
||||
<div className="mt-2 italic">"{activity.meta.excerpt}"</div>
|
||||
)}
|
||||
{activity.meta.details && (
|
||||
<div className="mt-1">{activity.meta.details}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
icon={activity.meta?.important ? AlertCircle : undefined}
|
||||
active={activity.meta?.active}
|
||||
variant={activity.meta?.highlighted ? "highlight" : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{hasMore && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load more"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for activity items
|
||||
function ActivityItemSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 pl-6 relative">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border" />
|
||||
<div className="absolute left-[-4px] top-1.5 w-2 h-2 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default ActivityFeed;
|
||||
Loading…
Reference in New Issue
Block a user