mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51: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 |
|
| 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
|
||||||
|
|||||||
@ -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 { 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user