tercul-frontend/client/src/components/dashboard/activity-feed.tsx
Damir Mukimov 4a23f496fa
Major frontend development updates
- Enhanced annotation system with improved inline editing
- Updated author components with new card and header designs
- Improved reading view with enhanced line numbering and controls
- Added new blog management features and tag management
- Updated UI components with improved accessibility and styling
- Enhanced search functionality with better filtering
- Added new dashboard features and activity feeds
- Improved translation selector and work comparison tools
- Updated GraphQL integration and API hooks
- Enhanced responsive design and mobile experience
2025-11-27 03:44:09 +01:00

521 lines
14 KiB
TypeScript

import { format, formatDistance } from "date-fns";
import {
AlertCircle,
Bookmark,
BookOpen,
CheckCircle,
ChevronDown,
Clock,
FileEdit,
Heart,
Layers,
MessageSquare,
MoreHorizontal,
PenSquare,
RefreshCw,
Star,
Tag,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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 { Timeline, TimelineItem } from "@/components/ui/timeline";
import { cn } from "@/lib/utils";
/**
* Activity Feed component for displaying user activity timelines
*
* @example
* ```tsx
* <ActivityFeed
* activities={activities}
* isLoading={isLoading}
* onLoadMore={handleLoadMore}
* hasMore={hasMoreActivities}
* />
* ```
*/
export interface Activity {
/**
* Unique ID for the activity
*/
id: string | number;
/**
* Type of activity
*/
type: string;
/**
* User who performed the activity
*/
user: {
id: string | number;
name: string;
avatar?: string;
role?: string;
};
/**
* Timestamp when the activity occurred
*/
timestamp: string | Date;
/**
* Associated entity of the activity (work, comment, etc)
*/
entity?: {
id: string | number;
type: string;
name: string;
url?: string;
};
/**
* 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 {
/**
* Array of activity items to display
*/
activities: Activity[];
/**
* Whether activities are currently loading
*/
isLoading?: boolean;
/**
* 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;
}
interface ActivityGroup {
date: string;
activities: Activity[];
}
/**
* 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" />,
};
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 (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(activity);
});
return Object.entries(groups)
.map(([date, activities]) => ({
date,
activities,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
};
/**
* 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");
}
};
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()],
}));
};
// 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)}>
{/* 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>
))}
{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>
);
}
export default ActivityFeed;