diff --git a/client/pages/Feed.tsx b/client/pages/Feed.tsx index cb18a989..e26a4e46 100644 --- a/client/pages/Feed.tsx +++ b/client/pages/Feed.tsx @@ -1,25 +1,34 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Layout from "@/components/Layout"; -import { useAuth } from "@/contexts/AuthContext"; -import { useEffect, useState } from "react"; import LoadingScreen from "@/components/LoadingScreen"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { aethexSocialService } from "@/lib/aethex-social-service"; -import { communityService, realtimeService } from "@/lib/supabase-service"; import PostComposer from "@/components/social/PostComposer"; -import { useToast } from "@/hooks/use-toast"; +import { FeedItemCard } from "@/components/social/FeedItemCard"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { - Heart, - MessageCircle, - Share2, - UserPlus, - UserCheck, - Volume2, - VolumeX, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/contexts/AuthContext"; +import { aethexSocialService } from "@/lib/aethex-social-service"; +import { cn } from "@/lib/utils"; +import { communityService, realtimeService } from "@/lib/supabase-service"; +import { + ArrowUpRight, + Flame, + RotateCcw, + Sparkles, + TrendingUp, + Users, } from "lucide-react"; -interface FeedItem { +export interface FeedItem { id: string; authorId: string; authorName: string; @@ -31,6 +40,19 @@ interface FeedItem { comments: number; } +interface TrendingTopic { + topic: string; + count: number; +} + +interface CreatorSummary { + id: string; + name: string; + avatar?: string | null; + likes: number; + posts: number; +} + function parseContent(content: string): { text?: string; mediaUrl?: string | null; @@ -57,232 +79,502 @@ function parseContent(content: string): { export default function Feed() { const { user, loading } = useAuth(); const { toast } = useToast(); + const composerRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); const [following, setFollowing] = useState([]); const [items, setItems] = useState([]); - const [muted, setMuted] = useState(true); + const [activeFilter, setActiveFilter] = useState<"all" | "following" | "trending">( + "all", + ); + + const fetchFeed = useCallback(async () => { + setIsLoading(true); + try { + const posts = await communityService.getPosts(30); + if (user?.id) { + const flw = await aethexSocialService.getFollowing(user.id); + setFollowing(flw); + } else { + setFollowing([]); + } + const mapped: FeedItem[] = posts.map((p: any) => { + const meta = parseContent(p.content); + const author = p.user_profiles || {}; + return { + id: p.id, + authorId: p.author_id, + authorName: author.full_name || author.username || "Community member", + authorAvatar: author.avatar_url, + caption: meta.text, + mediaUrl: meta.mediaUrl, + mediaType: meta.mediaType, + likes: p.likes_count ?? 0, + comments: p.comments_count ?? 0, + }; + }); + + setItems(mapped); + } catch (error) { + console.error("Failed to load feed", error); + setItems([]); + } finally { + setIsLoading(false); + } + }, [user?.id]); useEffect(() => { - const load = async () => { - setIsLoading(true); - try { - const posts = await communityService.getPosts(20); - if (user?.id) { - const flw = await aethexSocialService.getFollowing(user.id); - setFollowing(flw); - } else { - setFollowing([]); - } - const mapped: FeedItem[] = posts.map((p: any) => { - const meta = parseContent(p.content); - const author = p.user_profiles || {}; - return { - id: p.id, - authorId: p.author_id, - authorName: author.full_name || author.username || "User", - authorAvatar: author.avatar_url, - caption: meta.text, - mediaUrl: meta.mediaUrl, - mediaType: meta.mediaType, - likes: p.likes_count ?? 0, - comments: p.comments_count ?? 0, - }; - }); + fetchFeed(); - setItems(mapped); - } catch (error) { - console.error("Failed to load feed", error); - setItems([]); - } finally { - setIsLoading(false); - } - }; - load(); - - let cleanup: any = null; + let cleanup: (() => void) | undefined; try { - const sub = realtimeService.subscribeToCommunityPosts(() => load()); + const subscription = realtimeService.subscribeToCommunityPosts(() => { + fetchFeed(); + }); cleanup = () => { try { - sub.unsubscribe?.(); - } catch {} + subscription.unsubscribe?.(); + } catch (error) { + console.warn("Unable to unsubscribe from community posts", error); + } }; - } catch {} + } catch (error) { + console.warn("Realtime subscription unavailable", error); + } + return () => { cleanup?.(); }; - }, [user, loading]); + }, [fetchFeed]); - const isFollowingAuthor = (id: string) => following.includes(id); - const toggleFollow = async (targetId: string) => { - if (!user) { - toast({ description: "Please sign in to manage follows." }); - return; - } + const isFollowingAuthor = useCallback( + (id: string) => following.includes(id), + [following], + ); - try { - if (isFollowingAuthor(targetId)) { - await aethexSocialService.unfollowUser(user.id, targetId); - setFollowing((s) => s.filter((x) => x !== targetId)); - } else { - await aethexSocialService.followUser(user.id, targetId); - setFollowing((s) => Array.from(new Set([...s, targetId]))); + const toggleFollow = useCallback( + async (targetId: string) => { + if (!user) { + toast({ description: "Please sign in to manage follows." }); + return; } - } catch (error: any) { - toast({ - variant: "destructive", - title: "Action failed", - description: error?.message || "Try again in a moment.", - }); - } - }; - const share = async (id: string) => { - const url = `${location.origin}/feed#post-${id}`; - try { - if ((navigator as any).share) { - await (navigator as any).share({ - title: "AeThex", - text: "Check this post", - url, + try { + if (isFollowingAuthor(targetId)) { + await aethexSocialService.unfollowUser(user.id, targetId); + setFollowing((state) => state.filter((value) => value !== targetId)); + } else { + await aethexSocialService.followUser(user.id, targetId); + setFollowing((state) => Array.from(new Set([...state, targetId]))); + } + } catch (error: any) { + toast({ + variant: "destructive", + title: "Action failed", + description: error?.message || "Try again in a moment.", }); - } else { - await navigator.clipboard.writeText(url); - toast({ description: "Link copied" }); } - } catch {} - }; + }, + [isFollowingAuthor, toast, user], + ); - // Guests can view the feed with demo content - if (loading || isLoading) { + const handleShare = useCallback( + async (id: string) => { + const url = `${location.origin}/feed#post-${id}`; + try { + if ((navigator as any).share) { + await (navigator as any).share({ + title: "AeThex", + text: "Check out this post on AeThex", + url, + }); + } else { + await navigator.clipboard.writeText(url); + toast({ description: "Link copied to clipboard" }); + } + } catch (error) { + console.warn("Share cancelled", error); + } + }, + [toast], + ); + + const filteredItems = useMemo(() => { + if (activeFilter === "following") { + return items.filter( + (item) => isFollowingAuthor(item.authorId) || item.authorId === user?.id, + ); + } + if (activeFilter === "trending") { + return [...items].sort((a, b) => b.likes + b.comments - (a.likes + a.comments)); + } + return items; + }, [activeFilter, isFollowingAuthor, items, user?.id]); + + const trendingTopics = useMemo(() => { + const counts = new Map(); + items.forEach((item) => { + const matches = item.caption?.match(/#[\p{L}0-9_]+/gu) ?? []; + matches.forEach((tag) => { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + }); + }); + return Array.from(counts.entries()) + .map(([topic, count]) => ({ topic, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + }, [items]); + + const topCreators = useMemo(() => { + const map = new Map(); + items.forEach((item) => { + const existing = map.get(item.authorId) ?? { + id: item.authorId, + name: item.authorName, + avatar: item.authorAvatar, + likes: 0, + posts: 0, + }; + existing.likes += item.likes; + existing.posts += 1; + if (!existing.avatar) { + existing.avatar = item.authorAvatar; + } + map.set(item.authorId, existing); + }); + return Array.from(map.values()) + .sort((a, b) => { + if (b.likes === a.likes) return b.posts - a.posts; + return b.likes - a.likes; + }) + .slice(0, 5); + }, [items]); + + const suggestedCreators = useMemo(() => { + return topCreators.filter( + (creator) => creator.id !== user?.id && !isFollowingAuthor(creator.id), + ); + }, [isFollowingAuthor, topCreators, user?.id]); + + const totalEngagement = useMemo( + () => + items.reduce( + (acc, item) => acc + (Number(item.likes) || 0) + (Number(item.comments) || 0), + 0, + ), + [items], + ); + + const averageEngagement = useMemo(() => { + if (!items.length) return 0; + return Math.round(totalEngagement / items.length); + }, [items.length, totalEngagement]); + + const handleScrollToComposer = useCallback(() => { + composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, []); + + const handleManualRefresh = useCallback(() => { + fetchFeed(); + }, [fetchFeed]); + + if (loading || (isLoading && items.length === 0)) { return ( - + ); } return ( -
-
- setIsLoading(true)} /> -
-
- {items.length === 0 && ( -
- No posts yet. Share something to start the feed. +
+
+
+
+
+
+
+

+ Community Pulse +

+

+ Discover new creations, amplify your voice, and engage with the AeThex community in real time. +

+
+
+ + Live updates enabled + + +
+
+ +
+ {( + [ + { + key: "all" as const, + label: "All stories", + icon: Sparkles, + description: "Latest community activity", + }, + { + key: "following" as const, + label: "Following", + icon: Users, + description: "People you follow", + }, + { + key: "trending" as const, + label: "Trending", + icon: Flame, + description: "Most engagement", + }, + ] + ).map(({ key, label, icon: Icon, description }) => ( + + ))} +
- )} - {items.map((item) => ( -
- - - {item.mediaType === "video" && item.mediaUrl ? ( -
-
- -
- - - - +
+
+
+
+
+

Share something new

+

+ Post updates, showcase progress, or spark a conversation with the community. +

+ +
+ fetchFeed()} /> +
+
+ + Your post is shared instantly with followers and the broader community. +
+ +
+
-
-
- - - - {item.authorName[0] || "U"} - - -
-
- {item.authorName} -
- {item.caption && ( -
- {item.caption} -
- )} -
-
+ {filteredItems.length === 0 ? ( + + + No stories found + + Try switching filters or follow more creators to personalize your feed. + + + + + + ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} +
+ +
- ))} + + + + Trending topics + + Popular conversations emerging across the community. + + + + {trendingTopics.length === 0 ? ( +

+ Start a conversation by adding hashtags like #gamedev or #design to your next post. +

+ ) : ( + trendingTopics.map((topic, index) => ( +
+
+ + {index + 1} + +
+

{topic.topic}

+

+ {topic.count.toLocaleString()} mentions today +

+
+
+ +
+ )) + )} +
+
+ + + + Creators to watch + + Follow high-signal builders to enrich your feed. + + + + {suggestedCreators.length === 0 ? ( +

+ You are up to date with the creators you follow. Engage with new posts to unlock more suggestions. +

+ ) : ( + suggestedCreators.map((creator) => ( +
+
+ + + + {creator.name?.[0]?.toUpperCase() || "C"} + + +
+

{creator.name}

+

+ {creator.posts.toLocaleString()} posts ยท {creator.likes.toLocaleString()} reactions +

+
+
+ +
+ )) + )} +
+
+ +