diff --git a/COMPONENT-IMPLEMENTATION-TRACKER.md b/COMPONENT-IMPLEMENTATION-TRACKER.md index 6be5a4b..52b7b2f 100644 --- a/COMPONENT-IMPLEMENTATION-TRACKER.md +++ b/COMPONENT-IMPLEMENTATION-TRACKER.md @@ -19,7 +19,7 @@ This document tracks the implementation status of all components for the Tercul | Component | Status | File Path | Notes | |-----------|--------|-----------|-------| | Dashboard Header | ✅ Implemented | `client/src/components/dashboard/dashboard-header.tsx` | Complete with title, description, and actions | -| Activity Feed | ✅ Implemented | `client/src/components/dashboard/activity-feed.tsx` | Implemented with grouped timeline | +| Activity Feed | ✅ Implemented | `client/src/components/dashboard/activity-feed.tsx` | Complete with timeline, filters, and expandable entries | | Content Queue | ⬜️ Planned | `client/src/components/dashboard/content-queue.tsx` | | ### Phase 3: Work Management Components @@ -36,7 +36,7 @@ This document tracks the implementation status of all components for the Tercul | Component | Status | File Path | Notes | |-----------|--------|-----------|-------| | Author Editor | ⬜️ Planned | `client/src/components/authors/author-editor.tsx` | | -| Author Card | ⬜️ Planned | `client/src/components/authors/author-card.tsx` | | +| Author Card | ✅ Implemented | `client/src/components/authors/author-card.tsx` | Complete with biography, stats, and follow functionality | | Author Header | ⬜️ Planned | `client/src/components/authors/author-header.tsx` | | ### Phase 5: Comment and Annotation Components diff --git a/client/src/components/authors/author-card.tsx b/client/src/components/authors/author-card.tsx index e69de29..cd20dd6 100644 --- a/client/src/components/authors/author-card.tsx +++ b/client/src/components/authors/author-card.tsx @@ -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 + * + * ``` + */ + +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, + VariantProps { + /** + * 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 ( + + +
+ {/* Author avatar */} + + + + {author.name.split(' ').map(n => n[0]).join('').substring(0, 2)} + + + + {/* Author name and metadata */} +
+
+

+ {author.name} +

+ + {/* Featured badge */} + {author.isFeatured && ( + + Featured + + )} +
+ + {/* Era/Years */} +
+ {author.era && ( +
+ + {author.era} +
+ )} + + {yearsDisplay && ( +
+ {!author.era && } + {yearsDisplay} +
+ )} + + {/* Nationality */} + {author.nationality && ( +
+ + {author.nationality} +
+ )} + + {/* Location */} + {author.location && variant !== "compact" && variant !== "minimal" && ( +
+ + {author.location} +
+ )} +
+ + {/* Genres */} + {author.genres && author.genres.length > 0 && variant !== "minimal" && ( +
+ {author.genres.slice(0, maxGenres).map((genre, index) => ( + + {genre} + + ))} + {author.genres.length > maxGenres && ( + + +{author.genres.length - maxGenres} + + )} +
+ )} +
+
+
+ + {/* Bio and content */} + {(displayBio || variant === "detailed") && ( + + {/* Author bio */} + {displayBio && ( +
+

+ {displayBio} +

+ + {author.bio && author.bio.length > bioLength && ( + + )} +
+ )} + + {/* Influences */} + {author.influences && author.influences.length > 0 && variant === "detailed" && ( +
+

Influences:

+
+ {author.influences.map((influence, index) => ( + + {influence} + + ))} +
+
+ )} +
+ )} + + {/* Card footer with stats and actions */} + {(showStats || showFollowButton) && ( + + {/* Stats */} + {showStats && ( +
+ + {typeof author.works === 'number' && ( + + +
+ + {author.works} +
+
+ +

{author.works} works

+
+
+ )} + + {typeof author.followers === 'number' && ( + + +
+ + {author.followers} +
+
+ +

{author.followers} followers

+
+
+ )} +
+
+ )} + + {/* Actions */} +
+ {author.url && ( + + )} + + {showFollowButton && ( + + )} +
+
+ )} +
+ ); +} + +export default AuthorCard; \ No newline at end of file diff --git a/client/src/components/dashboard/activity-feed.tsx b/client/src/components/dashboard/activity-feed.tsx index 9686a7c..772d21a 100644 --- a/client/src/components/dashboard/activity-feed.tsx +++ b/client/src/components/dashboard/activity-feed.tsx @@ -1,243 +1,480 @@ import { cn } from "@/lib/utils"; -import { LucideIcon, Clock, User } from "lucide-react"; -import { format, formatDistanceToNow } from "date-fns"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Skeleton } from "@/components/ui/skeleton"; +import { Timeline, TimelineItem } from "@/components/ui/timeline"; +import { format, formatDistance } from "date-fns"; +import { + BookOpen, + MessageSquare, + Heart, + PenSquare, + FileEdit, + UserPlus, + Bookmark, + Star, + AlertCircle, + CheckCircle, + Clock, + MoreHorizontal, + ChevronDown, + RefreshCw, + Users, + Tag, + Layers +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; /** - * Activity feed component for displaying recent activities + * Activity Feed component for displaying user activity timelines * * @example * ```tsx - * * ``` */ -export interface ActivityItem { +export interface Activity { /** - * Unique identifier + * Unique ID for the activity */ id: string | number; /** - * Activity type + * Type of activity */ type: string; - /** - * Activity description - */ - description: string; - /** - * When the activity occurred - */ - timestamp: Date | string; /** * User who performed the activity */ - user?: { + user: { id: string | number; name: string; avatar?: string; + role?: string; }; /** - * Icon to display for this activity + * Timestamp when the activity occurred */ - icon?: LucideIcon; + timestamp: string | Date; /** - * Link URL for this activity + * Associated entity of the activity (work, comment, etc) */ - href?: string; + entity?: { + id: string | number; + type: string; + name: string; + url?: string; + }; /** - * Optional metadata + * Secondary entity involved in the activity (e.g., the tag that was added) + */ + secondaryEntity?: { + id: string | number; + type: string; + name: string; + url?: string; + }; + /** + * Additional metadata specific to the activity type */ meta?: Record; + /** + * Optional group identifier for grouping related activities + */ + groupId?: string | number; } export interface ActivityFeedProps { /** - * List of activities to display + * Array of activity items to display */ - activities: ActivityItem[]; + activities: Activity[]; /** - * Optional title for the feed - */ - title?: string; - /** - * Message to display when there are no activities - */ - emptyMessage?: string; - /** - * Whether the feed is loading + * Whether activities are currently loading */ isLoading?: boolean; /** - * Maximum number of items to show + * Callback to load more activities + */ + onLoadMore?: () => void; + /** + * Whether there are more activities to load + */ + hasMore?: boolean; + /** + * Callback when an activity is clicked + */ + onActivityClick?: (activity: Activity) => void; + /** + * Maximum number of activities to display before scrolling */ maxItems?: number; + /** + * Maximum height of the feed + */ + maxHeight?: string | number; + /** + * Whether to group activities by date + */ + groupByDate?: boolean; + /** + * Whether to use a compact layout + */ + compact?: boolean; + /** + * Optional filter by activity type + */ + filter?: string[]; + /** + * Whether to hide the filter controls + */ + hideFilters?: boolean; /** * Additional CSS classes */ className?: string; } -export function ActivityFeed({ - activities, - title, - emptyMessage = "No recent activity", - isLoading = false, - maxItems, - className, -}: ActivityFeedProps) { - // Limit number of activities if maxItems is specified - const displayActivities = maxItems - ? activities.slice(0, maxItems) - : activities; +interface ActivityGroup { + date: string; + activities: Activity[]; +} - // Group activities by date - const groupedActivities: Record = {}; - - displayActivities.forEach(activity => { +/** + * Get icon for activity type + */ +const getActivityIcon = (type: string): React.ReactNode => { + const icons: Record = { + comment: , + like: , + create: , + edit: , + join: , + bookmark: , + rate: , + alert: , + complete: , + schedule: , + read: , + follow: , + tag: , + collection: , + }; + + return icons[type] || ; +}; + +/** + * Get color variant for activity type + */ +const getActivityVariant = (type: string): string => { + const variants: Record = { + 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 = { + 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 = {}; + + activities.forEach(activity => { const date = new Date(activity.timestamp); const dateKey = format(date, 'yyyy-MM-dd'); - if (!groupedActivities[dateKey]) { - groupedActivities[dateKey] = []; + if (!groups[dateKey]) { + groups[dateKey] = []; } - groupedActivities[dateKey].push(activity); + groups[dateKey].push(activity); }); - // Sort date keys in descending order - const sortedDateKeys = Object.keys(groupedActivities).sort((a, b) => b.localeCompare(a)); + return Object.entries(groups).map(([date, activities]) => ({ + date, + activities, + })).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); +}; - // Helper function to format date groups - const formatDateGroup = (dateKey: string) => { - const date = new Date(dateKey); - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - 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'); - } +/** + * 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(filter); + const [expanded, setExpanded] = useState>({}); + + // Apply filters to activities + const filteredActivities = activeFilters.length > 0 + ? activities.filter(activity => activeFilters.includes(activity.type)) + : activities; + + // Group activities by date if needed + const groupedActivities = groupByDate + ? groupActivitiesByDate(filteredActivities) + : [{ date: 'all', activities: filteredActivities }]; + + // Function to handle toggling activity expansion + const toggleExpanded = (id: string | number) => { + setExpanded(prev => ({ + ...prev, + [id.toString()]: !prev[id.toString()] + })); }; - // Format the relative time - const formatTime = (timestamp: Date | string) => { - const date = new Date(timestamp); - return format(date, 'h:mm a'); + // Toggle a filter + const toggleFilter = (type: string) => { + setActiveFilters(prev => + prev.includes(type) + ? prev.filter(t => t !== type) + : [...prev, type] + ); }; + // Generate list of available types from activities + const availableTypes = Array.from(new Set(activities.map(activity => activity.type))); + return (
- {/* Title */} - {title &&

{title}

} - - {/* Activity List */} -
- {isLoading ? ( - // Loading state - Array.from({ length: 3 }).map((_, index) => ( - - )) - ) : displayActivities.length === 0 ? ( - // Empty state -

{emptyMessage}

- ) : ( - // Grouped activities - sortedDateKeys.map(dateKey => ( -
-

- {formatDateGroup(dateKey)} -

-
    - {groupedActivities[dateKey].map(activity => ( -
  • - {/* Timeline connector */} -
    - - {/* Activity dot */} -
    - - {/* Activity content */} -
    - {/* Activity icon or user avatar */} - {activity.user ? ( - - - - {activity.user.name.split(' ').map(n => n[0]).join('')} - - - ) : activity.icon ? ( -
    - -
    - ) : null} - - {/* Activity details */} -
    -
    - {activity.user && ( - {activity.user.name} - )}{' '} - {activity.description} -
    - - {/* Time */} -
    - - {formatTime(activity.timestamp)} -
    -
    -
    -
  • - ))} -
-
- )) - )} -
- - {/* Show more link */} - {maxItems && activities.length > maxItems && ( -
- { - e.preventDefault(); - // Handle showing more activities or navigation - }} - > - View all activities - + {/* Filter controls */} + {!hideFilters && availableTypes.length > 0 && ( +
+ {availableTypes.map(type => ( + toggleFilter(type)} + > + + {getActivityIcon(type)} + {type} + + + ))} + + {activeFilters.length > 0 && ( + + )}
)} + + {/* Activity list */} + + {groupedActivities.length === 0 || filteredActivities.length === 0 ? ( +
+ No activities to display +
+ ) : ( +
+ {groupedActivities.map((group) => ( +
+ {/* Date header (if grouping by date) */} + {groupByDate && ( +
+

+ {formatGroupDate(group.date)} +

+ +
+ )} + + {/* Activities list */} + + {group.activities.map((activity) => { + const timestamp = new Date(activity.timestamp); + const isExpanded = expanded[activity.id.toString()]; + + return ( + +
+ + + {activity.user.name.charAt(0)} + + {activity.user.name} +
+
+ + {formatDistance(timestamp, new Date(), { addSuffix: true })} + +
+
+ } + description={ +
+
+
+ {getActivityIcon(activity.type)} +
+ {getActivityMessage(activity)} +
+ + {activity.entity && ( +
+
toggleExpanded(activity.id)} + > +
+
{activity.entity.name}
+ +
+ + {isExpanded && activity.meta && ( +
+ {activity.meta.excerpt && ( +
"{activity.meta.excerpt}"
+ )} + {activity.meta.details && ( +
{activity.meta.details}
+ )} +
+ )} +
+
+ )} +
+ } + icon={activity.meta?.important ? AlertCircle : undefined} + active={activity.meta?.active} + variant={activity.meta?.highlighted ? "highlight" : undefined} + /> + ); + })} + +
+ ))} +
+ )} + + {/* Load more button */} + {hasMore && ( +
+ +
+ )} +
); } -// Skeleton loader for activity items -function ActivityItemSkeleton() { - return ( -
-
-
- - - -
- - -
-
- ); -} \ No newline at end of file +export default ActivityFeed; \ No newline at end of file