diff --git a/apps/frontend/index.html b/apps/frontend/index.html index b9b9096..b0cb5d0 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -2,9 +2,14 @@ - + + + + + - Rexial + Rexial — Real-time quiz platform +
diff --git a/apps/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg index 6893eb1..34b9f17 100644 --- a/apps/frontend/public/favicon.svg +++ b/apps/frontend/public/favicon.svg @@ -1 +1,28 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/components/Footer.tsx b/apps/frontend/src/components/Footer.tsx index 7fb85d7..d71c9ca 100644 --- a/apps/frontend/src/components/Footer.tsx +++ b/apps/frontend/src/components/Footer.tsx @@ -1,10 +1,110 @@ +import { BsEnvelopeFill, BsGithub, BsGlobe2, BsHeartFill, BsLinkedin, BsTwitterX } from "react-icons/bs" +import { Link } from "react-router-dom" + +const SOCIALS = [ + { + label: 'Portfolio', + href: 'https://iam-raj.vercel.app/', + Icon: BsGlobe2, + }, + { + label: 'GitHub', + href: 'https://github.com/TheCodeHeist-Coder', + Icon: BsGithub, + }, + { + label: 'X', + href: 'https://x.com/CodeHeistCoder', + Icon: BsTwitterX, + }, + { + label: 'LinkedIn', + href: 'https://www.linkedin.com/in/raj-kumar-54225532a/', + Icon: BsLinkedin, + }, + { + label: 'Email', + href: 'mailto:thecodeheist01@gmail.com', + Icon: BsEnvelopeFill, + }, +] + function Footer() { return ( -
+
+ ) } -export default Footer \ No newline at end of file +export default Footer diff --git a/apps/frontend/src/components/HeroSection.tsx b/apps/frontend/src/components/HeroSection.tsx index 9b6d245..93a6224 100644 --- a/apps/frontend/src/components/HeroSection.tsx +++ b/apps/frontend/src/components/HeroSection.tsx @@ -27,13 +27,13 @@ function HeroSection() { return ( -
+
-
+
{/* Badge */} -
+
diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx index 2537ab5..9d6650e 100644 --- a/apps/frontend/src/components/Navbar.tsx +++ b/apps/frontend/src/components/Navbar.tsx @@ -1,4 +1,6 @@ +import { useEffect, useState } from "react" import { BsGithub } from "react-icons/bs" +import { HiMenuAlt3, HiX } from "react-icons/hi" import { Link } from "react-router-dom" import { useAuthStore } from "../store/authStore" @@ -6,65 +8,142 @@ function Navbar() { const { user } = useAuthStore(); + const [menuOpen, setMenuOpen] = useState(false); + // Transparent over the hero, solid once the page moves under it, so the + // bar reads as part of the page rather than a slab sitting on top of it. + const [scrolled, setScrolled] = useState(false); - return ( -
-
+ useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 12); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); - {/* Logo */} -
- Rexial -
+ // A menu left open while the page scrolls away behind it is disorienting. + useEffect(() => { + if (!menuOpen) return; + const close = () => setMenuOpen(false); + window.addEventListener('scroll', close, { passive: true }); + return () => window.removeEventListener('scroll', close); + }, [menuOpen]); - {/* Right Section */} -
+ return ( +
+
+ + {/* Mobile drawer */} + {menuOpen && ( +
+ {!user && ( + setMenuOpen(false)} + className="font-secondary font-bold border border-pink-600/70 px-6 py-3 rounded-full text-base text-center text-pink-500 active:scale-95 transition" + > + Login + + )} + + setMenuOpen(false)} + > + + Contribute on GitHub + +
+ )} + + ) } -export default Navbar \ No newline at end of file +export default Navbar diff --git a/apps/frontend/src/screens/Dashboard.tsx b/apps/frontend/src/screens/Dashboard.tsx index 8279455..33ee871 100644 --- a/apps/frontend/src/screens/Dashboard.tsx +++ b/apps/frontend/src/screens/Dashboard.tsx @@ -230,6 +230,8 @@ function Dashboard() { Quiz Title Date + Rank + Players My Score @@ -240,6 +242,19 @@ function Dashboard() { {session.quiz.title} {new Date(session.startedAt || session.createdAt).toLocaleDateString()} + + {session.myRank ? ( + + #{session.myRank} + + ) : —} + + {session.totalParticipants ?? '—'} {myData?.score || 0} pts ); diff --git a/apps/frontend/src/screens/LiveQuiz.tsx b/apps/frontend/src/screens/LiveQuiz.tsx index 520d9ce..4ad93d3 100644 --- a/apps/frontend/src/screens/LiveQuiz.tsx +++ b/apps/frontend/src/screens/LiveQuiz.tsx @@ -77,18 +77,29 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { // because the leader is sized differently from the trail, so this tracks // the viewport directly. Mobile keeps the original sizes; a laptop or a // projector has the room for something readable across a hall. - const [isWideScreen, setIsWideScreen] = useState( - typeof window !== 'undefined' && window.matchMedia('(min-width: 640px)').matches - ); + // Three steps rather than two: a projector or a large monitor has the room + // for avatars that read across a hall, and the two-step version still + // looked small on anything above a laptop. + const [screenTier, setScreenTier] = useState<'sm' | 'md' | 'lg'>(() => { + if (typeof window === 'undefined') return 'sm'; + if (window.matchMedia('(min-width: 1024px)').matches) return 'lg'; + if (window.matchMedia('(min-width: 640px)').matches) return 'md'; + return 'sm'; + }); useEffect(() => { - const mq = window.matchMedia('(min-width: 640px)'); - const onChange = (e: MediaQueryListEvent) => setIsWideScreen(e.matches); - mq.addEventListener('change', onChange); - return () => mq.removeEventListener('change', onChange); + const wide = window.matchMedia('(min-width: 1024px)'); + const mid = window.matchMedia('(min-width: 640px)'); + const update = () => setScreenTier(wide.matches ? 'lg' : mid.matches ? 'md' : 'sm'); + wide.addEventListener('change', update); + mid.addEventListener('change', update); + return () => { + wide.removeEventListener('change', update); + mid.removeEventListener('change', update); + }; }, []); - const avatarLead = isWideScreen ? 52 : 30; - const avatarTrail = isWideScreen ? 42 : 24; - const avatarOverlap = isWideScreen ? -14 : -9; + const avatarLead = screenTier === 'lg' ? 76 : screenTier === 'md' ? 62 : 30; + const avatarTrail = screenTier === 'lg' ? 62 : screenTier === 'md' ? 50 : 24; + const avatarOverlap = screenTier === 'lg' ? -20 : screenTier === 'md' ? -16 : -9; const [selectedAnswer, setSelectedAnswer] = useState(null); const [questionStartTime, setQuestionStartTime] = useState(0); @@ -617,16 +628,16 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { return (
- + {allIn ? 'Everyone has answered' : 'Answers in'} - + {answered} / {total} @@ -635,7 +646,7 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { {/* Avatars of whoever answered most recently, riding the head of the bar. Newest is leftmost and largest, so an arrival is visible from across a room. */} -
+
-
+
{!allIn && ( -

+

{total - answered} still answering

)} diff --git a/apps/http-server/src/controllers/quizController.ts b/apps/http-server/src/controllers/quizController.ts index 6c9fcb5..624b486 100644 --- a/apps/http-server/src/controllers/quizController.ts +++ b/apps/http-server/src/controllers/quizController.ts @@ -70,16 +70,48 @@ export const getAllQuizesController = async (req: Request, res: Response) => { where: { userId: req.userId } - } + }, + // Total players in the session, so a past quiz can say + // "12th of 300" rather than just a score with no context. + _count: { select: { participants: true } } }, orderBy: { createdAt: 'desc' } }); + // Rank is a position within a session, which SQL will not give us from + // the query above. Counting how many beat each player is one query for + // the whole dashboard rather than one per quiz. + const myParticipantIds = participatedQuizes + .map((s) => s.participants[0]?.id) + .filter((id): id is string => Boolean(id)); + + const ranks = new Map(); + if (myParticipantIds.length > 0) { + const rows = await prisma.$queryRaw<{ id: string; rank: number }[]>` + SELECT me.id, + ( + SELECT count(*) + 1 + FROM "Participant" other + WHERE other."sessionId" = me."sessionId" + AND other.score > me.score + )::int AS rank + FROM "Participant" me + WHERE me.id = ANY(${myParticipantIds}) + `; + for (const row of rows) ranks.set(row.id, row.rank); + } + + const participatedWithRank = participatedQuizes.map((session) => ({ + ...session, + totalParticipants: session._count.participants, + myRank: ranks.get(session.participants[0]?.id ?? '') ?? null, + })); + return res.status(200).json({ organizedQuizes, - participated: participatedQuizes + participated: participatedWithRank }) } catch (error) { diff --git a/apps/http-server/src/middlewares/authmiddleware.ts b/apps/http-server/src/middlewares/authmiddleware.ts index 790dca4..485a13c 100644 --- a/apps/http-server/src/middlewares/authmiddleware.ts +++ b/apps/http-server/src/middlewares/authmiddleware.ts @@ -26,4 +26,34 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc console.log("Error in authentication", error); return errorResponse(res, 500, "Internal Server Error...") } -} \ No newline at end of file +} + +/** + * Attach req.userId when a valid token is present, and carry on when it is not. + * + * Joining a quiz is open to anyone — no account needed, which is the point of + * a join code — so the route cannot require auth. But it still needs to know + * who a signed-in participant is: without that the Participant row is written + * with userId null and the player never sees the quiz in their history, + * because nothing links the two. + * + * A bad or expired token is treated as no token rather than an error. Someone + * with a stale token in their browser should still be able to play. + */ +export const optionalAuthMiddleware = async (req: Request, _res: Response, next: NextFunction) => { + const authHeader = req.headers["authorization"]; + + if (!authHeader || !authHeader.startsWith('Bearer ')) return next(); + + const token = authHeader.split(' ')[1]; + if (!token) return next(); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET || "secret") as { userId: string }; + req.userId = decoded.userId; + } catch { + // Expired or malformed: continue as an anonymous participant. + } + + next(); +} diff --git a/apps/http-server/src/routes/session.ts b/apps/http-server/src/routes/session.ts index d8708c8..7634512 100644 --- a/apps/http-server/src/routes/session.ts +++ b/apps/http-server/src/routes/session.ts @@ -1,10 +1,14 @@ import { Router } from "express"; import { getLeaderboardController, joinQuizController } from "../controllers/sessionController"; +import { optionalAuthMiddleware } from "../middlewares/authmiddleware"; const router = Router(); // join quiz -router.post("/join", joinQuizController) +// Optional auth, not required: anyone with a code can play, but a signed-in +// participant must be linked to their account or the quiz never shows up in +// their history. +router.post("/join", optionalAuthMiddleware, joinQuizController) // get leaderboard router.get('/:sessionId/leaderboard', getLeaderboardController); diff --git a/apps/ws-server/src/utils/handleMessages.ts b/apps/ws-server/src/utils/handleMessages.ts index 1b5104a..f401638 100644 --- a/apps/ws-server/src/utils/handleMessages.ts +++ b/apps/ws-server/src/utils/handleMessages.ts @@ -32,6 +32,79 @@ import { enqueueAnswer, countAnswered, drainSession } from "./answerQueue.js"; const MAX_POINTS = 10; +/** + * Rate-limited progress updates. + * + * One broadcast per answer is O(answers x clients): at 750 participants that + * is roughly half a million socket writes in the few seconds a question takes + * to fill, on the one thread that also has to run the countdown. The timer + * skipped seconds and the results arrived late because of it. + * + * Coalescing to one update per interval keeps the bar smooth — it animates + * between values client-side — at a fraction of the cost. The newest payload + * always wins, and a trailing send guarantees the final count is not the one + * that got dropped. + */ +const PROGRESS_INTERVAL_MS = 150; + +interface ProgressPayload { + questionId: string; + answered: number; + total: number; + participantId?: string; +} + +const progressPending = new Map(); +const progressTimers = new Map(); +const progressLastSent = new Map(); + +function queueProgressBroadcast(sessionId: string, payload: ProgressPayload) { + progressPending.set(sessionId, payload); + + const now = Date.now(); + const last = progressLastSent.get(sessionId) ?? 0; + const elapsed = now - last; + + // Enough time has passed: send immediately, so the first answer and a + // slow trickle both show up at once rather than waiting out the interval. + if (elapsed >= PROGRESS_INTERVAL_MS) { + flushProgress(sessionId); + return; + } + + // Otherwise let the already-scheduled trailing send carry it. + if (progressTimers.has(sessionId)) return; + + progressTimers.set( + sessionId, + setTimeout(() => { + progressTimers.delete(sessionId); + flushProgress(sessionId); + }, PROGRESS_INTERVAL_MS - elapsed), + ); +} + +function flushProgress(sessionId: string) { + const payload = progressPending.get(sessionId); + if (!payload) return; + + progressPending.delete(sessionId); + progressLastSent.set(sessionId, Date.now()); + broadcastToSession(sessionId, 'quiz:answer-progress', payload); +} + +/** Drop a session's progress state once its quiz is over. */ +function clearProgress(sessionId: string) { + const timer = progressTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + progressTimers.delete(sessionId); + } + progressPending.delete(sessionId); + progressLastSent.delete(sessionId); +} + + /** @@ -265,6 +338,7 @@ export const handleMessage = async (client: Client, data: any) => { await drainSession(sessionId); await endQuizSession(sessionId); await clearSessionCache(sessionId); + clearProgress(sessionId); broadcastToSession(sessionId, 'quiz:ended'); return; }; @@ -388,14 +462,19 @@ export const handleMessage = async (client: Client, data: any) => { connected.map((c) => c.participantId as string), ); - // Progress for the room, from the counts already gathered - // above: no extra query. It also explains the early finish — - // without it a question ending before the clock looks like a - // glitch rather than "everyone has answered". - // participantId rides along so the room can show whose answer - // just landed. Avatars are derived from the id on the client, - // so this costs one field rather than a lookup. - broadcastToSession(sessionId, 'quiz:answer-progress', { + // Progress for the room, coalesced rather than sent per + // answer. Broadcasting on every submission meant 737 answers + // against 738 sockets — about half a million ws.send() calls + // in the four seconds a question takes to fill — which + // saturated the event loop: the one-second countdown could + // not run on schedule and visibly skipped (22 straight to + // 16), and the results that follow queued behind the + // backlog, reaching the host seconds late. + // + // At most one update every 150ms carries the same + // information: the bar is animated client-side anyway, and + // nobody can read 737 discrete updates. + queueProgressBroadcast(sessionId, { questionId, answered, total: connected.length, @@ -403,6 +482,12 @@ export const handleMessage = async (client: Client, data: any) => { }); if (answered >= connected.length) { + // Send the final n/n now rather than letting the trailing + // timer carry it: the question is about to close, and a + // bar that never visibly fills is what made the early + // finish look arbitrary. + flushProgress(sessionId); + // Index of the question just answered, taken from the // question list rather than the cached session: the // session cache is not invalidated when the organizer @@ -432,6 +517,7 @@ export const handleMessage = async (client: Client, data: any) => { // read, or the last question's scores are missing from the board // everyone finishes on. await drainSession(sessionId); + clearProgress(sessionId); await invalidateLeaderboard(sessionId); // Read the leaderboard before the caches are dropped: both diff --git a/apps/ws-server/src/utils/timeManager.ts b/apps/ws-server/src/utils/timeManager.ts index 160b526..19128b8 100644 --- a/apps/ws-server/src/utils/timeManager.ts +++ b/apps/ws-server/src/utils/timeManager.ts @@ -189,8 +189,14 @@ export async function startQuestionTimer( broadcastToSession(sessionId, 'quiz:timer-tick', { timeLeft }); + // Absolute end time, so a tick that runs late reports the time that has + // actually passed. Decrementing a counter instead meant a delayed tick + // silently lost that second: under load the countdown jumped 22 -> 16 and + // then finished early by however much it had skipped. + const endsAt = Date.now() + question.timeLimit * 1_000; + const timer = setInterval(async () => { - timeLeft--; + timeLeft = Math.max(0, Math.ceil((endsAt - Date.now()) / 1_000)); broadcastToSession(sessionId, 'quiz:timer-tick', { timeLeft }); if (timeLeft <= 0) {