aethex-forge/client/pages/Activity.tsx
sirpiglr d1dba6e9a4 Add a poll feature allowing users to create and vote on questions
Introduces the PollsTab component, enabling users to create, vote on, and view polls with expiration times, storing poll data in localStorage.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 06b5dc97-7dc5-41ad-a71c-d3e205c9be9c
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/139vJay
Replit-Helium-Checkpoint-Created: true
2025-12-13 05:06:27 +00:00

1929 lines
77 KiB
TypeScript

import { useEffect, useState, useCallback, useRef, useMemo, type MouseEvent } from "react";
import { useDiscordActivity } from "@/contexts/DiscordActivityContext";
import LoadingScreen from "@/components/LoadingScreen";
import {
Heart,
MessageCircle,
Zap,
Gamepad2,
Briefcase,
BookOpen,
Sparkles,
Shield,
ExternalLink,
Flame,
Star,
Target,
Gift,
CheckCircle,
AlertCircle,
Loader2,
ChevronRight,
Users,
TrendingUp,
Calendar,
Award,
X,
Send,
MessagesSquare,
BarChart3,
Plus,
Vote,
Trophy,
Clock,
ThumbsUp,
Layers,
Eye,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
const APP_URL = "https://aethex.dev";
type ArmType = "labs" | "gameforge" | "corp" | "foundation" | "nexus" | "staff";
const ARM_CONFIG: Record<ArmType, { label: string; icon: any; color: string; gradient: string; glow: string }> = {
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 {
id: string;
title: string;
content: string;
arm_affiliation: ArmType;
author_id: string;
created_at: string;
likes_count: number;
comments_count: number;
tags?: string[];
user_profiles?: { id: string; username?: string; full_name?: string; avatar_url?: string };
}
interface LeaderboardEntry {
rank: number;
user_id: string;
username: string;
avatar_url?: string;
total_xp: number;
level: number;
current_streak?: number;
}
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 (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#2b2d31"
strokeWidth={strokeWidth}
fill="none"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset }}
transition={{ duration: 1, ease: "easeOut" }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-white font-bold text-sm">{level}</span>
</div>
</div>
);
}
function XPGainAnimation({ amount, onComplete }: { amount: number; onComplete: () => void }) {
useEffect(() => {
const timer = setTimeout(onComplete, 2000);
return () => clearTimeout(timer);
}, [onComplete]);
return (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20 }}
className="fixed bottom-20 right-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white px-4 py-2 rounded-full shadow-lg z-50"
>
<span className="font-bold">+{amount} XP</span>
</motion.div>
);
}
function ConfettiEffect() {
const colors = ["#facc15", "#4ade80", "#60a5fa", "#c084fc", "#f87171"];
return (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
{Array.from({ length: 50 }).map((_, i) => (
<motion.div
key={i}
className="absolute w-2 h-2 rounded-full"
style={{
backgroundColor: colors[i % colors.length],
left: `${Math.random() * 100}%`,
}}
initial={{ y: -20, opacity: 1, rotate: 0 }}
animate={{
y: "100vh",
opacity: 0,
rotate: 360 * (Math.random() > 0.5 ? 1 : -1),
x: (Math.random() - 0.5) * 200
}}
transition={{
duration: 2 + Math.random() * 2,
delay: Math.random() * 0.5,
ease: "easeOut"
}}
/>
))}
</div>
);
}
function FeedTab({ openExternalLink, userId }: { openExternalLink: (url: string) => Promise<void>; userId?: string }) {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likingPost, setLikingPost] = useState<string | null>(null);
const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set());
const fetchPosts = useCallback(async () => {
setLoading(true);
setError(null);
try {
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 || []);
} catch {
setError("Couldn't load feed");
} finally {
setLoading(false);
}
}, []);
const handleQuickLike = async (postId: string, e: MouseEvent<HTMLButtonElement>) => {
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 (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-purple-400" />
</div>
);
if (error) return (
<div className="text-center py-6">
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-2" />
<p className="text-[#b5bac1] text-sm mb-3">{error}</p>
<button onClick={fetchPosts} className="text-purple-400 text-sm hover:underline">Try again</button>
</div>
);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
{posts.length === 0 ? (
<button onClick={() => openExternalLink(`${APP_URL}/community/feed`)} className="w-full p-4 rounded-xl bg-[#232428] hover:bg-[#2b2d31] transition-all text-left border border-[#3f4147]">
<p className="text-[#b5bac1] text-sm">No posts yet</p>
<p className="text-purple-400 text-xs mt-1 flex items-center gap-1">Open Feed <ChevronRight className="w-3 h-3" /></p>
</button>
) : (
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 (
<motion.div
key={post.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<button
onClick={() => openExternalLink(`${APP_URL}/community/feed/${post.id}`)}
className="w-full p-3 rounded-xl bg-[#232428] hover:bg-[#2b2d31] transition-all text-left border border-[#3f4147] group"
>
<div className="flex items-start gap-3">
<div className="w-1 h-full rounded-full self-stretch" style={{ backgroundColor: config.color }} />
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate group-hover:text-purple-300 transition-colors">{post.title}</p>
<p className="text-[#949ba4] text-xs line-clamp-2 mt-0.5">{post.content}</p>
<div className="flex items-center gap-4 mt-2">
<button
onClick={(e) => handleQuickLike(post.id, e)}
disabled={isLiked || isLiking || !userId}
className={`flex items-center gap-1 text-xs transition-all ${isLiked ? 'text-pink-400' : 'text-[#949ba4] hover:text-pink-400'}`}
>
{isLiking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Heart className={`w-3.5 h-3.5 ${isLiked ? 'fill-current' : ''}`} />
)}
{post.likes_count}
</button>
<span className="flex items-center gap-1 text-[#949ba4] text-xs">
<MessageCircle className="w-3.5 h-3.5" />{post.comments_count}
</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-[#4e5058] group-hover:text-[#b5bac1] transition-colors shrink-0 mt-1" />
</div>
</button>
</motion.div>
);
})
)}
<button onClick={() => openExternalLink(`${APP_URL}/community/feed`)} className="w-full py-2 text-purple-400 text-sm hover:underline flex items-center justify-center gap-1">
View all posts <ExternalLink className="w-3 h-3" />
</button>
</motion.div>
);
}
function RealmsTab({ currentRealm, openExternalLink }: { currentRealm: ArmType; openExternalLink: (url: string) => Promise<void> }) {
const realms = Object.entries(ARM_CONFIG) as [ArmType, typeof ARM_CONFIG[ArmType]][];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
<div className="grid grid-cols-2 gap-2">
{realms.map(([key, config], index) => {
const Icon = config.icon;
const isActive = currentRealm === key;
return (
<motion.button
key={key}
onClick={() => 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]"
}`}
>
<div className={`p-2 rounded-lg ${isActive ? 'bg-black/20' : 'bg-[#1e1f22]'}`}>
<Icon className="w-5 h-5" style={{ color: config.color }} />
</div>
<span className={`text-sm ${isActive ? "text-white font-semibold" : "text-[#b5bac1]"}`}>{config.label}</span>
{isActive && <CheckCircle className="w-4 h-4 ml-auto" style={{ color: config.color }} />}
</motion.button>
);
})}
</div>
<p className="text-center text-[#949ba4] text-xs mt-4">Tap to explore each realm</p>
</motion.div>
);
}
function LeaderboardTab({ openExternalLink, currentUserId }: { openExternalLink: (url: string) => Promise<void>; currentUserId?: string }) {
const leaderboard: LeaderboardEntry[] = [
{ rank: 1, user_id: "1", username: "PixelMaster", total_xp: 15420, level: 16, current_streak: 21 },
{ rank: 2, user_id: "2", username: "CodeNinja", total_xp: 12800, level: 13, current_streak: 14 },
{ rank: 3, user_id: "3", username: "BuilderX", total_xp: 11250, level: 12, current_streak: 7 },
{ rank: 4, user_id: "4", username: "GameDev_Pro", total_xp: 9800, level: 10, current_streak: 5 },
{ rank: 5, user_id: "5", username: "ForgeHero", total_xp: 8500, level: 9, current_streak: 12 },
{ rank: 6, user_id: "6", username: "NexusCreator", total_xp: 7200, level: 8, current_streak: 3 },
{ rank: 7, user_id: "7", username: "LabsExplorer", total_xp: 6100, level: 7 },
];
const medals = ["🥇", "🥈", "🥉"];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl bg-gradient-to-r from-purple-500/20 to-pink-500/20 border border-purple-500/30"
>
<div className="flex items-center justify-between">
<span className="text-white text-sm font-medium">Your Rank</span>
<span className="text-purple-300 font-bold">#12</span>
</div>
<p className="text-[#949ba4] text-xs mt-1">Keep earning XP to climb!</p>
</motion.div>
{(
leaderboard.map((entry, index) => (
<motion.div
key={entry.user_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`p-3 rounded-xl flex items-center gap-3 border ${
index < 3
? "bg-gradient-to-r from-[#232428] to-[#2b2d31] border-[#4e5058]"
: "bg-[#1e1f22] border-[#3f4147]"
}`}
>
<span className="w-8 text-center text-lg">{medals[index] || `#${index + 1}`}</span>
{entry.avatar_url ? (
<img src={entry.avatar_url} alt="" className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-[#5865f2] flex items-center justify-center text-white text-xs font-bold">
{entry.username?.[0]?.toUpperCase() || "?"}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{entry.username}</p>
<p className="text-[#949ba4] text-xs">Lvl {entry.level} · {entry.total_xp.toLocaleString()} XP</p>
</div>
{entry.current_streak && entry.current_streak > 0 && (
<motion.span
animate={{ scale: [1, 1.1, 1] }}
transition={{ repeat: Infinity, duration: 2 }}
className="flex items-center gap-1 text-orange-400 text-xs font-medium"
>
<Flame className="w-3.5 h-3.5" />{entry.current_streak}
</motion.span>
)}
</motion.div>
))
)}
<button onClick={() => openExternalLink(`${APP_URL}/leaderboard`)} className="w-full py-2 text-purple-400 text-sm hover:underline flex items-center justify-center gap-1">
Full leaderboard <ExternalLink className="w-3 h-3" />
</button>
</motion.div>
);
}
function QuestsTab({ userId, onXPGain }: { userId?: string; onXPGain: (amount: number) => void }) {
const [dailyClaimed, setDailyClaimed] = useState(() => {
try {
const today = new Date().toISOString().slice(0, 10);
const lastClaim = localStorage.getItem('aethex_daily_claim');
return lastClaim === today;
} catch {
return false;
}
});
const [claiming, setClaiming] = useState(false);
const claimDailyXP = () => {
if (claiming || dailyClaimed) return;
setClaiming(true);
setTimeout(() => {
try {
const today = new Date().toISOString().slice(0, 10);
localStorage.setItem('aethex_daily_claim', today);
} catch {}
setDailyClaimed(true);
onXPGain(25);
setClaiming(false);
}, 500);
};
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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-white text-sm font-medium">Daily Quests</span>
<span className="text-[#949ba4] text-xs">Resets at midnight UTC</span>
</div>
{quests.map((quest, index) => {
const Icon = quest.icon;
const isCompleted = quest.progress !== undefined && quest.progress >= (quest.total || 1);
return (
<motion.div
key={quest.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className={`p-3 rounded-xl border transition-all ${
isCompleted || (quest.canClaim === false)
? "bg-green-500/10 border-green-500/30"
: "bg-[#232428] border-[#3f4147]"
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isCompleted || (quest.canClaim === false) ? 'bg-green-500/20' : 'bg-[#1e1f22]'}`}>
<Icon className={`w-5 h-5 ${isCompleted || (quest.canClaim === false) ? 'text-green-400' : 'text-purple-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className={`text-sm font-medium ${isCompleted || (quest.canClaim === false) ? 'text-green-300' : 'text-white'}`}>
{quest.title}
</span>
<span className="text-amber-400 text-xs font-medium">+{quest.xp} XP</span>
</div>
<p className="text-[#949ba4] text-xs">{quest.description}</p>
{quest.progress !== undefined && quest.total && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-[#1e1f22] rounded-full overflow-hidden">
<motion.div
className="h-full bg-purple-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${(quest.progress / quest.total) * 100}%` }}
transition={{ duration: 0.5, delay: index * 0.1 }}
/>
</div>
<span className="text-[10px] text-[#949ba4]">{quest.progress}/{quest.total}</span>
</div>
)}
</div>
{quest.canClaim !== undefined && (
<motion.button
onClick={quest.onClaim}
disabled={!quest.canClaim || quest.claiming}
whileHover={quest.canClaim ? { scale: 1.05 } : {}}
whileTap={quest.canClaim ? { scale: 0.95 } : {}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
quest.canClaim
? 'bg-purple-500 hover:bg-purple-600 text-white'
: 'bg-green-500/20 text-green-400'
}`}
>
{quest.claiming ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : quest.canClaim ? (
"Claim"
) : (
<CheckCircle className="w-4 h-4" />
)}
</motion.button>
)}
{isCompleted && !quest.canClaim && (
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
)}
</div>
</motion.div>
);
})}
</motion.div>
);
}
function JobsTab({ openExternalLink }: { openExternalLink: (url: string) => Promise<void> }) {
const [jobs, setJobs] = useState<any[]>([]);
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, color: "#60a5fa" },
{ label: "Contract", icon: Target, color: "#4ade80" },
{ label: "Freelance", icon: Star, color: "#facc15" },
];
if (loading) return (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-purple-400" />
</div>
);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-4"
>
<div className="grid grid-cols-3 gap-2">
{categories.map(({ label, icon: Icon, color }, index) => (
<motion.button
key={label}
onClick={() => openExternalLink(`${APP_URL}/opportunities?type=${label.toLowerCase()}`)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
className="p-3 rounded-xl bg-[#232428] hover:bg-[#2b2d31] transition-all flex flex-col items-center gap-2 border border-[#3f4147]"
>
<Icon className="w-5 h-5" style={{ color }} />
<span className="text-[#b5bac1] text-xs">{label}</span>
</motion.button>
))}
</div>
{jobs.length > 0 && (
<div className="space-y-2">
<p className="text-[#949ba4] text-xs font-medium">Latest Opportunities</p>
{jobs.slice(0, 3).map((job, index) => (
<motion.button
key={job.id}
onClick={() => 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"
>
<p className="text-white text-sm font-medium truncate group-hover:text-purple-300">{job.title}</p>
<p className="text-[#949ba4] text-xs truncate">{job.company_name || "Remote"}</p>
</motion.button>
))}
</div>
)}
<motion.button
onClick={() => 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
</motion.button>
</motion.div>
);
}
function BadgesTab({ userId, openExternalLink }: { userId?: string; openExternalLink: (url: string) => Promise<void> }) {
const displayBadges = [
{ 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 },
{ id: "6", name: "Week Warrior", icon: "⚔️", description: "7-day login streak", unlocked: false, progress: 3, total: 7 },
];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-white text-sm font-medium">Your Badges</span>
<span className="text-[#949ba4] text-xs">{displayBadges.filter(b => b.unlocked).length}/{displayBadges.length} unlocked</span>
</div>
{displayBadges.map((badge, index) => (
<motion.div
key={badge.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className={`p-3 rounded-xl flex items-center gap-3 border transition-all ${
badge.unlocked
? "bg-gradient-to-r from-green-500/10 to-emerald-500/5 border-green-500/30"
: "bg-[#232428] border-[#3f4147]"
}`}
>
<span className={`text-2xl ${badge.unlocked ? "" : "grayscale opacity-50"}`}>{badge.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${badge.unlocked ? "text-green-300" : "text-[#b5bac1]"}`}>
{badge.name}
</span>
{badge.unlocked && <Award className="w-4 h-4 text-green-400" />}
</div>
<p className="text-[#949ba4] text-xs">{badge.description}</p>
{!badge.unlocked && badge.progress !== undefined && badge.total && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-[#1e1f22] rounded-full overflow-hidden">
<motion.div
className="h-full bg-purple-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${(badge.progress / badge.total) * 100}%` }}
transition={{ duration: 0.5, delay: index * 0.1 }}
/>
</div>
<span className="text-[10px] text-[#949ba4]">{badge.progress}/{badge.total}</span>
</div>
)}
</div>
{badge.unlocked && <CheckCircle className="w-5 h-5 text-green-400 shrink-0" />}
</motion.div>
))}
<button onClick={() => openExternalLink(`${APP_URL}/profile`)} className="w-full py-2 text-purple-400 text-sm hover:underline flex items-center justify-center gap-1">
View all badges <ExternalLink className="w-3 h-3" />
</button>
</motion.div>
);
}
interface Poll {
id: string;
question: string;
options: { id: string; text: string; votes: number }[];
createdBy: string;
createdByName: string;
createdAt: number;
expiresAt: number;
votedUsers: string[];
}
function PollsTab({ userId, username }: { userId?: string; username?: string }) {
const [polls, setPolls] = useState<Poll[]>(() => {
try {
const saved = localStorage.getItem('aethex_activity_polls');
const parsed = saved ? JSON.parse(saved) : [];
return parsed.filter((p: Poll) => p.expiresAt > Date.now());
} catch {
return [];
}
});
const [showCreate, setShowCreate] = useState(false);
const [newQuestion, setNewQuestion] = useState('');
const [newOptions, setNewOptions] = useState(['', '']);
const [creating, setCreating] = useState(false);
useEffect(() => {
try {
localStorage.setItem('aethex_activity_polls', JSON.stringify(polls));
} catch {}
}, [polls]);
const createPoll = () => {
if (!userId || !newQuestion.trim() || newOptions.filter(o => o.trim()).length < 2) return;
setCreating(true);
const poll: Poll = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
question: newQuestion.trim(),
options: newOptions.filter(o => o.trim()).map((text, i) => ({
id: `opt-${i}`,
text: text.trim(),
votes: 0
})),
createdBy: userId,
createdByName: username || 'Anonymous',
createdAt: Date.now(),
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
votedUsers: []
};
setPolls(prev => [poll, ...prev]);
setNewQuestion('');
setNewOptions(['', '']);
setShowCreate(false);
setCreating(false);
};
const vote = (pollId: string, optionId: string) => {
if (!userId) return;
setPolls(prev => prev.map(poll => {
if (poll.id !== pollId || poll.votedUsers.includes(userId)) return poll;
return {
...poll,
options: poll.options.map(opt =>
opt.id === optionId ? { ...opt, votes: opt.votes + 1 } : opt
),
votedUsers: [...poll.votedUsers, userId]
};
}));
};
const deletePoll = (pollId: string) => {
setPolls(prev => prev.filter(p => p.id !== pollId));
};
const formatTimeRemaining = (expiresAt: number) => {
const remaining = expiresAt - Date.now();
if (remaining <= 0) return 'Expired';
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m left`;
return `${minutes}m left`;
};
const samplePolls: Poll[] = [
{
id: 'sample1',
question: 'What realm are you most excited about?',
options: [
{ id: 'opt-1', text: 'GameForge', votes: 12 },
{ id: 'opt-2', text: 'Labs', votes: 8 },
{ id: 'opt-3', text: 'Nexus', votes: 15 },
{ id: 'opt-4', text: 'Foundation', votes: 5 },
],
createdBy: 'system',
createdByName: 'AeThex',
createdAt: Date.now() - 3600000,
expiresAt: Date.now() + 20 * 60 * 60 * 1000,
votedUsers: []
}
];
const displayPolls = polls.length > 0 ? polls : samplePolls;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-4"
>
<AnimatePresence>
{showCreate && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="p-4 rounded-xl bg-[#232428] border border-[#3f4147] space-y-3">
<div className="flex items-center justify-between">
<span className="text-white text-sm font-medium">Create Poll</span>
<button onClick={() => setShowCreate(false)} className="p-1 hover:bg-[#3f4147] rounded-lg transition-colors">
<X className="w-4 h-4 text-[#949ba4]" />
</button>
</div>
<input
type="text"
value={newQuestion}
onChange={(e) => setNewQuestion(e.target.value)}
placeholder="Ask a question..."
className="w-full bg-[#1e1f22] text-white placeholder-[#949ba4] px-3 py-2 rounded-lg border border-[#3f4147] focus:border-purple-500 focus:outline-none text-sm"
/>
<div className="space-y-2">
{newOptions.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={opt}
onChange={(e) => {
const updated = [...newOptions];
updated[i] = e.target.value;
setNewOptions(updated);
}}
placeholder={`Option ${i + 1}`}
className="flex-1 bg-[#1e1f22] text-white placeholder-[#949ba4] px-3 py-2 rounded-lg border border-[#3f4147] focus:border-purple-500 focus:outline-none text-sm"
/>
{newOptions.length > 2 && (
<button
onClick={() => setNewOptions(prev => prev.filter((_, idx) => idx !== i))}
className="p-2 hover:bg-[#3f4147] rounded-lg transition-colors"
>
<X className="w-4 h-4 text-[#949ba4]" />
</button>
)}
</div>
))}
{newOptions.length < 5 && (
<button
onClick={() => setNewOptions(prev => [...prev, ''])}
className="w-full py-2 text-purple-400 text-sm hover:underline flex items-center justify-center gap-1"
>
<Plus className="w-4 h-4" /> Add option
</button>
)}
</div>
<motion.button
onClick={createPoll}
disabled={!newQuestion.trim() || newOptions.filter(o => o.trim()).length < 2 || creating}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="w-full py-2.5 rounded-xl bg-purple-500 hover:bg-purple-600 disabled:bg-[#3f4147] disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : 'Create Poll'}
</motion.button>
</div>
</motion.div>
)}
</AnimatePresence>
{!showCreate && userId && (
<motion.button
onClick={() => setShowCreate(true)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="w-full py-3 rounded-xl bg-[#232428] hover:bg-[#2b2d31] border border-[#3f4147] text-[#b5bac1] text-sm font-medium flex items-center justify-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" /> Create a Poll
</motion.button>
)}
{displayPolls.map((poll, pollIndex) => {
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
const hasVoted = userId && poll.votedUsers.includes(userId);
const isOwner = userId === poll.createdBy;
return (
<motion.div
key={poll.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: pollIndex * 0.05 }}
className="p-4 rounded-xl bg-[#232428] border border-[#3f4147]"
>
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-white text-sm font-medium">{poll.question}</p>
<p className="text-[#949ba4] text-xs mt-0.5">
by {poll.createdByName} · {totalVotes} vote{totalVotes !== 1 ? 's' : ''} · {formatTimeRemaining(poll.expiresAt)}
</p>
</div>
{isOwner && (
<button
onClick={() => deletePoll(poll.id)}
className="p-1.5 hover:bg-[#3f4147] rounded-lg transition-colors"
>
<X className="w-4 h-4 text-[#949ba4]" />
</button>
)}
</div>
<div className="space-y-2">
{poll.options.map((option) => {
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
return (
<motion.button
key={option.id}
onClick={() => vote(poll.id, option.id)}
disabled={!userId || hasVoted}
whileHover={!hasVoted && userId ? { scale: 1.01 } : {}}
whileTap={!hasVoted && userId ? { scale: 0.99 } : {}}
className={`w-full relative overflow-hidden rounded-lg text-left transition-all ${
hasVoted
? 'bg-[#1e1f22] cursor-default'
: 'bg-[#1e1f22] hover:bg-[#2b2d31] cursor-pointer'
}`}
>
{hasVoted && (
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percentage}%` }}
transition={{ duration: 0.5 }}
className="absolute inset-y-0 left-0 bg-purple-500/20"
/>
)}
<div className="relative px-3 py-2.5 flex items-center justify-between">
<span className={`text-sm ${hasVoted ? 'text-white' : 'text-[#b5bac1]'}`}>{option.text}</span>
{hasVoted && (
<span className="text-sm text-purple-400 font-medium">{percentage}%</span>
)}
</div>
</motion.button>
);
})}
</div>
{!userId && (
<p className="text-center text-[#4e5058] text-xs mt-3">Sign in to vote</p>
)}
</motion.div>
);
})}
{polls.length === 0 && (
<p className="text-center text-[#949ba4] text-xs mt-2">
Polls expire after 24 hours. Create one to get opinions!
</p>
)}
</motion.div>
);
}
interface Challenge {
id: string;
title: string;
description: string;
xpReward: number;
type: 'daily' | 'weekly';
requirement: number;
icon: string;
endsAt: number;
}
function ChallengesTab({ userId, onXPGain }: { userId?: string; onXPGain: (amount: number) => void }) {
const [claimedChallenges, setClaimedChallenges] = useState<Set<string>>(() => {
try {
const saved = localStorage.getItem('aethex_claimed_challenges');
const parsed = saved ? JSON.parse(saved) : { claimed: [], lastReset: 0 };
const now = Date.now();
const weekStart = now - (now % (7 * 24 * 60 * 60 * 1000));
if (parsed.lastReset < weekStart) {
return new Set();
}
return new Set(parsed.claimed);
} catch {
return new Set();
}
});
const [progress, setProgress] = useState<Record<string, number>>(() => {
try {
const saved = localStorage.getItem('aethex_challenge_progress');
const parsed = saved ? JSON.parse(saved) : { data: {}, lastReset: 0 };
const now = Date.now();
const weekStart = now - (now % (7 * 24 * 60 * 60 * 1000));
if (parsed.lastReset < weekStart) {
return {};
}
return parsed.data || parsed;
} catch {
return {};
}
});
const [claiming, setClaiming] = useState<string | null>(null);
useEffect(() => {
const now = Date.now();
const weekStart = now - (now % (7 * 24 * 60 * 60 * 1000));
localStorage.setItem('aethex_claimed_challenges', JSON.stringify({
claimed: Array.from(claimedChallenges),
lastReset: weekStart
}));
}, [claimedChallenges]);
useEffect(() => {
const now = Date.now();
const weekStart = now - (now % (7 * 24 * 60 * 60 * 1000));
localStorage.setItem('aethex_challenge_progress', JSON.stringify({
data: progress,
lastReset: weekStart
}));
}, [progress]);
const getWeekEnd = () => {
const now = new Date();
const dayOfWeek = now.getDay();
const daysUntilSunday = 7 - dayOfWeek;
const endOfWeek = new Date(now);
endOfWeek.setDate(now.getDate() + daysUntilSunday);
endOfWeek.setHours(23, 59, 59, 999);
return endOfWeek.getTime();
};
const formatTimeRemaining = (endsAt: number) => {
const remaining = endsAt - Date.now();
if (remaining <= 0) return 'Ended';
const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
const hours = Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h left`;
return `${hours}h left`;
};
const challenges: Challenge[] = [
{ id: 'wc1', title: 'Social Butterfly', description: 'Like 20 posts this week', xpReward: 100, type: 'weekly', requirement: 20, icon: '🦋', endsAt: getWeekEnd() },
{ id: 'wc2', title: 'Realm Hopper', description: 'Visit all 6 realms', xpReward: 150, type: 'weekly', requirement: 6, icon: '🌀', endsAt: getWeekEnd() },
{ id: 'wc3', title: 'Pollster', description: 'Create 3 polls', xpReward: 75, type: 'weekly', requirement: 3, icon: '📊', endsAt: getWeekEnd() },
{ id: 'wc4', title: 'Chatterbox', description: 'Send 50 messages in Activity', xpReward: 80, type: 'weekly', requirement: 50, icon: '💬', endsAt: getWeekEnd() },
{ id: 'wc5', title: 'Streak Master', description: 'Maintain a 7-day login streak', xpReward: 200, type: 'weekly', requirement: 7, icon: '🔥', endsAt: getWeekEnd() },
];
const simulateProgress = (challengeId: string) => {
const challenge = challenges.find(c => c.id === challengeId);
if (!challenge) return;
const current = progress[challengeId] || 0;
const mockProgress = Math.min(current + Math.floor(Math.random() * 3) + 1, challenge.requirement);
setProgress(prev => ({ ...prev, [challengeId]: mockProgress }));
};
const claimReward = (challenge: Challenge) => {
if (!userId || claimedChallenges.has(challenge.id) || claiming) return;
const currentProgress = progress[challenge.id] || 0;
if (currentProgress < challenge.requirement) return;
setClaiming(challenge.id);
setTimeout(() => {
setClaimedChallenges(prev => new Set(prev).add(challenge.id));
onXPGain(challenge.xpReward);
setClaiming(null);
}, 500);
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-4"
>
<div className="p-4 rounded-xl bg-gradient-to-r from-amber-500/20 to-orange-500/20 border border-amber-500/30">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/20">
<Trophy className="w-6 h-6 text-amber-400" />
</div>
<div className="flex-1">
<p className="text-white text-sm font-medium">Weekly Challenges</p>
<p className="text-[#949ba4] text-xs flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTimeRemaining(getWeekEnd())}
</p>
</div>
<div className="text-right">
<p className="text-amber-400 font-bold">{challenges.filter(c => claimedChallenges.has(c.id)).length}/{challenges.length}</p>
<p className="text-[#949ba4] text-xs">Completed</p>
</div>
</div>
</div>
{challenges.map((challenge, index) => {
const currentProgress = progress[challenge.id] ?? 0;
const isCompleted = currentProgress >= challenge.requirement;
const isClaimed = claimedChallenges.has(challenge.id);
const isClaiming = claiming === challenge.id;
const progressPercent = Math.min((currentProgress / challenge.requirement) * 100, 100);
return (
<motion.div
key={challenge.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className={`p-4 rounded-xl border transition-all ${
isClaimed
? 'bg-green-500/10 border-green-500/30'
: isCompleted
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-[#232428] border-[#3f4147]'
}`}
>
<div className="flex items-start gap-3">
<span className={`text-2xl ${isClaimed ? '' : 'grayscale-0'}`}>{challenge.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className={`text-sm font-medium ${isClaimed ? 'text-green-300' : 'text-white'}`}>
{challenge.title}
</span>
<span className="text-amber-400 text-xs font-medium">+{challenge.xpReward} XP</span>
</div>
<p className="text-[#949ba4] text-xs mt-0.5">{challenge.description}</p>
<div className="mt-3 flex items-center gap-2">
<div className="flex-1 h-2 bg-[#1e1f22] rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full ${isClaimed ? 'bg-green-500' : isCompleted ? 'bg-amber-500' : 'bg-purple-500'}`}
initial={{ width: 0 }}
animate={{ width: `${progressPercent}%` }}
transition={{ duration: 0.5, delay: index * 0.1 }}
/>
</div>
<span className="text-[11px] text-[#949ba4] shrink-0">{currentProgress}/{challenge.requirement}</span>
</div>
</div>
{isClaimed ? (
<CheckCircle className="w-6 h-6 text-green-400 shrink-0" />
) : isCompleted ? (
<motion.button
onClick={() => claimReward(challenge)}
disabled={isClaiming}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white text-xs font-medium transition-colors shrink-0"
>
{isClaiming ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Claim'}
</motion.button>
) : (
<button
onClick={() => simulateProgress(challenge.id)}
className="px-2 py-1 rounded-lg bg-[#3f4147] hover:bg-[#4e5058] text-[#949ba4] text-xs transition-colors shrink-0"
>
+
</button>
)}
</div>
</motion.div>
);
})}
<p className="text-center text-[#949ba4] text-xs">
Complete challenges to earn bonus XP and exclusive badges!
</p>
</motion.div>
);
}
interface Project {
id: string;
title: string;
description: string;
author: string;
authorAvatar?: string;
realm: ArmType;
thumbnail: string;
votes: number;
views: number;
tags: string[];
}
function ProjectsTab({ userId, openExternalLink }: { userId?: string; openExternalLink: (url: string) => Promise<void> }) {
const [votedProjects, setVotedProjects] = useState<Set<string>>(() => {
try {
const saved = localStorage.getItem('aethex_voted_projects');
return new Set(saved ? JSON.parse(saved) : []);
} catch {
return new Set();
}
});
const [projects, setProjects] = useState<Project[]>([
{ id: 'p1', title: 'Neon Racer', description: 'A cyberpunk racing game with stunning visuals', author: 'PixelDev', realm: 'gameforge', thumbnail: '🏎️', votes: 142, views: 890, tags: ['Game', 'Racing'] },
{ id: 'p2', title: 'DevFlow', description: 'AI-powered code review and workflow tool', author: 'CodeMaster', realm: 'labs', thumbnail: '🔮', votes: 98, views: 654, tags: ['Tool', 'AI'] },
{ id: 'p3', title: 'EcoTrack', description: 'Track and reduce your carbon footprint', author: 'GreenCoder', realm: 'foundation', thumbnail: '🌱', votes: 76, views: 432, tags: ['Social', 'Environment'] },
{ id: 'p4', title: 'SoundWave', description: 'Collaborative music production platform', author: 'BeatMaker', realm: 'nexus', thumbnail: '🎵', votes: 112, views: 780, tags: ['Music', 'Collaboration'] },
{ id: 'p5', title: 'PixelForge', description: '2D game asset generator using AI', author: 'ArtistX', realm: 'gameforge', thumbnail: '🎨', votes: 89, views: 567, tags: ['Tool', 'Art'] },
]);
const [filter, setFilter] = useState<ArmType | 'all'>('all');
useEffect(() => {
localStorage.setItem('aethex_voted_projects', JSON.stringify(Array.from(votedProjects)));
}, [votedProjects]);
const voteProject = (projectId: string) => {
if (!userId || votedProjects.has(projectId)) return;
setVotedProjects(prev => new Set(prev).add(projectId));
setProjects(prev => prev.map(p =>
p.id === projectId ? { ...p, votes: p.votes + 1 } : p
));
};
const filteredProjects = filter === 'all'
? projects
: projects.filter(p => p.realm === filter);
const sortedProjects = [...filteredProjects].sort((a, b) => b.votes - a.votes);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-4"
>
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pb-1">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors whitespace-nowrap ${
filter === 'all' ? 'bg-purple-500 text-white' : 'bg-[#232428] text-[#b5bac1] hover:bg-[#2b2d31]'
}`}
>
All
</button>
{(Object.keys(ARM_CONFIG) as ArmType[]).map(realm => {
const config = ARM_CONFIG[realm];
return (
<button
key={realm}
onClick={() => setFilter(realm)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors whitespace-nowrap flex items-center gap-1.5 ${
filter === realm
? 'text-white'
: 'bg-[#232428] text-[#b5bac1] hover:bg-[#2b2d31]'
}`}
style={filter === realm ? { backgroundColor: config.color } : {}}
>
<config.icon className="w-3 h-3" />
{config.label}
</button>
);
})}
</div>
{sortedProjects.map((project, index) => {
const config = ARM_CONFIG[project.realm];
const hasVoted = votedProjects.has(project.id);
return (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="p-4 rounded-xl bg-[#232428] border border-[#3f4147] group"
>
<div className="flex items-start gap-3">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-2xl shrink-0"
style={{ backgroundColor: `${config.color}20` }}
>
{project.thumbnail}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<button
onClick={() => openExternalLink(`${APP_URL}/projects/${project.id}`)}
className="text-white text-sm font-medium truncate hover:text-purple-300 transition-colors"
>
{project.title}
</button>
<div
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${config.color}20`, color: config.color }}
>
{config.label}
</div>
</div>
<p className="text-[#949ba4] text-xs line-clamp-2 mt-0.5">{project.description}</p>
<div className="flex items-center gap-3 mt-2">
<span className="text-[#4e5058] text-xs">by {project.author}</span>
<span className="flex items-center gap-1 text-[#4e5058] text-xs">
<Eye className="w-3 h-3" /> {project.views}
</span>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{project.tags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full bg-[#1e1f22] text-[#949ba4] text-[10px]">
{tag}
</span>
))}
</div>
</div>
<div className="flex flex-col items-center gap-1 shrink-0">
<motion.button
onClick={() => voteProject(project.id)}
disabled={!userId || hasVoted}
whileHover={!hasVoted && userId ? { scale: 1.1 } : {}}
whileTap={!hasVoted && userId ? { scale: 0.9 } : {}}
className={`p-2 rounded-lg transition-colors ${
hasVoted
? 'bg-green-500/20 text-green-400'
: 'bg-[#1e1f22] text-[#949ba4] hover:text-white hover:bg-[#3f4147]'
}`}
>
<ThumbsUp className={`w-5 h-5 ${hasVoted ? 'fill-current' : ''}`} />
</motion.button>
<span className={`text-sm font-medium ${hasVoted ? 'text-green-400' : 'text-white'}`}>
{project.votes}
</span>
</div>
</div>
</motion.div>
);
})}
<button
onClick={() => openExternalLink(`${APP_URL}/projects`)}
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 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
<Layers className="w-4 h-4" /> Browse All Projects
</button>
</motion.div>
);
}
interface ChatMessage {
id: string;
userId: string;
username: string;
avatar: string | null;
content: string;
timestamp: number;
}
function ChatTab({
userId,
username,
avatar,
participants
}: {
userId?: string;
username?: string;
avatar?: string | null;
participants: any[];
}) {
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const saved = localStorage.getItem('aethex_activity_chat');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
try {
localStorage.setItem('aethex_activity_chat', JSON.stringify(messages.slice(-50)));
} catch {}
}, [messages]);
const sendMessage = useCallback(() => {
if (!inputValue.trim() || !userId || sending) return;
setSending(true);
const newMessage: ChatMessage = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
userId,
username: username || 'Anonymous',
avatar: avatar || null,
content: inputValue.trim(),
timestamp: Date.now(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue('');
setSending(false);
inputRef.current?.focus();
}, [inputValue, userId, username, avatar, sending]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const mockMessages: ChatMessage[] = [
{ id: '1', userId: 'bot', username: 'AeThex Bot', avatar: null, content: 'Welcome to Activity Chat! Say hi to your fellow builders.', timestamp: Date.now() - 300000 },
{ id: '2', userId: 'user1', username: 'GameDevPro', avatar: null, content: 'Hey everyone! Working on a new GameForge project 🎮', timestamp: Date.now() - 180000 },
{ id: '3', userId: 'user2', username: 'PixelArtist', avatar: null, content: 'Nice! What genre?', timestamp: Date.now() - 120000 },
];
const displayMessages = messages.length > 0 ? messages : mockMessages;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full -m-4"
>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{participants.length > 0 && (
<div className="text-center py-2">
<span className="text-xs text-[#949ba4] bg-[#232428] px-3 py-1 rounded-full">
{participants.length} {participants.length === 1 ? 'person' : 'people'} in this Activity
</span>
</div>
)}
{displayMessages.map((msg, index) => {
const isOwn = msg.userId === userId;
const showAvatar = index === 0 || displayMessages[index - 1]?.userId !== msg.userId;
return (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex items-start gap-2 ${isOwn ? 'flex-row-reverse' : ''}`}
>
{showAvatar ? (
msg.avatar ? (
<img
src={msg.avatar}
alt={msg.username}
className="w-8 h-8 rounded-full shrink-0"
/>
) : (
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 ${
msg.userId === 'bot' ? 'bg-gradient-to-br from-purple-500 to-pink-500' : 'bg-[#5865f2]'
}`}>
{msg.username?.[0]?.toUpperCase() || '?'}
</div>
)
) : (
<div className="w-8 shrink-0" />
)}
<div className={`max-w-[75%] ${isOwn ? 'items-end' : 'items-start'}`}>
{showAvatar && (
<div className={`flex items-center gap-2 mb-0.5 ${isOwn ? 'flex-row-reverse' : ''}`}>
<span className={`text-xs font-medium ${msg.userId === 'bot' ? 'text-purple-400' : 'text-white'}`}>
{msg.username}
</span>
<span className="text-[10px] text-[#949ba4]">{formatTime(msg.timestamp)}</span>
</div>
)}
<div className={`px-3 py-2 rounded-2xl text-sm ${
isOwn
? 'bg-purple-500 text-white rounded-tr-sm'
: msg.userId === 'bot'
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white border border-purple-500/30 rounded-tl-sm'
: 'bg-[#232428] text-white rounded-tl-sm'
}`}>
{msg.content}
</div>
</div>
</motion.div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="p-4 bg-[#2b2d31] border-t border-[#1e1f22]">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={!userId}
className="flex-1 bg-[#1e1f22] text-white placeholder-[#949ba4] px-4 py-2.5 rounded-xl border border-[#3f4147] focus:border-purple-500 focus:outline-none transition-colors text-sm disabled:opacity-50"
/>
<motion.button
onClick={sendMessage}
disabled={!inputValue.trim() || !userId || sending}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2.5 bg-purple-500 hover:bg-purple-600 disabled:bg-[#3f4147] disabled:opacity-50 rounded-xl transition-colors"
>
<Send className="w-5 h-5 text-white" />
</motion.button>
</div>
<p className="text-center text-[10px] text-[#4e5058] mt-2">
Messages are local to this session
</p>
</div>
</motion.div>
);
}
interface ParticipantProfile {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
speaking?: boolean;
}
function ProfilePreviewModal({
participant,
onClose,
openExternalLink
}: {
participant: ParticipantProfile;
onClose: () => void;
openExternalLink: (url: string) => Promise<void>;
}) {
const { mockBadges, mockLevel, mockXP } = useMemo(() => {
const hash = participant.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const level = (hash % 15) + 1;
return {
mockBadges: [
{ icon: "🚀", name: "Early Adopter", unlocked: hash % 2 === 0 },
{ icon: "⚔️", name: "Realm Explorer", unlocked: hash % 3 === 0 },
{ icon: "🎮", name: "GameForge Member", unlocked: hash % 4 !== 0 },
{ icon: "✨", name: "First Post", unlocked: hash % 5 !== 0 },
],
mockLevel: level,
mockXP: level * 1000 - (hash % 800),
};
}, [participant.id]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="bg-[#2b2d31] rounded-2xl w-full max-w-sm overflow-hidden border border-[#3f4147] shadow-xl"
>
<div className="relative bg-gradient-to-br from-purple-600/30 to-pink-600/30 p-6 pb-12">
<button
onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-black/20 hover:bg-black/40 transition-colors"
>
<X className="w-4 h-4 text-white/70" />
</button>
<div className="absolute -bottom-10 left-1/2 -translate-x-1/2">
{participant.avatar ? (
<img
src={`https://cdn.discordapp.com/avatars/${participant.id}/${participant.avatar}.png?size=128`}
alt={participant.global_name || participant.username}
className={`w-20 h-20 rounded-full border-4 border-[#2b2d31] ${participant.speaking ? 'ring-3 ring-green-400' : ''}`}
/>
) : (
<div className={`w-20 h-20 rounded-full bg-[#5865f2] flex items-center justify-center text-white text-2xl font-bold border-4 border-[#2b2d31] ${participant.speaking ? 'ring-3 ring-green-400' : ''}`}>
{(participant.global_name || participant.username)?.[0]?.toUpperCase() || "?"}
</div>
)}
{participant.speaking && (
<motion.div
className="absolute bottom-0 right-0 w-5 h-5 bg-green-400 rounded-full border-2 border-[#2b2d31] flex items-center justify-center"
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Infinity, duration: 0.5 }}
>
<span className="text-[8px]">🎤</span>
</motion.div>
)}
</div>
</div>
<div className="pt-12 pb-4 px-4 text-center">
<h3 className="text-white font-semibold text-lg">{participant.global_name || participant.username}</h3>
<p className="text-[#949ba4] text-sm">@{participant.username}</p>
<div className="flex items-center justify-center gap-4 mt-4">
<div className="text-center">
<p className="text-white font-bold text-lg">{mockLevel}</p>
<p className="text-[#949ba4] text-xs">Level</p>
</div>
<div className="w-px h-8 bg-[#3f4147]" />
<div className="text-center">
<p className="text-white font-bold text-lg">{mockXP.toLocaleString()}</p>
<p className="text-[#949ba4] text-xs">XP</p>
</div>
<div className="w-px h-8 bg-[#3f4147]" />
<div className="text-center">
<p className="text-white font-bold text-lg">{mockBadges.filter(b => b.unlocked).length}</p>
<p className="text-[#949ba4] text-xs">Badges</p>
</div>
</div>
<div className="mt-4 flex flex-wrap justify-center gap-2">
{mockBadges.map((badge, i) => (
<motion.div
key={i}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: i * 0.05 }}
className={`px-3 py-1.5 rounded-full text-sm flex items-center gap-1.5 ${
badge.unlocked
? 'bg-green-500/20 text-green-300 border border-green-500/30'
: 'bg-[#1e1f22] text-[#4e5058] grayscale'
}`}
>
<span>{badge.icon}</span>
<span className="text-xs">{badge.name}</span>
</motion.div>
))}
</div>
</div>
<div className="p-4 pt-0">
<motion.button
onClick={() => openExternalLink(`${APP_URL}/passport/${participant.id}`)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
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 flex items-center justify-center gap-2 shadow-lg shadow-purple-500/20"
>
View Full Passport <ExternalLink className="w-4 h-4" />
</motion.button>
</div>
</motion.div>
</motion.div>
);
}
function ParticipantsBar({
participants,
currentUserId,
openExternalLink
}: {
participants: any[];
currentUserId?: string;
openExternalLink: (url: string) => Promise<void>;
}) {
const [selectedParticipant, setSelectedParticipant] = useState<ParticipantProfile | null>(null);
const otherParticipants = participants.filter(p => p.id !== currentUserId);
if (otherParticipants.length === 0) return null;
return (
<>
<AnimatePresence>
{selectedParticipant && (
<ProfilePreviewModal
participant={selectedParticipant}
onClose={() => setSelectedParticipant(null)}
openExternalLink={openExternalLink}
/>
)}
</AnimatePresence>
<div className="flex items-center gap-2 px-4 py-2 bg-[#2b2d31] border-b border-[#1e1f22]">
<Users className="w-4 h-4 text-[#949ba4]" />
<span className="text-xs text-[#949ba4]">{otherParticipants.length} here</span>
<div className="flex -space-x-2 ml-2">
{otherParticipants.slice(0, 8).map((p) => (
<motion.button
key={p.id}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.1, zIndex: 10 }}
whileTap={{ scale: 0.95 }}
onClick={() => setSelectedParticipant(p)}
className="relative cursor-pointer"
>
{p.avatar ? (
<img
src={`https://cdn.discordapp.com/avatars/${p.id}/${p.avatar}.png?size=32`}
alt={p.global_name || p.username}
className={`w-7 h-7 rounded-full border-2 border-[#2b2d31] ${p.speaking ? 'ring-2 ring-green-400' : ''}`}
title={p.global_name || p.username}
/>
) : (
<div
className={`w-7 h-7 rounded-full bg-[#5865f2] flex items-center justify-center text-white text-xs font-bold border-2 border-[#2b2d31] ${p.speaking ? 'ring-2 ring-green-400' : ''}`}
title={p.global_name || p.username}
>
{(p.global_name || p.username)?.[0]?.toUpperCase() || "?"}
</div>
)}
{p.speaking && (
<motion.div
className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-400 rounded-full border border-[#2b2d31]"
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Infinity, duration: 0.5 }}
/>
)}
</motion.button>
))}
{otherParticipants.length > 8 && (
<div className="w-7 h-7 rounded-full bg-[#4e5058] flex items-center justify-center text-white text-xs font-bold border-2 border-[#2b2d31]">
+{otherParticipants.length - 8}
</div>
)}
</div>
</div>
</>
);
}
export default function Activity() {
const { isActivity, isLoading, user, error, openExternalLink, participants } = useDiscordActivity();
const [activeTab, setActiveTab] = useState("feed");
const [xpGain, setXpGain] = useState<number | null>(null);
const [showConfetti, setShowConfetti] = useState(false);
const [userStats, setUserStats] = useState<UserStats>({ total_xp: 0, level: 1, current_streak: 0, longest_streak: 0 });
const currentRealm: ArmType = (user?.primary_arm as ArmType) || "nexus";
useEffect(() => {
// Use mock data for user stats (no API endpoint available)
setUserStats({
total_xp: 2450,
level: 3,
current_streak: 5,
longest_streak: 12,
rank: 12
});
}, [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 <LoadingScreen message="Connecting to AeThex..." showProgress={true} duration={3000} />;
if (error) return (
<div className="min-h-screen bg-[#313338] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center max-w-xs"
>
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-white font-medium mb-2">Connection Error</p>
<p className="text-[#949ba4] text-sm mb-4">{error}</p>
<button onClick={() => window.location.reload()} className="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white text-sm rounded-xl transition-colors">
Retry
</button>
</motion.div>
</div>
);
if (!isActivity) return (
<div className="min-h-screen bg-[#313338] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-xs"
>
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center mx-auto mb-4">
<Sparkles className="w-8 h-8 text-white" />
</div>
<p className="text-white font-semibold text-lg mb-2">AeThex Activity</p>
<p className="text-[#949ba4] text-sm mb-4">Launch this within Discord to access the full experience.</p>
<a href={APP_URL} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-purple-400 text-sm hover:underline">
Visit aethex.dev <ExternalLink className="w-3 h-3" />
</a>
</motion.div>
</div>
);
const tabs = [
{ id: "feed", label: "Feed", icon: MessageCircle },
{ id: "chat", label: "Chat", icon: MessagesSquare },
{ id: "polls", label: "Polls", icon: BarChart3 },
{ id: "challenges", label: "Challenges", icon: Trophy },
{ id: "projects", label: "Projects", icon: Layers },
{ 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];
const RealmIcon = realmConfig.icon;
return (
<div className="h-screen w-screen flex flex-col bg-[#313338] overflow-hidden">
<AnimatePresence>
{showConfetti && <ConfettiEffect />}
{xpGain && <XPGainAnimation amount={xpGain} onComplete={() => setXpGain(null)} />}
</AnimatePresence>
{/* Dynamic Gradient Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`relative overflow-hidden bg-gradient-to-br ${realmConfig.gradient} border-b border-[#1e1f22] flex-shrink-0`}
>
<div className="absolute inset-0 bg-[#2b2d31]/80" />
<div className="relative px-6 py-4">
<div className="flex items-center gap-4">
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-14 h-14 rounded-full ring-2 ring-white/10" />
) : (
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-lg">
{user?.username?.[0]?.toUpperCase() || "?"}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-lg truncate">{user?.full_name || user?.username || "Builder"}</p>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-black/20" style={{ borderColor: realmConfig.color }}>
<RealmIcon className="w-4 h-4" style={{ color: realmConfig.color }} />
<span className="text-sm font-medium" style={{ color: realmConfig.color }}>{realmConfig.label}</span>
</div>
{userStats.current_streak > 0 && (
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-orange-500/20">
<Flame className="w-4 h-4 text-orange-400" />
<span className="text-sm font-medium text-orange-400">{userStats.current_streak}d</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<XPRing xp={userStats.total_xp} level={userStats.level} size={56} strokeWidth={4} color={realmConfig.color} />
<button onClick={() => openExternalLink(`${APP_URL}/profile`)} className="p-2.5 hover:bg-black/20 rounded-xl transition-colors">
<ExternalLink className="w-5 h-5 text-[#b5bac1]" />
</button>
</div>
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-[#949ba4]">{userStats.total_xp.toLocaleString()} XP</span>
<span className="text-[#949ba4]">{1000 - (userStats.total_xp % 1000)} XP to Level {userStats.level + 1}</span>
</div>
</div>
</motion.div>
{/* Participants Bar */}
<ParticipantsBar participants={participants} currentUserId={user?.id} openExternalLink={openExternalLink} />
{/* Tab Navigation */}
<div className="flex bg-[#2b2d31] border-b border-[#1e1f22] px-2 overflow-x-auto scrollbar-hide flex-shrink-0">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all relative whitespace-nowrap ${
isActive ? "text-white" : "text-[#949ba4] hover:text-[#dbdee1]"
}`}
>
<Icon className="w-5 h-5" />
{tab.label}
{isActive && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-2 right-2 h-0.5 bg-purple-500 rounded-full"
/>
)}
</button>
);
})}
</div>
{/* Tab Content - fills remaining space */}
<div className="flex-1 overflow-y-auto p-4">
<AnimatePresence mode="wait">
{activeTab === "feed" && <FeedTab key="feed" openExternalLink={openExternalLink} userId={user?.id} />}
{activeTab === "chat" && <ChatTab key="chat" userId={user?.id} username={user?.username || undefined} avatar={user?.avatar_url} participants={participants} />}
{activeTab === "polls" && <PollsTab key="polls" userId={user?.id} username={user?.username || undefined} />}
{activeTab === "challenges" && <ChallengesTab key="challenges" userId={user?.id} onXPGain={handleXPGain} />}
{activeTab === "projects" && <ProjectsTab key="projects" userId={user?.id} openExternalLink={openExternalLink} />}
{activeTab === "realms" && <RealmsTab key="realms" currentRealm={currentRealm} openExternalLink={openExternalLink} />}
{activeTab === "quests" && <QuestsTab key="quests" userId={user?.id} onXPGain={handleXPGain} />}
{activeTab === "top" && <LeaderboardTab key="top" openExternalLink={openExternalLink} currentUserId={user?.id} />}
{activeTab === "jobs" && <JobsTab key="jobs" openExternalLink={openExternalLink} />}
{activeTab === "badges" && <BadgesTab key="badges" userId={user?.id} openExternalLink={openExternalLink} />}
</AnimatePresence>
</div>
</div>
);
}