From 7fb734601bd6957b1472f6ea1bf5f41a3dbecbcd Mon Sep 17 00:00:00 2001 From: reyes-simonejake Date: Wed, 8 Apr 2026 02:14:47 +0800 Subject: [PATCH 01/15] update ui re design --- .../src/components/game/ScoreboardDisplay.tsx | 190 ++- .../src/components/game/Timer.tsx | 22 +- .../src/pages/admin/QuestionsPage.tsx | 160 +-- .../src/pages/admin/QuizWorkspacePage.tsx | 565 +++++--- .../src/pages/host/HostGame.tsx | 62 +- .../src/pages/host/HostLobby.tsx | 146 +- .../src/pages/player/PlayerGame.tsx | 57 +- .../intelliquiz-frontend/src/styles/admin.css | 647 ++++++++- .../src/styles/participant.css | 1252 ++++++++++++----- .../src/styles/proctor.css | 1086 +++++++++++--- 10 files changed, 3180 insertions(+), 1007 deletions(-) diff --git a/frontend/intelliquiz-frontend/src/components/game/ScoreboardDisplay.tsx b/frontend/intelliquiz-frontend/src/components/game/ScoreboardDisplay.tsx index 2bb859e..528887b 100644 --- a/frontend/intelliquiz-frontend/src/components/game/ScoreboardDisplay.tsx +++ b/frontend/intelliquiz-frontend/src/components/game/ScoreboardDisplay.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { BiMedal, BiTrophy } from 'react-icons/bi'; +import { Users, Target } from 'lucide-react'; +import { GiTrophyCup, GiMedal, GiRibbonMedal } from 'react-icons/gi'; +import { FaStar } from 'react-icons/fa6'; import type { RankingEntry } from '../../services/api'; interface ScoreboardDisplayProps { @@ -20,9 +22,16 @@ const ScoreboardDisplay: React.FC = ({ const prefix = variant === 'participant' ? 'participant' : 'proctor'; const getRankIcon = (rank: number) => { - if (rank === 1) return ; - if (rank === 2) return ; - if (rank === 3) return ; + if (rank === 1) return ; + if (rank === 2) return ; + if (rank === 3) return ; + return null; + }; + + const getRankBadge = (rank: number) => { + if (rank === 1) return ; + if (rank === 2) return ; + if (rank === 3) return ; return `#${rank}`; }; @@ -46,10 +55,12 @@ const ScoreboardDisplay: React.FC = ({ } return { ...team, displayRank: team.rank || actualRank }; }); + const isArcadeFinal = isFinal && variant === 'proctor'; if (rankings.length === 0) { return (
+

No scores yet

); @@ -67,40 +78,143 @@ const ScoreboardDisplay: React.FC = ({ )} - {/* Podium for Final Results */} - {isFinal && rankings.length >= 3 && ( -
- {/* 2nd Place */} -
-
-
-

{rankedTeams[1]?.teamName}

-

{rankedTeams[1]?.score} pts

+ {/* Top 3 Podium for Final Results */} + {isFinal && rankings.length >= 3 && !isArcadeFinal && ( +
+
+ {/* 2nd Place */} +
+
+ +
+
+ {getInitials(rankedTeams[1]?.teamName)} +
+
+

{rankedTeams[1]?.teamName}

+

+ + {rankedTeams[1]?.score} pts +

+
+
+ + {/* 1st Place */} +
+
+ +
+
+ {getInitials(rankedTeams[0]?.teamName)} +
+
+

{rankedTeams[0]?.teamName}

+

+ + {rankedTeams[0]?.score} pts +

+
+
+ + {/* 3rd Place */} +
+
+ +
+
+ {getInitials(rankedTeams[2]?.teamName)} +
+
+

{rankedTeams[2]?.teamName}

+

+ + {rankedTeams[2]?.score} pts +

+
+
+ )} - {/* 1st Place */} -
-
-
-

{rankedTeams[0]?.teamName}

-

{rankedTeams[0]?.score} pts

+ {isArcadeFinal && ( +
+
+
+ {rankedTeams.slice(0, 3).map((team, idx) => ( +
+
{getRankBadge(team.displayRank)}
+
{team.teamName}
+
{team.score} pts
+
+ ))}
- {/* 3rd Place */} -
-
-
-

{rankedTeams[2]?.teamName}

-

{rankedTeams[2]?.score} pts

+
+
+ {rankedTeams.map((entry, index) => { + const isHighlighted = entry.teamId === highlightTeamId; + const isTopThree = entry.displayRank <= 3; + + return ( +
+
+ {isTopThree ? ( +
+ {getRankIcon(entry.displayRank)} +
+ ) : ( + #{entry.displayRank} + )} +
+ +
+ {getInitials(entry.teamName)} +
+ +
+

+ {entry.teamName} +

+ {isHighlighted && ( + + + You + + )} +
+ +
+
+ +

+ {entry.score} +

+
+

points

+
+
+ ); + })}
)} {/* Rankings List */} -
+ {!isArcadeFinal &&
{rankedTeams.map((entry, index) => { const isHighlighted = entry.teamId === highlightTeamId; const isTopThree = entry.displayRank <= 3; @@ -118,7 +232,13 @@ const ScoreboardDisplay: React.FC = ({ > {/* Rank */}
- {getRankIcon(entry.displayRank)} + {isTopThree ? ( +
+ {getRankIcon(entry.displayRank)} +
+ ) : ( + #{entry.displayRank} + )}
{/* Team Avatar */} @@ -132,21 +252,27 @@ const ScoreboardDisplay: React.FC = ({ {entry.teamName}

{isHighlighted && ( -

Your Team

+ + + You + )}
{/* Score */}
-

- {entry.score} -

+
+ +

+ {entry.score} +

+

points

); })} -
+
}
); }; diff --git a/frontend/intelliquiz-frontend/src/components/game/Timer.tsx b/frontend/intelliquiz-frontend/src/components/game/Timer.tsx index d9f06af..6454f2c 100644 --- a/frontend/intelliquiz-frontend/src/components/game/Timer.tsx +++ b/frontend/intelliquiz-frontend/src/components/game/Timer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; interface TimerProps { timeRemaining: number; @@ -20,9 +20,21 @@ const Timer: React.FC = ({ const percentage = totalTime > 0 ? (timeRemaining / totalTime) * 100 : 0; const isLow = timeRemaining <= 5; const isCritical = timeRemaining <= 3; + const prevTimeRef = useRef(timeRemaining); + const [tickAnimation, setTickAnimation] = React.useState(false); const prefix = variant === 'participant' ? 'participant' : 'proctor'; + // Trigger tick animation when time changes + useEffect(() => { + if (prevTimeRef.current !== timeRemaining && timeRemaining > 0) { + setTickAnimation(true); + const timer = setTimeout(() => setTickAnimation(false), 500); + prevTimeRef.current = timeRemaining; + return () => clearTimeout(timer); + } + }, [timeRemaining]); + const getTimerClass = () => { let classes = `${prefix}-timer`; if (large) classes += ` ${prefix}-timer-large`; @@ -38,6 +50,12 @@ const Timer: React.FC = ({ return classes; }; + const getValueClass = () => { + let classes = `${prefix}-timer-value`; + if (tickAnimation) classes += ' tick-animation'; + return classes; + }; + const minutes = Math.floor(Math.max(0, timeRemaining) / 60); const seconds = Math.max(0, timeRemaining) % 60; const secondsPadded = String(seconds).padStart(2, '0'); @@ -45,7 +63,7 @@ const Timer: React.FC = ({ return (
{/* Time Display */} -
+
{displayMode === 'clock' ? ( <> {minutes}m:{secondsPadded} diff --git a/frontend/intelliquiz-frontend/src/pages/admin/QuestionsPage.tsx b/frontend/intelliquiz-frontend/src/pages/admin/QuestionsPage.tsx index 0d3d3b9..d9facc1 100644 --- a/frontend/intelliquiz-frontend/src/pages/admin/QuestionsPage.tsx +++ b/frontend/intelliquiz-frontend/src/pages/admin/QuestionsPage.tsx @@ -402,7 +402,7 @@ export default function AdminQuestionsPage() {
{canEditContent && ( -
+
@@ -426,93 +426,95 @@ export default function AdminQuestionsPage() { )} {/* Questions List */} -
+
{questions.length > 0 ? ( - questions.map((q, idx) => ( -
-
-
- {idx + 1} -
-
-
-

{q.text}

-
- - {q.points} + questions.map((q, idx) => { + const identificationAnswers = (q.correctKey || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + return ( +
+
+
{idx + 1}
+
+

{q.text}

+
+ + {q.points} pts - - {q.timeLimit}s + + {q.timeLimit}s {q.difficulty}
- {q.type === 'IDENTIFICATION' ? ( -
- Accepted answers: {q.correctKey.split(/\r?\n/).filter(Boolean).join(', ')} -
- ) : ( -
- {q.options.map((option, optIdx) => { - const key = OPTION_KEYS[optIdx]; - const isCorrect = key === q.correctKey; - return ( -
- {key}. - {isCorrect && } - {option} -
- ); - })} -
- )} -
-
- {canEditContent && ( - <> - - - - )}
+ + {q.type === 'IDENTIFICATION' ? ( +
+
+ {identificationAnswers.length > 0 + ? identificationAnswers.map((answer, answerIdx) => ( +
{answer}
+ )) + : 'No accepted answer set'} +
+
+ ) : ( +
+
+ {q.options.map((option, optIdx) => { + const key = OPTION_KEYS[optIdx] || String.fromCharCode(65 + optIdx); + const isCorrect = q.correctKey === key; + return ( +
+ {key} + {option} + {isCorrect && } +
+ ); + })} +
+
+ )} + + {canEditContent && ( +
+ + +
+ )}
-
- )) + ); + }) ) : ( -
-
-
-

No questions yet

-

{canEditContent ? 'Add questions to make your quiz complete' : 'Questions are view-only until the quiz is set back to Draft status.'}

- {canEditContent && ( -
- - -
- )} -
+
+
+

No questions yet

+

{canEditContent ? 'Add questions to make your quiz complete' : 'Questions are view-only until the quiz is set back to Draft status.'}

+ {canEditContent && ( +
+ + +
+ )}
)}
diff --git a/frontend/intelliquiz-frontend/src/pages/admin/QuizWorkspacePage.tsx b/frontend/intelliquiz-frontend/src/pages/admin/QuizWorkspacePage.tsx index b88cf96..8152f8c 100644 --- a/frontend/intelliquiz-frontend/src/pages/admin/QuizWorkspacePage.tsx +++ b/frontend/intelliquiz-frontend/src/pages/admin/QuizWorkspacePage.tsx @@ -64,6 +64,7 @@ export default function QuizWorkspacePage() { const [violationLogs, setViolationLogs] = useState([]); const [violationLogsLoading, setViolationLogsLoading] = useState(false); const [violationLogsError, setViolationLogsError] = useState(null); + const [activeTab, setActiveTab] = useState<'overview' | 'actions' | 'config' | 'reports' | 'codes'>('overview'); const hasEdit = !!quiz && (isSuperAdmin() || canEditQuiz(quiz.id, quiz.createdByUserId)); const isDraftQuiz = quiz?.status === 'DRAFT'; @@ -153,6 +154,14 @@ export default function QuizWorkspacePage() { return teamBasedScoreboard; }, [scoreboard, teams, isArchived]); + const sortedScoreboard = useMemo( + () => [...normalizedScoreboard].sort((a, b) => a.rank - b.rank), + [normalizedScoreboard], + ); + + const topEntry = sortedScoreboard[0] ?? null; + const totalScorePoints = normalizedScoreboard.reduce((sum, row) => sum + row.score, 0); + const statusActionLabel = useMemo(() => { if (!quiz) return ''; if (quiz.status === 'DRAFT') return 'Mark Quiz Ready'; @@ -287,244 +296,382 @@ export default function QuizWorkspacePage() { const formatViolationType = (value: string): string => value.toLowerCase().split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); - const topEntry = normalizedScoreboard.length > 0 - ? [...normalizedScoreboard].sort((a, b) => a.rank - b.rank)[0] - : null; - - const totalScorePoints = normalizedScoreboard.reduce((sum, row) => sum + row.score, 0); - return (
-
+
+
+

{quiz.title}

+

{modeLabel} workspace controls and reports

+
{quiz.status}
-

{quiz.title}

-

{modeLabel} workspace controls and reports

-
- - - - - - - {normalizedAccessMode === 'RESTRICTED' && ( - <> - - - )} - - -
- -
-
-

Participants

-

{teams.length}

-

Current participant groups in this quiz

-
-
-

Total Score Points

-

{totalScorePoints}

-

Combined points across all participants

-
-
-

Top Team

-

{topEntry ? topEntry.teamName : 'N/A'}

-

{topEntry ? `${topEntry.score} pts` : 'No rankings yet'}

-
-
-

Violation Records

-

{violationLogs.length}

-

Persisted anti-cheat entries

+ {/* Tabs Navigation */} +
+
+ + + + +
-
- {normalizedAccessMode === 'PUBLIC' ? : } -
-

{modeLabel}

-

- {normalizedAccessMode === 'PUBLIC' - ? 'Participants can join from the unified entry using quiz/team credentials.' - : 'Entry is controlled by pre-registered team access codes for this quiz.'} -

-
-
+
+ + {/* OVERVIEW TAB */} + {activeTab === 'overview' && ( +
+
+

Quiz Snapshot

+
+
+

Participants

+

{teams.length}

+

Current participant groups in this quiz

+
+
+

Total Score Points

+

{totalScorePoints}

+

Combined points across all participants

+
+
+

Top Team

+

{topEntry ? topEntry.teamName : 'N/A'}

+

{topEntry ? `${topEntry.score} pts` : 'No rankings yet'}

+
+
+

Violation Records

+

{violationLogs.length}

+

Persisted anti-cheat entries

+
+
+
-
-

Quiz Configuration

- {!hasEdit ? ( -

You do not have permission to edit this quiz configuration.

- ) : !isDraftQuiz ? ( -

Configuration is locked because this quiz is not in Draft status.

- ) : ( - <> -
-
- - -
-
- - +

+ {quiz.status === 'READY' ? : } Status Toggle +

+

{statusActionLabel}

+
- {settingsDraft.navigationMode === 'CLASS' && ( -
- - setSettingsDraft((prev) => ({ ...prev, globalTimeLimitMinutes: Number(e.target.value || 0) }))} - /> - {invalidClassTimer && ( -

- Please enter at least 1 minute. -

- )} -
- )} +
+
+ )} + + {/* ACTIONS TAB */} + {activeTab === 'actions' && ( +
+
+

Workspace Actions

+

Use these cards for the day-to-day controls of this quiz.

- - - {settingsError &&

{settingsError}

} -
- + + + + + + {normalizedAccessMode === 'RESTRICTED' && ( + + )} + +
- +
)} -
- {settingsSnackbar && ( -
- {settingsSnackbar} -
- )} + {/* CONFIGURATION TAB */} + {activeTab === 'config' && ( +
+
+
+

Configuration

+

Review the access mode details and quiz settings in one place.

+
+
-
-
-
-

Quiz Code

-

- Share this code with participants: {quiz.quizCode || 'UNAVAILABLE'} -

-
- -
+
+ {normalizedAccessMode === 'PUBLIC' ? : } +
+

{modeLabel}

+

+ {normalizedAccessMode === 'PUBLIC' + ? 'Participants can join from the unified entry using quiz/team credentials.' + : 'Entry is controlled by pre-registered team access codes for this quiz.'} +

+
+
-
-
-

Proctor PIN

-

- Use this code for host access: {quiz.proctorPin || 'UNAVAILABLE'} -

-
- -
-
+
+

Quiz Configuration

+ {!hasEdit ? ( +

You do not have permission to edit this quiz configuration.

+ ) : !isDraftQuiz ? ( +

Configuration is locked because this quiz is not in Draft status.

+ ) : ( + <> +
+
+ + +
+
+ + +
+ {settingsDraft.navigationMode === 'CLASS' && ( +
+ + setSettingsDraft((prev) => ({ ...prev, globalTimeLimitMinutes: Number(e.target.value || 0) }))} + /> + {invalidClassTimer && ( +

+ Please enter at least 1 minute. +

+ )} +
+ )} +
-
-
-
-

{isArchived ? 'Final Scoreboard' : 'Live Scoreboard'}

-

Open rankings in a focused modal and refresh whenever needed.

-
-
- - + + + {settingsError &&

{settingsError}

} +
+ +
+ + )} +
+ + {settingsSnackbar && ( +
+ {settingsSnackbar} +
+ )}
-
+ )} + + {/* CODES TAB */} + {activeTab === 'codes' && ( +
+
+
+

Access Codes

+

Share the quiz code and proctor PIN from these dedicated cards.

+
+
-
-
-

Violation Log Reports

-

Review persisted anti-cheat logs in a focused modal view.

+
+
+
+

Quiz Code

+

+ Share this code with participants: {quiz.quizCode || 'UNAVAILABLE'} +

+
+ +
+ +
+
+

Proctor PIN

+

+ Use this code for host access: {quiz.proctorPin || 'UNAVAILABLE'} +

+
+ +
+
-
- - + )} + + {/* REPORTS TAB */} + {activeTab === 'reports' && ( +
+
+
+

Reports

+

Use the following actions to reopen live scoreboard and violation summaries quickly.

+
+
+ +
+
+
+

{isArchived ? 'Final Scoreboard' : 'Live Scoreboard'}

+

Open rankings in a focused modal and refresh whenever needed.

+
+
+ + +
+
+ +
+
+

Violation Log Reports

+

Review persisted anti-cheat logs in a focused modal view.

+
+
+ + +
+
+
-
+ )} +
{showScoreboardModal && ( diff --git a/frontend/intelliquiz-frontend/src/pages/host/HostGame.tsx b/frontend/intelliquiz-frontend/src/pages/host/HostGame.tsx index 0906c81..1aa507f 100644 --- a/frontend/intelliquiz-frontend/src/pages/host/HostGame.tsx +++ b/frontend/intelliquiz-frontend/src/pages/host/HostGame.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { Trophy, Sparkles, Home, Medal } from 'lucide-react'; +import { Trophy, Home } from 'lucide-react'; +import confetti from 'canvas-confetti'; import { Pause, Play, BarChart3, ArrowRight, Gauge, Users, Timer as TimerIcon } from 'lucide-react'; import { useSSE } from '../../hooks/useSSE'; import { clearSession, getProctorSession } from '../../services/sessionStorage'; @@ -78,6 +79,7 @@ const HostGame: React.FC = () => { const [showRoundAnnouncement, setShowRoundAnnouncement] = useState(false); const [startingRound, setStartingRound] = useState(false); + const hasFiredFinalConfettiRef = useRef(false); const handleStartRoundFromModal = async () => { if (startingRound || gameState !== 'BUFFER') return; @@ -98,6 +100,22 @@ const HostGame: React.FC = () => { setShowRoundAnnouncement(false); setStartingRound(false); }, [gameState, currentRound]); + + useEffect(() => { + if (gameState !== 'FINAL_RESULTS' || hasFiredFinalConfettiRef.current) return; + + hasFiredFinalConfettiRef.current = true; + + confetti({ + particleCount: 42, + spread: 58, + startVelocity: 24, + ticks: 180, + origin: { y: 0.28 }, + colors: ['#f8d86b', '#f4b6c2', '#9dbdff', '#7a1733'], + scalar: 0.85, + }); + }, [gameState]); const isLastQuestion = totalQuestions > 0 && questionNumber >= totalQuestions; const gamePhaseLabel = gameState.replace(/_/g, ' '); @@ -465,8 +483,6 @@ const HostGame: React.FC = () => {

Quiz Complete

Final results are in. Great run from every team.

@@ -486,40 +502,10 @@ const HostGame: React.FC = () => {
- {rankings.length > 0 && ( -
- Champion -
-

{rankings[0]?.teamName}

-

{rankings[0]?.score ?? 0} points

-
-
- )} - - {rankings.length > 0 && ( -
-
Top 3 Teams
- {rankings.slice(0, 3).map((team, idx) => ( -
-
- {idx === 0 ? : } -
-

{team.teamName}

- {team.score} pts -
- ))} -
- )} - -
- -
+
- )} -
- - {/* Error Display */} {error && ( -
- -

{error}

+
+
+ +

{error}

+
)} - {/* Teams Section */} -
-

+
+

- +

+ {connectedTeams.length === 0 + ? 'Share team codes with participants to let them join' + : `${connectedTeams.length} team${connectedTeams.length !== 1 ? 's' : ''} ready to play`} +

- {/* Action Buttons */} -
- - - -
+
+
+

+

+
+ + + {connected ? 'Connected to server' : + connecting ? 'Connecting...' : + 'Disconnected'} + + {!connected && !connecting && ( + + )} +
+
+ + + +
+
- {/* Help Text */} -

- {connectedTeams.length === 0 - ? 'Share team codes with participants to let them join' - : `${connectedTeams.length} team${connectedTeams.length !== 1 ? 's' : ''} ready to play`} -

+
+

+ Ranking Preview +

+ +

+ Rankings refresh automatically as soon as answers are graded. +

+
+

diff --git a/frontend/intelliquiz-frontend/src/pages/player/PlayerGame.tsx b/frontend/intelliquiz-frontend/src/pages/player/PlayerGame.tsx index 7de25f6..9cacc2d 100644 --- a/frontend/intelliquiz-frontend/src/pages/player/PlayerGame.tsx +++ b/frontend/intelliquiz-frontend/src/pages/player/PlayerGame.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { CheckCircle2, CircleX, AlarmClock, Trophy, Sparkles, ListChecks, Home } from 'lucide-react'; +import { CheckCircle2, CircleX, AlarmClock, ListChecks, Home, Trophy, Award, BarChart3 } from 'lucide-react'; import { useSSE } from '../../hooks/useSSE'; import { getParticipantSession } from '../../services/sessionStorage'; import { quizResultsApi, type ParticipantQuestionResult } from '../../services/api'; @@ -345,7 +345,10 @@ const PlayerGame: React.FC = () => { {/* Timer — only show during active question, not buffer */} {gameState === 'QUESTION' && ( -
+
{
{/* Celebration Header */}
-