From 738e40fb339250adca882a04956b509ae959dcfa Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 13 Nov 2025 06:20:24 +0000 Subject: [PATCH] Community Feed page - Unified view with arm tabs and filtering cgen-3b2c478c318049ada1bd9321ab682361 --- client/pages/Feed.tsx | 970 +++++++++++++++--------------------------- 1 file changed, 334 insertions(+), 636 deletions(-) diff --git a/client/pages/Feed.tsx b/client/pages/Feed.tsx index 67d8bdf1..4ef9eeb0 100644 --- a/client/pages/Feed.tsx +++ b/client/pages/Feed.tsx @@ -1,688 +1,386 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useState, useEffect, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; import Layout from "@/components/Layout"; -import LoadingScreen from "@/components/LoadingScreen"; -import PostComposer from "@/components/social/PostComposer"; -import { FeedItemCard } from "@/components/social/FeedItemCard"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; +import SEO from "@/components/SEO"; +import ArmPostCard, { ArmType } from "@/components/feed/ArmPostCard"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { useToast } from "@/hooks/use-toast"; +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 { 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 { aethexToast } from "@/lib/aethex-toast"; +import LoadingScreen from "@/components/LoadingScreen"; import { - ArrowUpRight, - Flame, - RotateCcw, + Zap, + Gamepad2, + Briefcase, + BookOpen, + Network, Sparkles, - TrendingUp, - Users, + Shield, + Plus, + Filter, + X, } from "lucide-react"; -export interface FeedItem { +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 { id: 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" }; - } + 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; + }; } export default function Feed() { - const { user, loading } = useAuth(); - const { toast } = useToast(); - const composerRef = useRef(null); + const { user } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); + const [posts, setPosts] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [following, setFollowing] = useState([]); - const [items, setItems] = useState([]); - const [activeFilter, setActiveFilter] = useState< - "all" | "following" | "trending" - >("all"); - - 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 { - // Add timeout to prevent indefinite hanging (30 seconds) - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); - - try { - // Parallelize posts and following fetch with timeout - 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, - ), - ), - ]); - - 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]); + const [selectedArms, setSelectedArms] = useState([]); + const [userFollowedArms, setUserFollowedArms] = useState([]); + const [showFilters, setShowFilters] = useState(false); + const [activeTab, setActiveTab] = useState<"all" | ArmType>("all"); + // Load user's followed arms 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); - } - - return () => { - cleanup?.(); - }; - }, [fetchFeed]); - - const isFollowingAuthor = useCallback( - (id: string) => following.includes(id), - [following], - ); - - const toggleFollow = useCallback( - async (targetId: string) => { - if (!user) { - toast({ description: "Please sign in to manage follows." }); - return; - } + const loadFollowedArms = async () => { + if (!user?.id) return; 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.", - }); - } - }, - [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)" }); + 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.warn("Share cancelled", error); + console.error("Failed to load followed arms:", error); } - }, - [toast], - ); + }; - const handleLike = useCallback( - async (postId: string) => { - if (!user?.id) { - toast({ description: "Please sign in to like posts." }); - return; - } + loadFollowedArms(); + }, [user?.id]); + + // Load feed posts + useEffect(() => { + const loadPosts = async () => { + setIsLoading(true); 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); + 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 || []); + } else { + console.error("Failed to load feed"); + aethexToast.error({ + title: "Failed to load feed", + description: "Please try again later", + }); + } + } 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); } - }, - [toast, user?.id], - ); + }; - const handleComment = useCallback((postId: string) => { - setItems((prev) => - prev.map((it) => - it.id === postId ? { ...it, comments: it.comments + 1 } : it, - ), + loadPosts(); + }, [activeTab, selectedArms, user?.id]); + + const handleArmToggle = (armId: ArmType) => { + setSelectedArms((prev) => + prev.includes(armId) ? prev.filter((a) => a !== armId) : [...prev, armId] ); - }, []); + }; - 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); + const handleFollowArm = async (armId: ArmType) => { + if (!user?.id) { + aethexToast.error({ + title: "Not signed in", + description: "Please sign in to follow arms", }); - }); - return Array.from(counts.entries()) - .map(([topic, count]) => ({ topic, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 5); - }, [items]); + return; + } - 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; + 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", + }), + }); + + 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}`, + }); + } } - 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]); + } catch (error) { + console.error("Error toggling arm follow:", error); + aethexToast.error({ + title: "Error", + description: "Failed to update follow status", + }); + } + }; - const suggestedCreators = useMemo(() => { - return topCreators.filter( - (creator) => creator.id !== user?.id && !isFollowingAuthor(creator.id), - ); - }, [isFollowingAuthor, topCreators, user?.id]); + 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]); - 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 ( - - ); + if (isLoading) { + return ; } return ( -
-
-
-
-
-
+ + +
+
+ {/* Header */} +
+
+
-

- Community Pulse +

+ Community Feed

-

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

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

-
- - 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 }) => ( +
+ {/* 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) => ( ))}
-
-
-
-
-
-
-
-

- Share something new -

-

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

-
- -
- fetchFeed()} - suggestedTags={trendingTopics - .map((t) => t.topic.replace(/^#/, "")) - .slice(0, 8)} - /> -
-
- - 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. - - - - + + + ) : ( + filteredPosts.map((post, index) => ( +
- Reset filters - - - - ) : ( -
- {filteredItems.map((item) => ( - - ))} -
- )} + +
+ )) + )} +
- -