From a9492ca7154a6186a68df9091770a87418e72dd8 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 13 Nov 2025 06:51:37 +0000 Subject: [PATCH] Restore Community Pulse Feed with new Discord/collaboration features cgen-d2c8dab1fee34f169645709a957c1be3 --- client/pages/Feed.tsx | 1136 +++++++++++++++++++++++------------------ 1 file changed, 642 insertions(+), 494 deletions(-) diff --git a/client/pages/Feed.tsx b/client/pages/Feed.tsx index a219462b..c064064c 100644 --- a/client/pages/Feed.tsx +++ b/client/pages/Feed.tsx @@ -1,559 +1,707 @@ -import { useState, useEffect, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Layout from "@/components/Layout"; -import SEO from "@/components/SEO"; -import ArmPostCard, { ArmType } from "@/components/feed/ArmPostCard"; -import CommentsModal from "@/components/feed/CommentsModal"; -import PostComposer from "@/components/feed/PostComposer"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/lib/aethex-toast"; import LoadingScreen from "@/components/LoadingScreen"; +import PostComposer from "@/components/feed/PostComposer"; +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 { - Zap, - Gamepad2, - Briefcase, - BookOpen, - Network, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { useToast } from "@/hooks/use-aethex-toast"; +import { useAuth } from "@/contexts/AuthContext"; +import { aethexSocialService } from "@/lib/aethex-social-service"; +import { cn } from "@/lib/utils"; +import { normalizeErrorMessage } from "@/lib/error-utils"; +import { communityService, realtimeService } from "@/lib/supabase-service"; +import { + ArrowUpRight, + Flame, + RotateCcw, Sparkles, - Shield, - Plus, - Filter, - X, + TrendingUp, + Users, } from "lucide-react"; const API_BASE = import.meta.env.VITE_API_BASE || ""; -const ARMS: { id: ArmType; label: string; icon: any; color: string }[] = [ - { id: "labs", label: "Labs", icon: Zap, color: "text-yellow-400" }, - { id: "gameforge", label: "GameForge", icon: Gamepad2, color: "text-green-400" }, - { id: "corp", label: "Corp", icon: Briefcase, color: "text-blue-400" }, - { id: "foundation", label: "Foundation", icon: BookOpen, color: "text-red-400" }, - { id: "devlink", label: "Dev-Link", icon: Network, color: "text-cyan-400" }, - { id: "nexus", label: "Nexus", icon: Sparkles, color: "text-purple-400" }, - { id: "staff", label: "Staff", icon: Shield, color: "text-purple-400" }, -]; - -interface Post { +export interface FeedItem { id: string; - title: string; - content: string; - arm_affiliation: ArmType; - author_id: string; - created_at: string; - likes_count?: number; - comments_count?: number; - tags?: string[]; - category?: string; - user_profiles?: { - id: string; - username?: string; - full_name?: string; - avatar_url?: string; - }; + authorId: string; + authorName: string; + authorAvatar?: string | null; + caption?: string; + mediaUrl?: string | null; + mediaType: "video" | "image" | "none"; + likes: number; + 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; + mediaType: "video" | "image" | "none"; +} { + try { + const obj = JSON.parse(content || "{}"); + return { + text: obj.text || content, + mediaUrl: obj.mediaUrl || null, + mediaType: + obj.mediaType || + (obj.mediaUrl + ? /(mp4|webm|mov)$/i.test(obj.mediaUrl) + ? "video" + : "image" + : "none"), + }; + } catch { + return { text: content, mediaUrl: null, mediaType: "none" }; + } } export default function Feed() { - const { user } = useAuth(); - const [searchParams, setSearchParams] = useSearchParams(); + const { user, loading } = useAuth(); + const { toast } = useToast(); + const composerRef = useRef(null); - const [posts, setPosts] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [selectedArms, setSelectedArms] = useState([]); - const [userFollowedArms, setUserFollowedArms] = useState([]); - const [showFilters, setShowFilters] = useState(false); - const [activeTab, setActiveTab] = useState<"all" | ArmType>("all"); - const [selectedPostForComments, setSelectedPostForComments] = useState(null); - const [userLikedPosts, setUserLikedPosts] = useState>(new Set()); + const [following, setFollowing] = useState([]); + const [items, setItems] = useState([]); + const [activeFilter, setActiveFilter] = useState< + "all" | "following" | "trending" + >("all"); const [showPostComposer, setShowPostComposer] = useState(false); - // Load user's followed arms - useEffect(() => { - const loadFollowedArms = async () => { - if (!user?.id) return; - - try { - const response = await fetch( - `${API_BASE}/api/user/followed-arms?user_id=${user.id}` - ); - if (response.ok) { - const data = await response.json(); - setUserFollowedArms(data.followedArms || []); - // Initialize selected arms with followed arms - if (data.followedArms.length > 0) { - setSelectedArms(data.followedArms); - } else { - // If no followed arms, default to all - setSelectedArms(ARMS.map((a) => a.id)); - } - } - } catch (error) { - console.error("Failed to load followed arms:", error); - } - }; - - loadFollowedArms(); - }, [user?.id]); - - // Load user's liked posts - const loadUserLikes = async (postIds: string[]) => { - if (!user?.id || postIds.length === 0) return; + const mapPostsToFeedItems = useCallback( + (source: any[]): FeedItem[] => + (Array.isArray(source) ? source : []).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, + }; + }), + [], + ); + const fetchFeed = useCallback(async () => { + setIsLoading(true); try { - const likedPosts = new Set(); - await Promise.allSettled( - postIds.map(async (postId) => { - const response = await fetch( - `${API_BASE}/api/community/post-likes?post_id=${postId}&user_id=${user.id}` - ); - if (response.ok) { - const data = await response.json(); - if (data.userLiked) { - likedPosts.add(postId); - } - } - }) - ); - setUserLikedPosts(likedPosts); - } catch (error) { - console.error("Failed to load user likes:", error); - } - }; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); - // Load feed posts - useEffect(() => { - const loadPosts = async () => { - setIsLoading(true); try { - const armFilter = - activeTab === "all" && selectedArms.length > 0 - ? selectedArms - : activeTab !== "all" - ? [activeTab] - : []; + const [posts, flw] = await Promise.race([ + Promise.all([ + communityService.getPosts(30), + user?.id + ? aethexSocialService.getFollowing(user.id) + : Promise.resolve([]), + ]), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Feed loading timeout - try refreshing")), + 28000, + ), + ), + ]); - const params = new URLSearchParams({ - limit: "50", - offset: "0", - }); + clearTimeout(timeoutId); + setFollowing(Array.isArray(flw) ? flw : []); + let mapped = mapPostsToFeedItems(posts); + setItems(mapped); + } catch (timeoutError) { + clearTimeout(timeoutId); + throw timeoutError; + } + } catch (error) { + console.error("Failed to load feed", error); + toast({ + variant: "destructive", + title: "Failed to load feed", + description: normalizeErrorMessage(error), + }); + setItems([]); + } finally { + setIsLoading(false); + } + }, [mapPostsToFeedItems, toast, user?.id]); - if (armFilter.length > 0) { - armFilter.forEach((arm) => { - params.append("arm_filter", arm); - }); + useEffect(() => { + fetchFeed(); + + let cleanup: (() => void) | undefined; + try { + const subscription = realtimeService.subscribeToCommunityPosts(() => { + fetchFeed(); + }); + cleanup = () => { + try { + subscription.unsubscribe?.(); + } catch (error) { + console.warn("Unable to unsubscribe from community posts", error); } + }; + } catch (error) { + console.warn("Realtime subscription unavailable", error); + } - if (user?.id) { - params.append("user_id", user.id); - } + return () => { + cleanup?.(); + }; + }, [fetchFeed]); - const response = await fetch(`${API_BASE}/api/feed?${params.toString()}`); + const isFollowingAuthor = useCallback( + (id: string) => following.includes(id), + [following], + ); - if (response.ok) { - const data = await response.json(); - setPosts(data.posts || []); - // Load user's likes for these posts - const postIds = data.posts?.map((p: Post) => p.id) || []; - if (postIds.length > 0) { - loadUserLikes(postIds); - } + const toggleFollow = useCallback( + async (targetId: string) => { + if (!user) { + toast({ description: "Please sign in to manage follows." }); + return; + } + + try { + if (isFollowingAuthor(targetId)) { + await aethexSocialService.unfollowUser(user.id, targetId); + setFollowing((state) => state.filter((value) => value !== targetId)); } else { - console.error("Failed to load feed"); - aethexToast.error({ - title: "Failed to load feed", - description: "Please try again later", + 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.", + }); + } + }, + [isFollowingAuthor, toast, user], + ); + + 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 if (typeof window !== "undefined") { + const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + "Check out this post on AeThex", + )}&url=${encodeURIComponent(url)}`; + window.open(intent, "_blank", "noopener,noreferrer"); + await navigator.clipboard.writeText(url).catch(() => undefined); + toast({ description: "Share link opened (copied to clipboard)" }); } } catch (error) { - console.error("Error loading feed:", error); - aethexToast.error({ - title: "Error loading feed", - description: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - setIsLoading(false); + console.warn("Share cancelled", error); } - }; + }, + [toast], + ); - loadPosts(); - }, [activeTab, selectedArms, user?.id]); + const handleLike = useCallback( + async (postId: string) => { + if (!user?.id) { + toast({ description: "Please sign in to like posts." }); + return; + } + try { + const newCount = await communityService.likePost(postId, user.id); + setItems((prev) => + prev.map((it) => + it.id === postId && typeof newCount === "number" + ? { ...it, likes: newCount } + : it, + ), + ); + } catch (e) { + console.warn("Like failed", e); + } + }, + [toast, user?.id], + ); - const handleArmToggle = (armId: ArmType) => { - setSelectedArms((prev) => - prev.includes(armId) ? prev.filter((a) => a !== armId) : [...prev, armId] + const handleComment = useCallback((postId: string) => { + setItems((prev) => + prev.map((it) => + it.id === postId ? { ...it, comments: it.comments + 1 } : it, + ), ); - }; + }, []); - const handleFollowArm = async (armId: ArmType) => { - if (!user?.id) { - aethexToast.error({ - title: "Not signed in", - description: "Please sign in to follow arms", - }); - return; + const handlePostSuccess = useCallback(() => { + setShowPostComposer(false); + fetchFeed(); + }, [fetchFeed]); + + 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]); - try { - const isFollowing = userFollowedArms.includes(armId); - const response = await fetch(`${API_BASE}/api/user/followed-arms`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user_id: user.id, - arm_id: armId, - action: isFollowing ? "unfollow" : "follow", - }), + 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]); - if (response.ok) { - if (isFollowing) { - setUserFollowedArms((prev) => prev.filter((a) => a !== armId)); - aethexToast.info({ - title: "Unfollowed", - description: `You unfollowed ${armId}`, - }); - } else { - setUserFollowedArms((prev) => [...prev, armId]); - aethexToast.success({ - title: "Following", - description: `You're now following ${armId}`, - }); - } + 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; } - } catch (error) { - console.error("Error toggling arm follow:", error); - aethexToast.error({ - title: "Error", - description: "Failed to update follow status", - }); - } - }; + 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 handleLike = async (postId: string) => { - if (!user?.id) { - aethexToast.error({ - title: "Not signed in", - description: "Please sign in to like posts", - }); - return; - } + const suggestedCreators = useMemo(() => { + return topCreators.filter( + (creator) => creator.id !== user?.id && !isFollowingAuthor(creator.id), + ); + }, [isFollowingAuthor, topCreators, user?.id]); - try { - const response = await fetch(`${API_BASE}/api/community/post-likes`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - post_id: postId, - user_id: user.id, - }), - }); + const totalEngagement = useMemo( + () => + items.reduce( + (acc, item) => + acc + (Number(item.likes) || 0) + (Number(item.comments) || 0), + 0, + ), + [items], + ); - if (response.ok) { - const data = await response.json(); - if (data.liked) { - setUserLikedPosts((prev) => new Set([...prev, postId])); - } else { - setUserLikedPosts((prev) => { - const newSet = new Set(prev); - newSet.delete(postId); - return newSet; - }); - } + const averageEngagement = useMemo(() => { + if (!items.length) return 0; + return Math.round(totalEngagement / items.length); + }, [items.length, totalEngagement]); - // Update post likes count - setPosts((prev) => - prev.map((p) => - p.id === postId ? { ...p, likes_count: data.likes_count } : p - ) - ); - } - } catch (error) { - console.error("Error toggling like:", error); - aethexToast.error({ - title: "Error", - description: "Failed to like post", - }); - } - }; + const handleScrollToComposer = useCallback(() => { + composerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, []); - const handleCommentClick = (postId: string) => { - setSelectedPostForComments(postId); - }; + const handleManualRefresh = useCallback(() => { + fetchFeed(); + }, [fetchFeed]); - const handleCommentAdded = () => { - // Reload posts to get updated comment count - const loadPosts = async () => { - try { - const armFilter = - activeTab === "all" && selectedArms.length > 0 - ? selectedArms - : activeTab !== "all" - ? [activeTab] - : []; - - const params = new URLSearchParams({ - limit: "50", - offset: "0", - }); - - if (armFilter.length > 0) { - armFilter.forEach((arm) => { - params.append("arm_filter", arm); - }); - } - - if (user?.id) { - params.append("user_id", user.id); - } - - const response = await fetch(`${API_BASE}/api/feed?${params.toString()}`); - - if (response.ok) { - const data = await response.json(); - setPosts(data.posts || []); - } - } catch (error) { - console.error("Error loading feed:", error); - } - }; - - loadPosts(); - }; - - const filteredPosts = useMemo(() => { - if (activeTab === "all") { - return posts.filter((post) => selectedArms.includes(post.arm_affiliation)); - } - return posts.filter((post) => post.arm_affiliation === activeTab); - }, [posts, activeTab, selectedArms]); - - if (isLoading) { - return ; + if (loading || (isLoading && items.length === 0)) { + return ( + + ); } return ( - - -
-
- {/* Header */} -
-
-
+
+
+
+
+
+
-

- Community Feed +

+ Community Pulse

-

- The AeThex Town Square • See what all arms are building +

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

- -
-
-
- -
- {/* Sidebar - Filters & Followed Arms */} -
- {/* Filter Toggle (Mobile) */} - - - {/* Filters Card */} - {(showFilters || true) && ( - - - - - Filter by Arm - - - - {ARMS.map((arm) => ( -
- handleArmToggle(arm.id)} - className="transition-all" - /> - - - {posts.filter((p) => p.arm_affiliation === arm.id).length} - -
- ))} -
-
- )} - - {/* Followed Arms Card */} - - - My Arms - - - {userFollowedArms.length === 0 ? ( -

- No arms followed yet. Start following to personalize your feed. -

- ) : ( - userFollowedArms.map((armId) => { - const arm = ARMS.find((a) => a.id === armId); - if (!arm) return null; - return ( -
-
- - {arm.label} -
- -
- ); - }) - )} -
-
-
- - {/* Main Feed Content */} -
- {/* Tabs */} -
- - {ARMS.map((arm) => ( - +
+
+ +
+ {[ + { + 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 }) => ( + ))}
- - {/* Posts */} -
- {filteredPosts.length === 0 ? ( - - -

- No posts found in{" "} - {activeTab === "all" ? "your feed" : activeTab} -

- -
-
- ) : ( - filteredPosts.map((post, index) => ( -
- handleLike(post.id)} - onComment={() => handleCommentClick(post.id)} - isLiked={userLikedPosts.has(post.id)} - /> -
- )) - )} -
+
+ +
+
+
+
+
+

+ Share something new +

+

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

+
+ +
+ +
+
+ + Your post is shared instantly with followers and the broader + community. +
+ +
+
+ + {filteredItems.length === 0 ? ( + + + No stories found + + Try switching filters or follow more creators to + personalize your feed. + + + + + + + ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} +
+ +
- - {/* Comments Modal */} - {selectedPostForComments && ( - { - if (!open) { - setSelectedPostForComments(null); - } - }} - postId={selectedPostForComments} - currentUserId={user?.id} - onCommentAdded={handleCommentAdded} - /> - )} - - {/* Post Composer Modal */} - { - setShowPostComposer(false); - handleCommentAdded(); // Reload feed - }} - /> ); }