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:
mukimovd 2025-05-10 21:42:36 +00:00
parent 950077fe11
commit 3b21cb5163
3 changed files with 860 additions and 175 deletions

View File

@ -19,7 +19,7 @@ This document tracks the implementation status of all components for the Tercul
| Component | Status | File Path | Notes | | Component | Status | File Path | Notes |
|-----------|--------|-----------|-------| |-----------|--------|-----------|-------|
| Dashboard Header | ✅ Implemented | `client/src/components/dashboard/dashboard-header.tsx` | Complete with title, description, and actions | | 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` | | | Content Queue | ⬜️ Planned | `client/src/components/dashboard/content-queue.tsx` | |
### Phase 3: Work Management Components ### 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 | | Component | Status | File Path | Notes |
|-----------|--------|-----------|-------| |-----------|--------|-----------|-------|
| Author Editor | ⬜️ Planned | `client/src/components/authors/author-editor.tsx` | | | 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` | | | Author Header | ⬜️ Planned | `client/src/components/authors/author-header.tsx` | |
### Phase 5: Comment and Annotation Components ### Phase 5: Comment and Annotation Components

View File

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

View File

@ -1,243 +1,480 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LucideIcon, Clock, User } from "lucide-react"; import { useState } from "react";
import { format, formatDistanceToNow } from "date-fns"; 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 { 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 * @example
* ```tsx * ```tsx
* <ActivityFeed * <ActivityFeed
* activities={activities} * activities={activities}
* title="Recent Activity" * isLoading={isLoading}
* emptyMessage="No recent activities" * onLoadMore={handleLoadMore}
* hasMore={hasMoreActivities}
* /> * />
* ``` * ```
*/ */
export interface ActivityItem { export interface Activity {
/** /**
* Unique identifier * Unique ID for the activity
*/ */
id: string | number; id: string | number;
/** /**
* Activity type * Type of activity
*/ */
type: string; type: string;
/**
* Activity description
*/
description: string;
/**
* When the activity occurred
*/
timestamp: Date | string;
/** /**
* User who performed the activity * User who performed the activity
*/ */
user?: { user: {
id: string | number; id: string | number;
name: string; name: string;
avatar?: 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>; meta?: Record<string, any>;
/**
* Optional group identifier for grouping related activities
*/
groupId?: string | number;
} }
export interface ActivityFeedProps { export interface ActivityFeedProps {
/** /**
* List of activities to display * Array of activity items to display
*/ */
activities: ActivityItem[]; activities: Activity[];
/** /**
* Optional title for the feed * Whether activities are currently loading
*/
title?: string;
/**
* Message to display when there are no activities
*/
emptyMessage?: string;
/**
* Whether the feed is loading
*/ */
isLoading?: boolean; 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; 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 * Additional CSS classes
*/ */
className?: string; className?: string;
} }
export function ActivityFeed({ interface ActivityGroup {
activities, date: string;
title, activities: Activity[];
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;
// Group activities by date /**
const groupedActivities: Record<string, ActivityItem[]> = {}; * Get icon for activity type
*/
displayActivities.forEach(activity => { 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" />,
};
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 date = new Date(activity.timestamp);
const dateKey = format(date, 'yyyy-MM-dd'); const dateKey = format(date, 'yyyy-MM-dd');
if (!groupedActivities[dateKey]) { if (!groups[dateKey]) {
groupedActivities[dateKey] = []; groups[dateKey] = [];
} }
groupedActivities[dateKey].push(activity); groups[dateKey].push(activity);
}); });
// Sort date keys in descending order return Object.entries(groups).map(([date, activities]) => ({
const sortedDateKeys = Object.keys(groupedActivities).sort((a, b) => b.localeCompare(a)); 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) => { * Format date for group headers
const date = new Date(dateKey); */
const today = new Date(); const formatGroupDate = (dateString: string): string => {
const yesterday = new Date(today); const date = new Date(dateString);
yesterday.setDate(yesterday.getDate() - 1); const today = new Date();
const yesterday = new Date(today);
if (format(date, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')) { yesterday.setDate(yesterday.getDate() - 1);
return 'Today';
} else if (format(date, 'yyyy-MM-dd') === format(yesterday, 'yyyy-MM-dd')) { if (format(date, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')) {
return 'Yesterday'; return 'Today';
} else { } else if (format(date, 'yyyy-MM-dd') === format(yesterday, 'yyyy-MM-dd')) {
return format(date, 'MMMM d, yyyy'); 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 // Toggle a filter
const formatTime = (timestamp: Date | string) => { const toggleFilter = (type: string) => {
const date = new Date(timestamp); setActiveFilters(prev =>
return format(date, 'h:mm a'); 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 ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
{/* Title */} {/* Filter controls */}
{title && <h3 className="font-medium text-lg">{title}</h3>} {!hideFilters && availableTypes.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{/* Activity List */} {availableTypes.map(type => (
<div className="space-y-6"> <Badge
{isLoading ? ( key={type}
// Loading state variant={activeFilters.includes(type) ? "default" : "outline"}
Array.from({ length: 3 }).map((_, index) => ( className="cursor-pointer"
<ActivityItemSkeleton key={index} /> onClick={() => toggleFilter(type)}
)) >
) : displayActivities.length === 0 ? ( <span className="flex items-center gap-1">
// Empty state {getActivityIcon(type)}
<p className="text-muted-foreground text-center py-6">{emptyMessage}</p> <span className="capitalize">{type}</span>
) : ( </span>
// Grouped activities </Badge>
sortedDateKeys.map(dateKey => ( ))}
<div key={dateKey} className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground"> {activeFilters.length > 0 && (
{formatDateGroup(dateKey)} <Button
</h4> variant="ghost"
<ul className="space-y-4"> size="sm"
{groupedActivities[dateKey].map(activity => ( onClick={() => setActiveFilters([])}
<li key={activity.id} className="relative pl-6"> className="h-6 px-2 text-xs"
{/* Timeline connector */} >
<div className="absolute left-0 top-0 bottom-0 w-px bg-border" /> Clear filters
</Button>
{/* 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>
</div> </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> </div>
); );
} }
// Skeleton loader for activity items export default ActivityFeed;
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>
);
}