From 71eeafdaee42fb854b4b46ac17d58b494ea6fdb1 Mon Sep 17 00:00:00 2001 From: shafin-r Date: Thu, 10 Jul 2025 19:28:50 +0600 Subject: [PATCH] feat(ui): add modal functionality, leaderboard view --- app/(tabs)/leaderboard/page.tsx | 102 +++++++++++++++++++++ app/exam/[id]/page.tsx | 151 ++++++++++++++++++++++---------- app/exam/results/page.tsx | 35 ++++---- app/providers.tsx | 5 +- components/ExamModal.tsx | 91 +++++++++++++++++++ components/Header.tsx | 7 +- context/AuthContext.tsx | 22 ++--- context/ModalContext.tsx | 24 +++++ css/Header.module.css | 4 +- 9 files changed, 358 insertions(+), 83 deletions(-) create mode 100644 components/ExamModal.tsx create mode 100644 context/ModalContext.tsx diff --git a/app/(tabs)/leaderboard/page.tsx b/app/(tabs)/leaderboard/page.tsx index e69de29..4660abc 100644 --- a/app/(tabs)/leaderboard/page.tsx +++ b/app/(tabs)/leaderboard/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import BackgroundWrapper from "@/components/BackgroundWrapper"; +import Header from "@/components/Header"; +import { API_URL, getToken } from "@/lib/auth"; +import React, { useEffect, useState } from "react"; + +const page = () => { + const [boardError, setBoardError] = useState(null); + const [boardData, setBoardData] = useState([]); + const [userData, setUserData] = useState(null); + const [loading, setLoading] = useState(true); + + async function fetchBoardData() { + try { + const boardResponse = await fetch(`${API_URL}/leaderboard`, { + method: "GET", + }); + + if (!boardResponse.ok) { + throw new Error("Failed to fetch leaderboard data"); + } + + const fetchedBoardData = await boardResponse.json(); + if (Array.isArray(fetchedBoardData) && fetchedBoardData.length > 0) { + setBoardData(fetchedBoardData); + } else { + setBoardError("No leaderboard data available."); + setBoardData([]); + } + } catch (error) { + console.error(error); + setBoardError("Something went wrong. Please try again."); + setBoardData([]); + } + } + + useEffect(() => { + async function fetchUser() { + try { + const token = await getToken(); + if (!token) throw new Error("User is not authenticated"); + + const response = await fetch(`${API_URL}/me`, { + method: "get", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) throw new Error("Failed to fetch user data"); + + const fetchedUserData = await response.json(); + setLoading(false); + setUserData(fetchedUserData); + } catch (error) { + console.error(error); + setUserData(null); + } + } + + fetchUser(); + fetchBoardData(); + }, []); + + const getTopThree = (boardData) => { + if (!boardData || !Array.isArray(boardData)) return []; + const sortedData = boardData + .filter((player) => player?.points !== undefined) // Ensure `points` exists + .sort((a, b) => b.points - a.points); + + const topThree = sortedData.slice(0, 3).map((player, index) => ({ + ...player, + rank: index + 1, + height: index === 0 ? 280 : index === 1 ? 250 : 220, + })); + + return [topThree[1], topThree[0], topThree[2]].filter(Boolean); // Handle missing players + }; + + const getLeaderboard = (boardData) => { + return boardData.slice().sort((a, b) => b.points - a.points); + }; + + const getUserData = (boardData, name) => { + if (!boardData || !Array.isArray(boardData)) return []; + const sortedData = boardData + .filter((player) => player?.name && player?.points !== undefined) + .sort((a, b) => b.points - a.points); + + const result = sortedData.find((player) => player.name === name); + return result ? [{ ...result, rank: sortedData.indexOf(result) + 1 }] : []; + }; + + return ( + +
+
+
+
+ ); +}; + +export default page; diff --git a/app/exam/[id]/page.tsx b/app/exam/[id]/page.tsx index 9a41f9d..c82ce9e 100644 --- a/app/exam/[id]/page.tsx +++ b/app/exam/[id]/page.tsx @@ -1,11 +1,14 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useMemo } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTimer } from "@/context/TimerContext"; import { useExam } from "@/context/ExamContext"; import { API_URL, getToken } from "@/lib/auth"; import Header from "@/components/Header"; +import { Bookmark, BookmarkCheck } from "lucide-react"; +import { useModal } from "@/context/ModalContext"; +import Modal from "@/components/ExamModal"; // Types interface Question { @@ -21,33 +24,47 @@ interface QuestionItemProps { } const QuestionItem = React.memo( - ({ question, selectedAnswer, handleSelect }) => ( -
-

- {question.id}. {question.question} -

-
- {Object.entries(question.options).map(([key, value]) => ( - - ))} +
+
+ {Object.entries(question.options).map(([key, value]) => ( + + ))} +
- - ) + ); + } ); QuestionItem.displayName = "QuestionItem"; @@ -56,6 +73,7 @@ export default function ExamPage() { const router = useRouter(); const { id } = useParams(); const time = useSearchParams().get("time"); + const { isOpen, close } = useModal(); const { setInitialTime, stopTimer } = useTimer(); const { @@ -76,17 +94,6 @@ export default function ExamPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [submissionLoading, setSubmissionLoading] = useState(false); - useEffect(() => { - console.log( - "hydrated:", - isHydrated, - "initialized:", - isInitialized, - "exam:", - currentExam - ); - }, [isHydrated, isInitialized, currentExam]); - // Initial checks useEffect(() => { if (!isHydrated || !isInitialized || isSubmitting) return; @@ -203,6 +210,11 @@ export default function ExamPage() { ); } + const answeredSet = useMemo(() => { + if (!currentAttempt) return new Set(); + return new Set(currentAttempt.answers.map((a) => String(a.questionId))); + }, [currentAttempt]); + return (
-
+ + {currentAttempt ? ( +
+
+

Questions: {currentExam?.questions.length}

+

+ Answers:{" "} + + {currentAttempt?.answers.length} + +

+

+ Skipped:{" "} + + {currentExam?.questions.length - + currentAttempt?.answers.length} + +

+ + {/* more details */} +
+
+
+ {currentExam?.questions.map((q, idx) => { + const answered = answeredSet.has(String(q.id)); // ← convert to string + + return ( +
+ {idx + 1} +
+ ); + })} +
+
+ ) : ( +

No attempt data.

+ )} +
+
{loading ? (
@@ -231,15 +290,13 @@ export default function ExamPage() { )}
-
- -
+
diff --git a/app/exam/results/page.tsx b/app/exam/results/page.tsx index 0572042..06b62aa 100644 --- a/app/exam/results/page.tsx +++ b/app/exam/results/page.tsx @@ -31,18 +31,18 @@ const QuestionItem = ({ question, selectedAnswer }: QuestionItemProps) => (
- {selectedAnswer?.answer === question.correctAnswer ? ( + {!selectedAnswer ? ( + + Skipped + + ) : selectedAnswer.answer === question.correctAnswer ? ( Correct - ) : selectedAnswer?.answer !== question.correctAnswer ? ( + ) : ( Incorrect - ) : ( - - Skipped - )}
@@ -74,7 +74,7 @@ const QuestionItem = ({ question, selectedAnswer }: QuestionItemProps) => ( ); })}
- +

Solution:

{question.solution}

@@ -96,6 +96,7 @@ export default function ResultsPage() { try { examResults = useExamResults(); + console.log(examResults); } catch (error) { // Handle case where there's no completed exam return ( @@ -221,12 +222,16 @@ export default function ResultsPage() { return (
-
-

- Keep up the good work! +

+ {examResults?.score < 30 + ? "Try harder!" + : examResults?.score < 70 + ? "Getting Better" + : "You did great!"}

{/* Score Display */} @@ -246,16 +251,6 @@ export default function ResultsPage() { /> ))}
-
-
-
{" "} - Correct -
-
-
Your - Answer (Incorrect) -
-
)} diff --git a/app/providers.tsx b/app/providers.tsx index 6df70af..3a17280 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -3,12 +3,15 @@ import { ExamProvider } from "@/context/ExamContext"; import { TimerProvider } from "@/context/TimerContext"; import { AuthProvider } from "@/context/AuthContext"; +import { ModalProvider } from "@/context/ModalContext"; export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); diff --git a/components/ExamModal.tsx b/components/ExamModal.tsx new file mode 100644 index 0000000..37c77f1 --- /dev/null +++ b/components/ExamModal.tsx @@ -0,0 +1,91 @@ +"use client"; +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; + +interface ModalProps { + /** Control visibility */ + open: boolean; + /** Callback for both explicit and implicit close actions */ + onClose: () => void; + /** Optional heading */ + title?: string; + children: React.ReactNode; + /** Center horizontally and vertically? (default true) */ + center?: boolean; +} + +export default function Modal({ + open, + onClose, + title, + children, + center = true, +}: ModalProps) { + const dialogRef = useRef(null); + + // Open / close imperatively to keep in sync with prop + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + }, [open]); + + // Close on native close event + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const handleClose = () => onClose(); + dialog.addEventListener("close", handleClose); + return () => dialog.removeEventListener("close", handleClose); + }, [onClose]); + + // ESC -> close (for browsers without built‑in handling) + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (typeof window === "undefined") return null; // SSR guard + + return createPortal( + + {/* Card */} +
+ {title && ( +
+

{title}

+ +
+ )} + +
{children}
+
+
, + document.body + ); +} diff --git a/components/Header.tsx b/components/Header.tsx index 2904164..887a762 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -6,6 +6,7 @@ import { useTimer } from "@/context/TimerContext"; import styles from "@/css/Header.module.css"; import { useExam } from "@/context/ExamContext"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { useModal } from "@/context/ModalContext"; const API_URL = "https://examjam-api.pptx704.com"; @@ -28,6 +29,7 @@ const Header = ({ examDuration, }) => { const router = useRouter(); + const { open } = useModal(); const { clearExam } = useExam(); const [totalSeconds, setTotalSeconds] = useState( examDuration ? parseInt(examDuration) * 60 : 0 @@ -147,7 +149,7 @@ const Header = ({ Mins
-
+
{String(seconds).padStart(2, "0")} @@ -156,8 +158,7 @@ const Header = ({