mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +00:00
- 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
521 lines
14 KiB
TypeScript
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;
|