diff --git a/client/pages/Activity.tsx b/client/pages/Activity.tsx index ef2cbf2f..57f746ff 100644 --- a/client/pages/Activity.tsx +++ b/client/pages/Activity.tsx @@ -1,18 +1,15 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useDiscordActivity } from "@/contexts/DiscordActivityContext"; import LoadingScreen from "@/components/LoadingScreen"; import { Heart, MessageCircle, - Trophy, Zap, Gamepad2, Briefcase, BookOpen, - Network, Sparkles, Shield, - RefreshCw, ExternalLink, Flame, Star, @@ -22,19 +19,24 @@ import { AlertCircle, Loader2, ChevronRight, + Users, + TrendingUp, + Calendar, + Award, } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; const APP_URL = "https://aethex.dev"; -type ArmType = "labs" | "gameforge" | "corp" | "foundation" | "devlink" | "nexus" | "staff"; +type ArmType = "labs" | "gameforge" | "corp" | "foundation" | "nexus" | "staff"; -const ARM_CONFIG: Record = { - labs: { label: "Labs", icon: Zap, color: "#facc15", accent: "bg-yellow-500" }, - gameforge: { label: "GameForge", icon: Gamepad2, color: "#4ade80", accent: "bg-green-500" }, - corp: { label: "Corp", icon: Briefcase, color: "#60a5fa", accent: "bg-blue-500" }, - foundation: { label: "Foundation", icon: BookOpen, color: "#f87171", accent: "bg-red-500" }, - nexus: { label: "Nexus", icon: Sparkles, color: "#c084fc", accent: "bg-purple-500" }, - staff: { label: "Staff", icon: Shield, color: "#818cf8", accent: "bg-indigo-500" }, +const ARM_CONFIG: Record = { + labs: { label: "Labs", icon: Zap, color: "#facc15", gradient: "from-yellow-500/20 via-amber-500/10 to-transparent", glow: "shadow-yellow-500/30" }, + gameforge: { label: "GameForge", icon: Gamepad2, color: "#4ade80", gradient: "from-green-500/20 via-emerald-500/10 to-transparent", glow: "shadow-green-500/30" }, + corp: { label: "Corp", icon: Briefcase, color: "#60a5fa", gradient: "from-blue-500/20 via-sky-500/10 to-transparent", glow: "shadow-blue-500/30" }, + foundation: { label: "Foundation", icon: BookOpen, color: "#f87171", gradient: "from-red-500/20 via-rose-500/10 to-transparent", glow: "shadow-red-500/30" }, + nexus: { label: "Nexus", icon: Sparkles, color: "#c084fc", gradient: "from-purple-500/20 via-violet-500/10 to-transparent", glow: "shadow-purple-500/30" }, + staff: { label: "Staff", icon: Shield, color: "#818cf8", gradient: "from-indigo-500/20 via-blue-500/10 to-transparent", glow: "shadow-indigo-500/30" }, }; interface Post { @@ -50,47 +52,124 @@ interface Post { user_profiles?: { id: string; username?: string; full_name?: string; avatar_url?: string }; } -interface Quest { - id: string; - title: string; - description: string; - xp_reward: number; - completed: boolean; - progress: number; - total: number; - type: "daily" | "weekly"; -} - -interface Achievement { - id: string; - name: string; - description: string; - icon: string; - xp_reward: number; - unlocked: boolean; - progress?: number; - total?: number; -} - interface LeaderboardEntry { rank: number; user_id: string; username: string; - xp: number; + avatar_url?: string; + total_xp: number; level: number; - streak?: number; + current_streak?: number; } -function FeedTab({ openExternalLink }: { openExternalLink: (url: string) => Promise }) { +interface UserStats { + total_xp: number; + level: number; + current_streak: number; + longest_streak: number; + rank?: number; +} + +function XPRing({ xp, level, size = 64, strokeWidth = 4, color }: { xp: number; level: number; size?: number; strokeWidth?: number; color: string }) { + const xpForLevel = 1000; + const xpInCurrentLevel = xp % xpForLevel; + const progress = xpInCurrentLevel / xpForLevel; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference * (1 - progress); + + return ( +
+ + + + +
+ {level} +
+
+ ); +} + +function XPGainAnimation({ amount, onComplete }: { amount: number; onComplete: () => void }) { + useEffect(() => { + const timer = setTimeout(onComplete, 2000); + return () => clearTimeout(timer); + }, [onComplete]); + + return ( + + +{amount} XP + + ); +} + +function ConfettiEffect() { + const colors = ["#facc15", "#4ade80", "#60a5fa", "#c084fc", "#f87171"]; + return ( +
+ {Array.from({ length: 50 }).map((_, i) => ( + 0.5 ? 1 : -1), + x: (Math.random() - 0.5) * 200 + }} + transition={{ + duration: 2 + Math.random() * 2, + delay: Math.random() * 0.5, + ease: "easeOut" + }} + /> + ))} +
+ ); +} + +function FeedTab({ openExternalLink, userId }: { openExternalLink: (url: string) => Promise; userId?: string }) { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [likingPost, setLikingPost] = useState(null); + const [likedPosts, setLikedPosts] = useState>(new Set()); const fetchPosts = useCallback(async () => { setLoading(true); setError(null); try { - const response = await fetch("/api/feed?limit=8"); + const response = await fetch("/api/feed?limit=10"); if (!response.ok) throw new Error("Failed to load"); const data = await response.json(); setPosts(data.data || []); @@ -101,47 +180,107 @@ function FeedTab({ openExternalLink }: { openExternalLink: (url: string) => Prom } }, []); + const handleQuickLike = async (postId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!userId || likingPost) return; + + setLikingPost(postId); + try { + const response = await fetch(`/api/feed/${postId}/like`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (response.ok) { + setLikedPosts(prev => new Set(prev).add(postId)); + setPosts(prev => prev.map(p => + p.id === postId ? { ...p, likes_count: p.likes_count + 1 } : p + )); + } + } catch { + // Silent fail for likes + } finally { + setLikingPost(null); + } + }; + useEffect(() => { fetchPosts(); }, [fetchPosts]); - if (loading) return
; + if (loading) return ( +
+ +
+ ); + if (error) return (
+

{error}

- +
); return ( -
+ {posts.length === 0 ? ( - ) : ( - posts.map((post) => { - const config = ARM_CONFIG[post.arm_affiliation] || ARM_CONFIG.labs; + posts.map((post, index) => { + const config = ARM_CONFIG[post.arm_affiliation] || ARM_CONFIG.nexus; + const isLiked = likedPosts.has(post.id); + const isLiking = likingPost === post.id; + return ( - + + {post.comments_count} + +
+ - - - + + ); }) )} - - + ); } @@ -149,196 +288,536 @@ function RealmsTab({ currentRealm, openExternalLink }: { currentRealm: ArmType; const realms = Object.entries(ARM_CONFIG) as [ArmType, typeof ARM_CONFIG[ArmType]][]; return ( -
+
- {realms.map(([key, config]) => { + {realms.map(([key, config], index) => { const Icon = config.icon; const isActive = currentRealm === key; return ( - + openExternalLink(`${APP_URL}/${key}`)} + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ delay: index * 0.05 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={`p-3 rounded-xl transition-all flex items-center gap-3 border ${ + isActive + ? `bg-gradient-to-br ${config.gradient} border-[${config.color}]/30 shadow-lg ${config.glow}` + : "bg-[#232428] hover:bg-[#2b2d31] border-[#3f4147]" + }`} + > +
+ +
+ {config.label} + {isActive && } +
); })}
- -
+

Tap to explore each realm

+ ); } -function BadgesTab({ openExternalLink }: { openExternalLink: (url: string) => Promise }) { - const achievements: Achievement[] = [ - { id: "1", name: "First Post", description: "Create your first post", icon: "πŸ“", xp_reward: 50, unlocked: true }, - { id: "2", name: "Social Butterfly", description: "Follow 5 realms", icon: "πŸ¦‹", xp_reward: 100, unlocked: true }, - { id: "3", name: "Realm Explorer", description: "Visit all 7 realms", icon: "πŸ—ΊοΈ", xp_reward: 150, unlocked: false, progress: 5, total: 7 }, - { id: "4", name: "Community Leader", description: "Get 100 likes", icon: "πŸ‘‘", xp_reward: 500, unlocked: false, progress: 42, total: 100 }, - ]; +function LeaderboardTab({ openExternalLink, currentUserId }: { openExternalLink: (url: string) => Promise; currentUserId?: string }) { + const [leaderboard, setLeaderboard] = useState([]); + const [loading, setLoading] = useState(true); + const [userRank, setUserRank] = useState(null); - return ( -
- {achievements.map((a) => ( -
- {a.icon} -
-
- {a.name} - +{a.xp_reward} XP -
-

{a.description}

- {!a.unlocked && a.progress !== undefined && ( -
-
-
-
- {a.progress}/{a.total} -
- )} -
- {a.unlocked && } -
- ))} - -
- ); -} - -function TopTab({ openExternalLink }: { openExternalLink: (url: string) => Promise }) { - const leaderboard: LeaderboardEntry[] = [ - { rank: 1, user_id: "1", username: "CodeMaster", xp: 12500, level: 25, streak: 14 }, - { rank: 2, user_id: "2", username: "DevNinja", xp: 11200, level: 23, streak: 7 }, - { rank: 3, user_id: "3", username: "BuilderX", xp: 9800, level: 21, streak: 21 }, - { rank: 4, user_id: "4", username: "CreatorPro", xp: 8500, level: 19, streak: 5 }, - { rank: 5, user_id: "5", username: "ForgeHero", xp: 7200, level: 17, streak: 3 }, - ]; + useEffect(() => { + const fetchLeaderboard = async () => { + try { + const response = await fetch("/api/leaderboard?limit=10"); + if (response.ok) { + const data = await response.json(); + setLeaderboard(data.data || []); + if (currentUserId) { + const rank = data.data?.findIndex((e: LeaderboardEntry) => e.user_id === currentUserId); + if (rank !== -1) setUserRank(rank + 1); + } + } + } catch { + // Use fallback data + setLeaderboard([]); + } finally { + setLoading(false); + } + }; + fetchLeaderboard(); + }, [currentUserId]); const medals = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"]; - return ( -
- {leaderboard.map((entry) => ( -
- {medals[entry.rank - 1] || `#${entry.rank}`} -
-

{entry.username}

-

Lvl {entry.level} Β· {entry.xp.toLocaleString()} XP

-
- {entry.streak && entry.streak > 0 && ( - {entry.streak}d - )} -
- ))} - + if (loading) return ( +
+
); + + return ( + + {userRank && ( + +
+ Your Rank + #{userRank} +
+
+ )} + + {leaderboard.length === 0 ? ( +
+ +

Leaderboard loading...

+
+ ) : ( + leaderboard.map((entry, index) => ( + + {medals[index] || `#${index + 1}`} + {entry.avatar_url ? ( + + ) : ( +
+ {entry.username?.[0]?.toUpperCase() || "?"} +
+ )} +
+

{entry.username}

+

Lvl {entry.level} Β· {entry.total_xp.toLocaleString()} XP

+
+ {entry.current_streak && entry.current_streak > 0 && ( + + {entry.current_streak} + + )} +
+ )) + )} + +
+ ); +} + +function QuestsTab({ userId, onXPGain }: { userId?: string; onXPGain: (amount: number) => void }) { + const [dailyClaimed, setDailyClaimed] = useState(false); + const [claiming, setClaiming] = useState(false); + + const claimDailyXP = async () => { + if (!userId || claiming || dailyClaimed) return; + setClaiming(true); + try { + const response = await fetch("/api/xp/daily-claim", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (response.ok) { + const data = await response.json(); + setDailyClaimed(true); + onXPGain(data.xp_awarded || 25); + } + } catch { + // Silent fail + } finally { + setClaiming(false); + } + }; + + const quests = [ + { id: "daily", title: "Daily Login", description: "Log in today", xp: 25, icon: Calendar, canClaim: !dailyClaimed, onClaim: claimDailyXP, claiming }, + { id: "post", title: "Share Your Work", description: "Create a post", xp: 20, icon: Star, progress: 0, total: 1 }, + { id: "like", title: "Show Support", description: "Like 5 posts", xp: 15, icon: Heart, progress: 3, total: 5 }, + { id: "explore", title: "Realm Explorer", description: "Visit 3 realms", xp: 30, icon: Sparkles, progress: 2, total: 3 }, + ]; + + return ( + +
+ Daily Quests + Resets at midnight UTC +
+ + {quests.map((quest, index) => { + const Icon = quest.icon; + const isCompleted = quest.progress !== undefined && quest.progress >= (quest.total || 1); + + return ( + +
+
+ +
+
+
+ + {quest.title} + + +{quest.xp} XP +
+

{quest.description}

+ + {quest.progress !== undefined && quest.total && ( +
+
+ +
+ {quest.progress}/{quest.total} +
+ )} +
+ + {quest.canClaim !== undefined && ( + + {quest.claiming ? ( + + ) : quest.canClaim ? ( + "Claim" + ) : ( + + )} + + )} + + {isCompleted && !quest.canClaim && ( + + )} +
+
+ ); + })} +
+ ); } function JobsTab({ openExternalLink }: { openExternalLink: (url: string) => Promise }) { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchJobs = async () => { + try { + const response = await fetch("/api/opportunities?limit=5&status=active"); + if (response.ok) { + const data = await response.json(); + setJobs(data.data || []); + } + } catch { + setJobs([]); + } finally { + setLoading(false); + } + }; + fetchJobs(); + }, []); + const categories = [ - { label: "Full-Time", icon: Briefcase }, - { label: "Contract", icon: Target }, - { label: "Freelance", icon: Star }, + { label: "Full-Time", icon: Briefcase, color: "#60a5fa" }, + { label: "Contract", icon: Target, color: "#4ade80" }, + { label: "Freelance", icon: Star, color: "#facc15" }, ]; + if (loading) return ( +
+ +
+ ); + return ( -
-

Browse opportunities from the AeThex community.

+
- {categories.map(({ label, icon: Icon }) => ( - + ))}
- -
+ + {jobs.length > 0 && ( +
+

Latest Opportunities

+ {jobs.slice(0, 3).map((job, index) => ( + openExternalLink(`${APP_URL}/opportunities/${job.id}`)} + initial={{ opacity: 0, x: -10 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: 0.15 + index * 0.05 }} + className="w-full p-3 rounded-xl bg-[#232428] hover:bg-[#2b2d31] transition-all text-left border border-[#3f4147] group" + > +

{job.title}

+

{job.company_name || "Remote"}

+
+ ))} +
+ )} + + openExternalLink(`${APP_URL}/opportunities`)} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + className="w-full py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 transition-all text-white text-sm font-medium shadow-lg shadow-purple-500/20" + > + Browse All Opportunities + + ); } -function QuestsTab({ openExternalLink }: { openExternalLink: (url: string) => Promise }) { - const quests: Quest[] = [ - { id: "1", title: "Share Your Work", description: "Post an update", xp_reward: 25, completed: false, progress: 0, total: 1, type: "daily" }, - { id: "2", title: "Engage & Support", description: "Like 5 posts", xp_reward: 15, completed: false, progress: 3, total: 5, type: "daily" }, - { id: "3", title: "Realm Hopper", description: "Visit 3 realms", xp_reward: 20, completed: true, progress: 3, total: 3, type: "daily" }, - { id: "4", title: "Weekly Contributor", description: "Make 7 posts", xp_reward: 150, completed: false, progress: 4, total: 7, type: "weekly" }, +function BadgesTab({ userId, openExternalLink }: { userId?: string; openExternalLink: (url: string) => Promise }) { + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchBadges = async () => { + if (!userId) { + setLoading(false); + return; + } + try { + const response = await fetch(`/api/user/${userId}/badges`); + if (response.ok) { + const data = await response.json(); + setBadges(data.badges || []); + } + } catch { + setBadges([]); + } finally { + setLoading(false); + } + }; + fetchBadges(); + }, [userId]); + + const exampleBadges = [ + { id: "1", name: "Early Adopter", icon: "πŸš€", description: "Joined during beta", unlocked: true }, + { id: "2", name: "First Post", icon: "✨", description: "Created your first post", unlocked: true }, + { id: "3", name: "Realm Explorer", icon: "πŸ—ΊοΈ", description: "Visited all 6 realms", unlocked: false, progress: 4, total: 6 }, + { id: "4", name: "Social Butterfly", icon: "πŸ¦‹", description: "Made 10 connections", unlocked: false, progress: 6, total: 10 }, + { id: "5", name: "Top Contributor", icon: "πŸ‘‘", description: "Reach top 10 leaderboard", unlocked: false }, ]; - return ( -
- {quests.map((q) => ( -
-
-
- {q.completed ? : } - {q.title} -
- +{q.xp_reward} XP -
- {!q.completed && ( -
-
-
-
- {q.progress}/{q.total} -
- )} -
- ))} - + const displayBadges = badges.length > 0 ? badges : exampleBadges; + + if (loading) return ( +
+
); + + return ( + +
+ Your Badges + {displayBadges.filter(b => b.unlocked).length}/{displayBadges.length} unlocked +
+ + {displayBadges.map((badge, index) => ( + + {badge.icon} +
+
+ + {badge.name} + + {badge.unlocked && } +
+

{badge.description}

+ {!badge.unlocked && badge.progress !== undefined && badge.total && ( +
+
+ +
+ {badge.progress}/{badge.total} +
+ )} +
+ {badge.unlocked && } +
+ ))} + + +
+ ); } export default function Activity() { const { isActivity, isLoading, user, error, openExternalLink } = useDiscordActivity(); const [activeTab, setActiveTab] = useState("feed"); - const currentRealm: ArmType = (user?.primary_arm as ArmType) || "labs"; + const [xpGain, setXpGain] = useState(null); + const [showConfetti, setShowConfetti] = useState(false); + const [userStats, setUserStats] = useState({ total_xp: 0, level: 1, current_streak: 0, longest_streak: 0 }); + const currentRealm: ArmType = (user?.primary_arm as ArmType) || "nexus"; - if (isLoading) return ; + useEffect(() => { + const fetchUserStats = async () => { + if (!user?.id) return; + try { + const response = await fetch(`/api/user/${user.id}/stats`); + if (response.ok) { + const data = await response.json(); + setUserStats(data); + } + } catch { + // Use defaults + } + }; + fetchUserStats(); + }, [user?.id]); + + const handleXPGain = useCallback((amount: number) => { + setXpGain(amount); + setUserStats(prev => ({ + ...prev, + total_xp: prev.total_xp + amount, + level: Math.max(1, Math.floor((prev.total_xp + amount) / 1000) + 1) + })); + + const newLevel = Math.max(1, Math.floor((userStats.total_xp + amount) / 1000) + 1); + if (newLevel > userStats.level) { + setShowConfetti(true); + setTimeout(() => setShowConfetti(false), 3000); + } + }, [userStats]); + + if (isLoading) return ; if (error) return (
-
- -

Something went wrong

+ + +

Connection Error

{error}

- -
+
); if (!isActivity) return ( ); const tabs = [ - { id: "feed", label: "Feed" }, - { id: "realms", label: "Realms" }, - { id: "badges", label: "Badges" }, - { id: "top", label: "Top" }, - { id: "jobs", label: "Jobs" }, - { id: "quests", label: "Quests" }, + { id: "feed", label: "Feed", icon: MessageCircle }, + { id: "realms", label: "Realms", icon: Sparkles }, + { id: "quests", label: "Quests", icon: Target }, + { id: "top", label: "Top", icon: TrendingUp }, + { id: "jobs", label: "Jobs", icon: Briefcase }, + { id: "badges", label: "Badges", icon: Award }, ]; const realmConfig = ARM_CONFIG[currentRealm]; @@ -346,37 +825,96 @@ export default function Activity() { return (
+ + {showConfetti && } + {xpGain && setXpGain(null)} />} + +
-
- {user?.avatar_url && } -
-

{user?.full_name || user?.username}

-
- - {realmConfig.label} + {/* Dynamic Gradient Header */} + +
+
+
+ {user?.avatar_url ? ( + + ) : ( +
+ {user?.username?.[0]?.toUpperCase() || "?"} +
+ )} + +
+

{user?.full_name || user?.username || "Builder"}

+
+
+ + {realmConfig.label} +
+ {userStats.current_streak > 0 && ( +
+ + {userStats.current_streak}d +
+ )} +
+
+ +
+ + +
+
+ +
+ {userStats.total_xp.toLocaleString()} XP + {1000 - (userStats.total_xp % 1000)} XP to Level {userStats.level + 1}
- + + + {/* Tab Navigation */} +
+ {tabs.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })}
-
- {tabs.map((tab) => ( - - ))} -
- -
- {activeTab === "feed" && } - {activeTab === "realms" && } - {activeTab === "badges" && } - {activeTab === "top" && } - {activeTab === "jobs" && } - {activeTab === "quests" && } + {/* Tab Content */} +
+ + {activeTab === "feed" && } + {activeTab === "realms" && } + {activeTab === "quests" && } + {activeTab === "top" && } + {activeTab === "jobs" && } + {activeTab === "badges" && } +