From d4c2a093dde5985f402fa10d6824d7c3b8cf8321 Mon Sep 17 00:00:00 2001 From: ABHISHEKUPAI Date: Mon, 9 Mar 2026 20:19:14 +0530 Subject: [PATCH 1/3] feat: add JEE quiz interface with navigation, timer, and answer handling --- app/components/pages/Retentiaquiz.tsx | 233 +++++++++++++++++++++++ app/components/ui/Button.tsx | 30 ++- app/components/ui/Card.tsx | 23 +++ app/components/ui/Progressbar.tsx | 35 ++++ app/components/ui/QuestionNavigation.tsx | 141 ++++++++++++++ app/components/ui/Timer.tsx | 48 +++++ app/globals.css | 5 +- app/page.tsx | 2 +- 8 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 app/components/ui/Card.tsx create mode 100644 app/components/ui/Progressbar.tsx create mode 100644 app/components/ui/QuestionNavigation.tsx create mode 100644 app/components/ui/Timer.tsx diff --git a/app/components/pages/Retentiaquiz.tsx b/app/components/pages/Retentiaquiz.tsx index e69de29..f7b4add 100644 --- a/app/components/pages/Retentiaquiz.tsx +++ b/app/components/pages/Retentiaquiz.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState } from "react"; +import Card from "../ui/Card"; +import Button from "../ui/Button"; +import ProgressBar from "../ui/Progressbar"; +import Timer from "../ui/Timer"; +import QuestionNavigation from "../ui/QuestionNavigation"; +import { Flag, ChevronRight } from "lucide-react"; + +export default function Retentiaquiz() { + + const totalQuestions = 50; + + const [currentQuestion, setCurrentQuestion] = useState(1); + const [answers, setAnswers] = useState>({}); + const [confirmedAnswers, setConfirmedAnswers] = useState>({}); + const [markedReview, setMarkedReview] = useState>({}); + const [skipped, setSkipped] = useState>({}); + + const answeredCount = Object.keys(confirmedAnswers).length; + + const options = [ + { id: "A", text: "Solar Power" }, + { id: "B", text: "Wind Power" }, + { id: "C", text: "Natural Gas" }, + { id: "D", text: "Hydroelectric Power" }, + ]; + + const handleOptionClick = (optionId: string) => { + + setAnswers((prev) => { + + const current = prev[currentQuestion]; + + return { + ...prev, + [currentQuestion]: current === optionId ? null : optionId, + }; + + }); + + setSkipped((prev) => { + const updated = { ...prev }; + delete updated[currentQuestion]; + return updated; + }); + + }; + + const handleNext = () => { + + if (answers[currentQuestion]) { + + setConfirmedAnswers((prev) => ({ + ...prev, + [currentQuestion]: true, + })); + + setSkipped((prev) => { + const updated = { ...prev }; + delete updated[currentQuestion]; + return updated; + }); + + } else { + + setSkipped((prev) => ({ + ...prev, + [currentQuestion]: true, + })); + + setConfirmedAnswers((prev) => { + const updated = { ...prev }; + delete updated[currentQuestion]; + return updated; + }); + + } + + if (currentQuestion < totalQuestions) { + setCurrentQuestion((prev) => prev + 1); + } + + }; + + const toggleReview = () => { + + setMarkedReview((prev) => ({ + ...prev, + [currentQuestion]: !prev[currentQuestion], + })); + + setSkipped((prev) => { + const updated = { ...prev }; + delete updated[currentQuestion]; + return updated; + }); + + }; + + + const handleSubmit = () => { + + const confirmSubmit = window.confirm( + "Are you sure you want to submit the quiz?" + ); + + if (confirmSubmit) { + alert("Quiz submitted successfully!"); + } + + }; + + return ( + +
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + +

+ Which of the following energy sources cannot be + replenished naturally on a human timescale, + making it an example of a non-renewable resource? +

+
+ +
+ + {options.map((option) => { + + const isSelected = answers[currentQuestion] === option.id; + const isAnswered = confirmedAnswers[currentQuestion]; + + let colorClass = ""; + + if (isAnswered && isSelected) colorClass = "border-secondary "; + else if (isSelected) colorClass = "border-secondary"; + + return ( + + + + ); + })} + +
+ +
+ + + + + +
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index 35625bb..4361163 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -1,19 +1,35 @@ type ButtonProps = { children: React.ReactNode; - variant?: "default" | "sidebar_btn" ; + variant?: "default" | "sidebar_btn" | "opt_btn"; className?: string; + onClick?: () => void; }; -export default function Button({children,variant = "default",className = "", }: ButtonProps) { - const base = "flex items-center cursor-pointer "; +export default function Button({ + children, + variant = "default", + className = "", + onClick, +}: ButtonProps) { + const base = "cursor-pointer min-h-[48px]"; + const variants = { - default: "text-text text-2xl", - sidebar_btn: "group text-2xl px-4 gap-4 py-2 font-semibold rounded-full hover:bg-primary-light", - + default: + "flex p-2 sm:p-3 md:p-4 rounded-2xl items-center text-text text-base sm:text-lg md:text-xl", + + sidebar_btn: + "flex items-center group text-lg sm:text-xl md:text-2xl px-2 sm:px-4 gap-2 sm:gap-4 py-2 font-semibold rounded-full hover:bg-primary-light", + + opt_btn: + "w-full text-lg sm:text-xl md:text-2xl font-bold rounded-3xl h-[80px] sm:h-[100px] md:h-[120px] sm:py-6 md:py-8 px-4 sm:px-6 md:px-7 items-start text-left break-words bg-background/70 border-background flex justify-center gap-5 border-2 hover:bg-background/30 hover:border-text/60 transition-colors overflow-y-auto", }; return ( - ); diff --git a/app/components/ui/Card.tsx b/app/components/ui/Card.tsx new file mode 100644 index 0000000..249f1f5 --- /dev/null +++ b/app/components/ui/Card.tsx @@ -0,0 +1,23 @@ +type CardProps = { + children: React.ReactNode; + variant?: "primary" | "secondary"; + className?: string; +}; + +export default function Card({ + children, + variant = "primary", + className = "", +}: CardProps) { + const base = "rounded-3xl p-6 "; + const variants = { + primary: "bg-foreground text-text", + secondary: "bg-background/70 text-text", + }; + + return ( +
+ {children} +
+ ); +} diff --git a/app/components/ui/Progressbar.tsx b/app/components/ui/Progressbar.tsx new file mode 100644 index 0000000..736e649 --- /dev/null +++ b/app/components/ui/Progressbar.tsx @@ -0,0 +1,35 @@ +type ProgressBarProps = { + answered: number; + total: number; + className?: string; +}; + +export default function ProgressBar({ + answered, + total, + className = "", +}: ProgressBarProps) { + + const progress = total > 0 ? (answered / total) * 100 : 0; + + return ( +
+ + +
+ {answered} / {total} answered +
+ + +
+ +
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/app/components/ui/QuestionNavigation.tsx b/app/components/ui/QuestionNavigation.tsx new file mode 100644 index 0000000..f51e404 --- /dev/null +++ b/app/components/ui/QuestionNavigation.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +type QuestionNavigationProps = { + + currentQuestion: number; + totalQuestions: number; + onQuestionChange?: (q: number) => void; + + confirmedAnswers: Record; + markedReview: Record; + skipped: Record; + +}; + +export default function QuestionNavigation({ + + currentQuestion, + totalQuestions, + onQuestionChange, + confirmedAnswers, + markedReview, + skipped, + +}: QuestionNavigationProps) { + + const [startIndex, setStartIndex] = useState(0); + const [visibleCount, setVisibleCount] = useState(12); + + const shiftAmount = 5; + + useEffect(() => { + + const updateVisibleCount = () => { + + if (window.innerWidth < 640) setVisibleCount(10); + else if (window.innerWidth < 1024) setVisibleCount(15); + else setVisibleCount(20); + + }; + + updateVisibleCount(); + + window.addEventListener("resize", updateVisibleCount); + + return () => window.removeEventListener("resize", updateVisibleCount); + + }, []); + + const handleNext = () => { + + setStartIndex((prev) => + Math.min(prev + shiftAmount, totalQuestions - visibleCount) + ); + + }; + + const handlePrev = () => { + + setStartIndex((prev) => + Math.max(prev - shiftAmount, 0) + ); + + }; + + const visibleQuestions = Array.from({ length: visibleCount }) + .map((_, index) => startIndex + index + 1) + .filter((q) => q <= totalQuestions); + + return ( + +
+ + + +
+ +
+ + {visibleQuestions.map((qNumber) => { + + const isAnswered = confirmedAnswers[qNumber]; + const isMarked = markedReview[qNumber]; + const isSkipped = skipped[qNumber]; + + let colorClass = + "border-border text-text/70 hover:bg-surface"; + + if (isMarked) { + colorClass = "bg-marked-for-review text-white"; + } + else if (isAnswered) { + colorClass = "bg-answered text-white"; + } + else if (isSkipped) { + colorClass = "bg-not-answered text-white"; + } + + const isCurrent = qNumber === currentQuestion; + + return ( + + + + ); + + })} + +
+ +
+ + + +
+ + ); +} \ No newline at end of file diff --git a/app/components/ui/Timer.tsx b/app/components/ui/Timer.tsx new file mode 100644 index 0000000..7b764aa --- /dev/null +++ b/app/components/ui/Timer.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; + +type TimerProps = { + totalQuestions: number; + onTimeUp?: () => void; +}; + +export default function Timer({ totalQuestions, onTimeUp }: TimerProps) { + + const totalSeconds = totalQuestions * 60; + const [timeLeft, setTimeLeft] = useState(totalSeconds); + + useEffect(() => { + if (timeLeft <= 0) { + onTimeUp?.(); + return; + } + + const interval = setInterval(() => { + setTimeLeft((prev) => prev - 1); + }, 1000); + + return () => clearInterval(interval); + }, [timeLeft, onTimeUp]); + + + const hours = Math.floor(timeLeft / 3600); + const minutes = Math.floor((timeLeft % 3600) / 60); + const seconds = timeLeft % 60; + + const danger = + timeLeft <= 60 ? "text-[#FF0000]/50 animate-pulse" : "text-timer"; + + return ( +
+ + + Time Remaining:{" "} + {hours.toString().padStart(2, "0")}: + {minutes.toString().padStart(2, "0")}: + {seconds.toString().padStart(2, "0")} + +
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 105c4c8..d7066de 100644 --- a/app/globals.css +++ b/app/globals.css @@ -39,7 +39,10 @@ --color-primary-light: var(--primary-light); --color-secondary: var(--secondary); --color-border: var(--border); - + --color-marked-for-review: var(--marked-for-review); + --color-answered: var(--answered); + --color-not-answered: var(--not-answered); + --color-not-visited: var(--not-visited); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } diff --git a/app/page.tsx b/app/page.tsx index 3775d7f..99dde0c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,4 @@ -import Sidebar from "./components/Sidebar"; +import Sidebar from "./components/pages/Retentiaquiz"; export default function Home() { return ( From f7e5342e4cc6f809a74bb4898afbe8f77db6c070 Mon Sep 17 00:00:00 2001 From: ABHISHEKUPAI Date: Tue, 10 Mar 2026 18:42:15 +0530 Subject: [PATCH 2/3] refactor: improve quiz page UI and layout styling --- app/components/pages/Retentiaquiz.tsx | 26 ++++++++++++------------ app/components/ui/Button.tsx | 2 +- app/components/ui/Card.tsx | 2 +- app/components/ui/QuestionNavigation.tsx | 8 ++++---- app/globals.css | 26 ++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/app/components/pages/Retentiaquiz.tsx b/app/components/pages/Retentiaquiz.tsx index f7b4add..6e19c4b 100644 --- a/app/components/pages/Retentiaquiz.tsx +++ b/app/components/pages/Retentiaquiz.tsx @@ -21,7 +21,7 @@ export default function Retentiaquiz() { const answeredCount = Object.keys(confirmedAnswers).length; const options = [ - { id: "A", text: "Solar Power" }, + { id: "A", text: "Solarpx-2 sm:px-3 md:px-4 py-1 sm:py-3 border-2 rounded-md bg-background flex items-center gap-1 text-sm sm:text-basepx-2 sm:px-3 md:px-4 py-1 sm:py-3 border-2 rounded-md bg-background flex items-center gap-1 text-sm sm:text-basepx-2 sm:px-3 md:px-4 py-1 sm:py-3 border-2 rounded-md bg-background flex items-center gap-1 text-sm sm:text-basepx-2 sm:px-3 md:px-4 py-1 sm:py-3 border-2 rounded-md bg-background flex items-center gap-1 text-sm sm:text-base" }, { id: "B", text: "Wind Power" }, { id: "C", text: "Natural Gas" }, { id: "D", text: "Hydroelectric Power" }, @@ -114,12 +114,7 @@ export default function Retentiaquiz() { return ( -
- - +
@@ -156,15 +151,19 @@ export default function Retentiaquiz() { markedReview={markedReview} skipped={skipped} /> + + -

+

Which of the following energy sources cannot be replenished naturally on a human timescale, - making it an example of a non-renewable resource?

@@ -204,7 +203,10 @@ export default function Retentiaquiz() {
-
+ + + +
); } \ No newline at end of file diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index 4361163..7f0cee8 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -21,7 +21,7 @@ export default function Button({ "flex items-center group text-lg sm:text-xl md:text-2xl px-2 sm:px-4 gap-2 sm:gap-4 py-2 font-semibold rounded-full hover:bg-primary-light", opt_btn: - "w-full text-lg sm:text-xl md:text-2xl font-bold rounded-3xl h-[80px] sm:h-[100px] md:h-[120px] sm:py-6 md:py-8 px-4 sm:px-6 md:px-7 items-start text-left break-words bg-background/70 border-background flex justify-center gap-5 border-2 hover:bg-background/30 hover:border-text/60 transition-colors overflow-y-auto", + "w-full text-lg sm:text-xl md:text-2xl font-bold rounded-3xl sm:py-6 md:py-8 px-4 sm:px-6 md:px-7 items-start text-left break-words bg-background/70 border-background flex justify-center gap-5 border-2 hover:bg-background/30 hover:border-text/60 transition-colors overflow-y-auto", }; return ( diff --git a/app/components/ui/Card.tsx b/app/components/ui/Card.tsx index 249f1f5..bd5bd8f 100644 --- a/app/components/ui/Card.tsx +++ b/app/components/ui/Card.tsx @@ -11,7 +11,7 @@ export default function Card({ }: CardProps) { const base = "rounded-3xl p-6 "; const variants = { - primary: "bg-foreground text-text", + primary: "bg-foreground flex overflow-y-auto flex-col text-text", secondary: "bg-background/70 text-text", }; diff --git a/app/components/ui/QuestionNavigation.tsx b/app/components/ui/QuestionNavigation.tsx index f51e404..de7177a 100644 --- a/app/components/ui/QuestionNavigation.tsx +++ b/app/components/ui/QuestionNavigation.tsx @@ -75,7 +75,7 @@ export default function QuestionNavigation({
+ {showSubmitModal && ( +
+ + +

+ Submit Quiz? +

+ +

+ Are you sure you want to submit the quiz? +

+ +
+ + + + + +
+ +
+
+ )} ); diff --git a/app/components/ui/QuestionNavigation.tsx b/app/components/ui/QuestionNavigation.tsx index de7177a..6fb022e 100644 --- a/app/components/ui/QuestionNavigation.tsx +++ b/app/components/ui/QuestionNavigation.tsx @@ -4,26 +4,21 @@ import { useEffect, useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; type QuestionNavigationProps = { - currentQuestion: number; totalQuestions: number; onQuestionChange?: (q: number) => void; - confirmedAnswers: Record; markedReview: Record; skipped: Record; - }; export default function QuestionNavigation({ - currentQuestion, totalQuestions, onQuestionChange, confirmedAnswers, markedReview, skipped, - }: QuestionNavigationProps) { const [startIndex, setStartIndex] = useState(0); @@ -32,13 +27,10 @@ export default function QuestionNavigation({ const shiftAmount = 5; useEffect(() => { - const updateVisibleCount = () => { - if (window.innerWidth < 640) setVisibleCount(10); else if (window.innerWidth < 1024) setVisibleCount(15); else setVisibleCount(20); - }; updateVisibleCount(); @@ -46,23 +38,25 @@ export default function QuestionNavigation({ window.addEventListener("resize", updateVisibleCount); return () => window.removeEventListener("resize", updateVisibleCount); - }, []); + const isPrevDisabled = startIndex === 0; + const isNextDisabled = startIndex + visibleCount >= totalQuestions; + const handleNext = () => { + if (isNextDisabled) return; setStartIndex((prev) => Math.min(prev + shiftAmount, totalQuestions - visibleCount) ); - }; const handlePrev = () => { + if (isPrevDisabled) return; setStartIndex((prev) => Math.max(prev - shiftAmount, 0) ); - }; const visibleQuestions = Array.from({ length: visibleCount }) @@ -70,19 +64,19 @@ export default function QuestionNavigation({ .filter((q) => q <= totalQuestions); return ( -
-
{visibleQuestions.map((qNumber) => { @@ -91,8 +85,7 @@ export default function QuestionNavigation({ const isMarked = markedReview[qNumber]; const isSkipped = skipped[qNumber]; - let colorClass = - "border-border text-text/70 "; + let colorClass = "border-border text-text/70"; if (isMarked) { colorClass = "bg-marked-for-review text-white"; @@ -107,7 +100,6 @@ export default function QuestionNavigation({ const isCurrent = qNumber === currentQuestion; return ( - - ); - })}
-
- ); } \ No newline at end of file diff --git a/app/components/ui/Timer.tsx b/app/components/ui/Timer.tsx index 7b764aa..d77219b 100644 --- a/app/components/ui/Timer.tsx +++ b/app/components/ui/Timer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { Clock } from "lucide-react"; type TimerProps = { @@ -13,9 +13,16 @@ export default function Timer({ totalQuestions, onTimeUp }: TimerProps) { const totalSeconds = totalQuestions * 60; const [timeLeft, setTimeLeft] = useState(totalSeconds); + const onTimeUpRef = useRef(onTimeUp); + + useEffect(() => { + onTimeUpRef.current = onTimeUp; + }, [onTimeUp]); + useEffect(() => { + if (timeLeft <= 0) { - onTimeUp?.(); + onTimeUpRef.current?.(); return; } @@ -24,21 +31,29 @@ export default function Timer({ totalQuestions, onTimeUp }: TimerProps) { }, 1000); return () => clearInterval(interval); - }, [timeLeft, onTimeUp]); + }, [timeLeft]); const hours = Math.floor(timeLeft / 3600); const minutes = Math.floor((timeLeft % 3600) / 60); const seconds = timeLeft % 60; const danger = - timeLeft <= 60 ? "text-[#FF0000]/50 animate-pulse" : "text-timer"; + timeLeft <= 60 ? "text-text animate-pulse" : "text-timer"; return ( -
- - - Time Remaining:{" "} +
+ + + + Time Remaining: + {hours.toString().padStart(2, "0")}: {minutes.toString().padStart(2, "0")}: {seconds.toString().padStart(2, "0")} diff --git a/app/globals.css b/app/globals.css index 481f947..304f16f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -67,7 +67,7 @@ body { ::-webkit-scrollbar-track { background: transparent; } -:-webkit-scrollbar { +::-webkit-scrollbar { width: 10px; }