diff --git a/.gitignore b/.gitignore index 81e83fe..e6a3755 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .vercel + diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index 6a505f2..c9f6c08 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -50,7 +50,9 @@ const Dashboard = () => { ? summaryQuery.data.recentActivity : mockDashboardSummary.recentActivity; const solvedRate = summaryQuery.data?.totalChallenges - ? Math.round((summaryQuery.data.solved / summaryQuery.data.totalChallenges) * 100) + ? Math.round( + (summaryQuery.data.solved / summaryQuery.data.totalChallenges) * 100, + ) : 0; const stats = [ @@ -99,6 +101,7 @@ const Dashboard = () => { subtitle="Track progress, jump back into your latest work, and command the arena." /> + {/* Featured Hero */} { + {/* Pending Tasks Section Preview */} +
+
+
+

+ + Pending Tasks + 3 +

+ + View All + +
+ +
+ {[ + { id: 'ch_001', title: 'Two Sum', category: 'Logic', priority: 'High', due: '2h' }, + { id: 'ch_002', title: 'Reverse Link', category: 'Data Struct', priority: 'Med', due: '1d' }, + { id: 'ch_003', title: 'Graph Traversal', category: 'Algorithms', priority: 'Low', due: '3d' }, + ].map((task) => ( +
navigate(`/challenge/${task.id}`)} className="group macos-glass p-5 hover:border-accent transition-all cursor-pointer bg-white/[0.02]"> +
+ {task.category} + + {task.priority} + +
+

{task.title}

+
+
+ + Due {task.due} +
+ +
+
+ ))} +
+
+
); }; diff --git a/client/src/pages/Leaderboard.jsx b/client/src/pages/Leaderboard.jsx index d114fbc..bb406ed 100644 --- a/client/src/pages/Leaderboard.jsx +++ b/client/src/pages/Leaderboard.jsx @@ -1,7 +1,10 @@ import React, { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { motion } from 'framer-motion'; -import { FiAward, FiSearch, FiTrendingUp, FiUser, FiUsers, FiZap } from 'react-icons/fi'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + FiAward, FiSearch, FiTrendingUp, FiUser, FiUsers, + FiZap, FiChevronUp, FiChevronDown +} from 'react-icons/fi'; import Card from '../components/Card'; import EmptyState from '../components/EmptyState'; import SkeletonCard from '../components/SkeletonCard'; @@ -24,18 +27,14 @@ const Podium = ({ items, leaderType }) => { const heights = ['h-32 md:h-44', 'h-40 md:h-60', 'h-24 md:h-36']; const delays = [0.2, 0, 0.4]; - if (!items.length) { - return null; - } + if (!items.length) return null; return (
{podiumSteps.map((item, index) => { - if (!item) { - return
; - } - + if (!item) return
; const isFirst = index === 1; + return ( { transition={{ delay: delays[index], duration: 0.6 }} className="relative flex max-w-[120px] flex-1 flex-col items-center md:max-w-[200px]" > -
+
+ {isFirst && ( + + )} {item.profilePicture ? ( - + ) : ( - + {(item.username || item.name || '?')[0]} )} @@ -68,20 +74,21 @@ const Podium = ({ items, leaderType }) => { {item.totalPoints.toLocaleString()}

- {leaderType === 'clans' && ( -

- {item.memberCount ?? 0} members -

- )}
-
+
{index === 1 ? '1' : index === 0 ? '2' : '3'} - {isFirst && } + {isFirst && ( + + + + )}
); @@ -90,36 +97,71 @@ const Podium = ({ items, leaderType }) => { ); }; +const BackgroundAnimation = () => { + const bubbles = useMemo(() => Array.from({ length: 15 }, (_, i) => ({ + id: i, + size: Math.random() * 15 + 5, + x: Math.random() * 100, + duration: Math.random() * 10 + 10, + delay: Math.random() * 5 + })), []); + + return ( +
+
+ + +
+ {bubbles.map((b) => ( + + ))} +
+ ); +}; + const Leaderboard = () => { const { user } = useAuth(); const queryClient = useQueryClient(); const [filters, setFilters] = useState({ window: 'all', page: 1, limit: 20 }); const [search, setSearch] = useState(''); const [leaderType, setLeaderType] = useState('individual'); + const [sortConfig, setSortConfig] = useState(null); useSocket('leaderboard_update', () => { queryClient.invalidateQueries({ queryKey: ['leaderboard'] }); queryClient.invalidateQueries({ queryKey: ['clan-leaderboard'] }); }); + const requestSort = (key) => { + let direction = 'desc'; + if (sortConfig?.key === key && sortConfig.direction === 'desc') direction = 'asc'; + setSortConfig({ key, direction }); + }; + + const renderSortableHeader = (label, sortKey, align = 'left') => ( + requestSort(sortKey)}> +
+ {label} + {sortConfig?.key === sortKey && (sortConfig.direction === 'asc' ? : )} +
+ + ); + const leaderboardQuery = useQuery({ queryKey: ['leaderboard', filters], enabled: leaderType === 'individual', queryFn: async () => { - if (USE_MOCK) { - return { data: mockLeaderboardMembers, meta: { page: 1, totalPages: 1 } }; - } - - const params = new URLSearchParams({ - window: filters.window, - page: String(filters.page), - limit: String(filters.limit), - }); + if (USE_MOCK) return { data: mockLeaderboardMembers, meta: { page: 1, totalPages: 1 } }; + const params = new URLSearchParams({ window: filters.window, page: String(filters.page), limit: String(filters.limit) }); const res = await api.get(`/api/submissions/leaderboard?${params.toString()}`); - return { - data: res.data.data || [], - meta: res.data.meta || {}, - }; + return { data: res.data.data || [], meta: res.data.meta || {} }; }, }); @@ -127,9 +169,7 @@ const Leaderboard = () => { queryKey: ['clan-leaderboard', filters.window], enabled: leaderType === 'clans', queryFn: async () => { - if (USE_MOCK) { - return mockClans; - } + if (USE_MOCK) return mockClans; const res = await api.get(`/api/clans/leaderboard?window=${filters.window}`); return res.data.data || []; }, @@ -142,12 +182,21 @@ const Leaderboard = () => { const meta = leaderType === 'clans' ? { page: 1, totalPages: 1 } : leaderboardQuery.data?.meta || {}; const visibleRows = useMemo(() => { + let result = [...rows]; const query = search.trim().toLowerCase(); - if (!query) { - return rows; + if (query) result = result.filter((row) => (row.username || row.name || '').toLowerCase().includes(query)); + + if (sortConfig) { + result.sort((a, b) => { + let valA = a[sortConfig.key]; + let valB = b[sortConfig.key]; + if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1; + if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); } - return rows.filter((row) => (row.username || row.name || '').toLowerCase().includes(query)); - }, [rows, search]); + return result; + }, [rows, search, sortConfig]); const topThree = visibleRows.slice(0, 3); const myRow = leaderType === 'individual' ? rows.find((row) => row.username === user?.username) : null; @@ -155,225 +204,80 @@ const Leaderboard = () => { return (
-
-
-
-
- - + +
- -
- setSearch(e.target.value)} - /> + setSearch(e.target.value)} />
- setFilters((p) => ({ ...p, page: 1, window: e.target.value }))}> - -
- {myRow && leaderType === 'individual' && ( - -
-
- + + {myRow && ( + +
+ + Your Standing: Rank #{myRow.rank}
- Your current standing in this window -
- - Rank #{myRow.rank} - {myRow.totalPoints} pts - - - )} + {myRow.totalPoints} pts + + )} + {loading ? ( -
- - -
- ) : visibleRows.length === 0 ? ( -
- -
+
) : ( - -
- - - - - - - - - - - {visibleRows.map((item, index) => { - const isMe = leaderType === 'individual' && item.username === user?.username; - const rank = item.rank || index + 1; - return ( - - - - - - - ); - })} - -
Rank{leaderType === 'individual' ? 'Coder' : 'Clan'}{leaderType === 'individual' ? 'Solved' : 'Members'}XP Points
-
- {rank} -
-
-
-
- {item.profilePicture ? ( - - ) : ( - (item.username || item.name || '?')[0] - )} -
-
-
- {item.username || item.name} - {isMe && ( - YOU - )} -
- {leaderType === 'clans' && item.tag && ( -

[{item.tag}]

- )} -
-
-
- {leaderType === 'individual' ? item.solvedCount : item.memberCount} - - {item.totalPoints.toLocaleString()} -
-
- -
- {visibleRows.map((item, index) => { - const rank = item.rank || index + 1; - const isMe = leaderType === 'individual' && item.username === user?.username; - return ( -
-
+
+ + + + {renderSortableHeader('Rank', 'rank')} + {renderSortableHeader(leaderType === 'individual' ? 'Coder' : 'Clan', 'username')} + {renderSortableHeader(leaderType === 'individual' ? 'Solved' : 'Members', 'solvedCount', 'center')} + {renderSortableHeader('XP Points', 'totalPoints', 'right')} + + + + {visibleRows.map((item, index) => ( + + + + + + + ))} + +
{item.rank || index + 1}
- #{rank} - {item.username || item.name} +
{item.profilePicture && }
+ {item.username || item.name}
- {item.totalPoints} pts - -
- {leaderType === 'individual' ? `Solved: ${item.solvedCount}` : `${item.memberCount} members`} - {leaderType === 'clans' && {item.solvedCount ?? 0} solved} -
- - ); - })} - - +
{leaderType === 'individual' ? item.solvedCount : item.memberCount}{item.totalPoints.toLocaleString()}
+
)} - - {leaderType === 'individual' && (meta.totalPages || 1) > 1 && ( -
- - Page {meta.page || filters.page} of {meta.totalPages || 1} - -
- - -
-
- )}
); }; -export default Leaderboard; +export default Leaderboard; \ No newline at end of file diff --git a/server/validators/challengeSchemas.js b/server/validators/challengeSchemas.js index 2f1ef44..30b67e0 100644 --- a/server/validators/challengeSchemas.js +++ b/server/validators/challengeSchemas.js @@ -9,7 +9,7 @@ const challengeIdParamsSchema = { const challengeCreateSchema = { body: z.object({ title: z.string().trim().min(3).max(200), - description: z.string().trim().min(10), + description: z.string().min(0), difficulty: z.enum(['Easy', 'Medium', 'Hard']).default('Easy'), points: z.coerce.number().int().positive().max(10000), category: z.string().trim().min(2).max(80).default('Logic'),