From d1dba6e9a4bb1f76df2471c9bdcf437e795be509 Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Sat, 13 Dec 2025 05:06:27 +0000 Subject: [PATCH] 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 --- client/pages/Activity.tsx | 671 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 671 insertions(+) diff --git a/client/pages/Activity.tsx b/client/pages/Activity.tsx index 36b7db44..ea221bf6 100644 --- a/client/pages/Activity.tsx +++ b/client/pages/Activity.tsx @@ -26,6 +26,14 @@ import { X, Send, MessagesSquare, + BarChart3, + Plus, + Vote, + Trophy, + Clock, + ThumbsUp, + Layers, + Eye, } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; @@ -682,6 +690,663 @@ function BadgesTab({ userId, openExternalLink }: { userId?: string; openExternal ); } +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(() => { + 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 ( + + + {showCreate && ( + +
+
+ Create Poll + +
+ + 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" + /> + +
+ {newOptions.map((opt, i) => ( +
+ { + 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 && ( + + )} +
+ ))} + {newOptions.length < 5 && ( + + )} +
+ + 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 ? : 'Create Poll'} + +
+
+ )} +
+ + {!showCreate && userId && ( + 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" + > + Create a Poll + + )} + + {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 ( + +
+
+

{poll.question}

+

+ by {poll.createdByName} ยท {totalVotes} vote{totalVotes !== 1 ? 's' : ''} ยท {formatTimeRemaining(poll.expiresAt)} +

+
+ {isOwner && ( + + )} +
+ +
+ {poll.options.map((option) => { + const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0; + + return ( + 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 && ( + + )} +
+ {option.text} + {hasVoted && ( + {percentage}% + )} +
+
+ ); + })} +
+ + {!userId && ( +

Sign in to vote

+ )} +
+ ); + })} + + {polls.length === 0 && ( +

+ Polls expire after 24 hours. Create one to get opinions! +

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

Weekly Challenges

+

+ + {formatTimeRemaining(getWeekEnd())} +

+
+
+

{challenges.filter(c => claimedChallenges.has(c.id)).length}/{challenges.length}

+

Completed

+
+
+
+ + {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 ( + +
+ {challenge.icon} +
+
+ + {challenge.title} + + +{challenge.xpReward} XP +
+

{challenge.description}

+ +
+
+ +
+ {currentProgress}/{challenge.requirement} +
+
+ + {isClaimed ? ( + + ) : isCompleted ? ( + 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 ? : 'Claim'} + + ) : ( + + )} +
+
+ ); + })} + +

+ Complete challenges to earn bonus XP and exclusive badges! +

+
+ ); +} + +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 }) { + const [votedProjects, setVotedProjects] = useState>(() => { + try { + const saved = localStorage.getItem('aethex_voted_projects'); + return new Set(saved ? JSON.parse(saved) : []); + } catch { + return new Set(); + } + }); + const [projects, setProjects] = useState([ + { 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('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 ( + +
+ + {(Object.keys(ARM_CONFIG) as ArmType[]).map(realm => { + const config = ARM_CONFIG[realm]; + return ( + + ); + })} +
+ + {sortedProjects.map((project, index) => { + const config = ARM_CONFIG[project.realm]; + const hasVoted = votedProjects.has(project.id); + + return ( + +
+
+ {project.thumbnail} +
+ +
+
+ +
+ {config.label} +
+
+

{project.description}

+
+ by {project.author} + + {project.views} + +
+
+ {project.tags.map(tag => ( + + {tag} + + ))} +
+
+ +
+ 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]' + }`} + > + + + + {project.votes} + +
+
+
+ ); + })} + + +
+ ); +} + interface ChatMessage { id: string; userId: string; @@ -1147,6 +1812,9 @@ export default function Activity() { 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 }, @@ -1246,6 +1914,9 @@ export default function Activity() { {activeTab === "feed" && } {activeTab === "chat" && } + {activeTab === "polls" && } + {activeTab === "challenges" && } + {activeTab === "projects" && } {activeTab === "realms" && } {activeTab === "quests" && } {activeTab === "top" && }