diff --git a/package.json b/package.json index f398c8a..a4dd4ce 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", + "katex": "^0.16.28", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-katex": "^3.1.0", "react-router-dom": "^7.12.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aca1ec2..1bc068c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) + katex: + specifier: ^0.16.28 + version: 0.16.28 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -44,6 +47,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.3(react@19.2.3) + react-katex: + specifier: ^3.1.0 + version: 3.1.0(prop-types@15.8.1)(react@19.2.3) react-router-dom: specifier: ^7.12.0 version: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1145,6 +1151,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1395,6 +1405,10 @@ packages: engines: {node: '>=6'} hasBin: true + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1479,6 +1493,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1511,6 +1529,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1550,6 +1572,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1559,6 +1584,15 @@ packages: peerDependencies: react: ^19.2.3 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-katex@3.1.0: + resolution: {integrity: sha512-At9uLOkC75gwn2N+ZXc5HD8TlATsB+3Hkp9OGs6uA8tM3dwZ3Wljn74Bk3JyHFPgSnesY/EMrIAB1WJwqZqejA==} + peerDependencies: + prop-types: ^15.8.1 + react: '>=15.3.2 <20' + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2761,6 +2795,8 @@ snapshots: color-name@1.1.4: {} + commander@8.3.0: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -3008,6 +3044,10 @@ snapshots: json5@2.2.3: {} + katex@0.16.28: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3072,6 +3112,10 @@ snapshots: lodash.merge@4.6.2: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3100,6 +3144,8 @@ snapshots: node-releases@2.0.27: {} + object-assign@4.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3137,6 +3183,12 @@ snapshots: prelude-ls@1.2.1: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + punycode@2.3.1: {} react-dom@19.2.3(react@19.2.3): @@ -3144,6 +3196,14 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-is@16.13.1: {} + + react-katex@3.1.0(prop-types@15.8.1)(react@19.2.3): + dependencies: + katex: 0.16.28 + prop-types: 15.8.1 + react: 19.2.3 + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): diff --git a/src/App.tsx b/src/App.tsx index 21d26dc..68a7169 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,5 @@ +import "katex/dist/katex.min.css"; + import { Home } from "./pages/student/Home"; import { createBrowserRouter, diff --git a/src/components/RenderQuestionText.tsx b/src/components/RenderQuestionText.tsx new file mode 100644 index 0000000..641e842 --- /dev/null +++ b/src/components/RenderQuestionText.tsx @@ -0,0 +1,19 @@ +import { BlockMath, InlineMath } from "react-katex"; + +export const renderQuestionText = (text: string) => { + const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g); + + return ( + <> + {parts.map((part, index) => { + if (part.startsWith("$$")) { + return {part.slice(2, -2)}; + } + if (part.startsWith("$")) { + return {part.slice(1, -1)}; + } + return {part}; + })} + > + ); +}; diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx index 6d2ec79..cf78603 100644 --- a/src/pages/student/practice/Test.tsx +++ b/src/pages/student/practice/Test.tsx @@ -16,7 +16,7 @@ import { } from "lucide-react"; import { api } from "../../../utils/api"; import { useAuthStore } from "../../../stores/authStore"; -import type { Option, PracticeSheet, Question } from "../../../types/sheet"; +import type { PracticeSheet, Question } from "../../../types/sheet"; import { Button } from "../../../components/ui/button"; import { useSatExam } from "../../../stores/useSatExam"; import { useSatTimer } from "../../../hooks/useSatTimer"; @@ -25,6 +25,7 @@ import type { SubmitAnswer, } from "../../../types/session"; import { useAuthToken } from "../../../hooks/useAuthToken"; +import { renderQuestionText } from "../../../components/RenderQuestionText"; export const Test = () => { const navigate = useNavigate(); @@ -33,8 +34,9 @@ export const Test = () => { const [practiceSheet, setPracticeSheet] = useState( null, ); - const [answer, setAnswer] = useState(""); + // const [answer, setAnswer] = useState(""); const [answers, setAnswers] = useState>({}); + const [showNavigator, setShowNavigator] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [sessionId, setSessionId] = useState(null); @@ -47,10 +49,15 @@ export const Test = () => { const questionIndex = useSatExam((s) => s.questionIndex); const currentQuestion = currentModule?.questions[questionIndex]; + const currentAnswer = currentQuestion + ? (answers[currentQuestion.id] ?? "") + : ""; const resetExam = useSatExam((s) => s.resetExam); const nextQuestion = useSatExam((s) => s.nextQuestion); const prevQuestion = useSatExam((s) => s.prevQuestion); + const goToQuestion = useSatExam((s) => s.goToQuestion); + const finishExam = useSatExam((s) => s.finishExam); const startExam = async () => { @@ -184,10 +191,6 @@ export const Test = () => { if (!user) return; }, [sheetId]); - useEffect(() => { - setAnswer(""); - }, [questionIndex, currentModule?.module_id]); - // const isLastQuestion = // questionIndex === (currentModule?.questions.length ?? 0) - 1; @@ -201,7 +204,7 @@ export const Test = () => { return ( {question.options.map((option, index) => { - const isSelected = answer === option.id; + const isSelected = currentAnswer === option.id; return ( { ? "bg-linear-to-br from-purple-400 to-purple-500 text-white" : "" }`} - onClick={() => setAnswer(option.id)} + onClick={() => + setAnswers((prev) => ({ + ...prev, + [question.id]: option.id, + })) + } > { > {"ABCD"[index]} {" "} - {option.text} + {renderQuestionText(option.text)} ); })} @@ -234,8 +242,13 @@ export const Test = () => { return ( setAnswer(e.target.value)} + value={currentAnswer} + onChange={(e) => + setAnswers((prev) => ({ + ...prev, + [question.id]: e.target.value, + })) + } placeholder="Type your answer here..." className="w-full min-h-30 border rounded-xl px-4 py-3 text-lg font-satoshi focus:outline-none focus:ring-2 focus:ring-purple-400" /> @@ -246,13 +259,13 @@ export const Test = () => { switch (phase) { case "IDLE": return ( - + Ready to begin your test? - + {/* @@ -291,7 +304,7 @@ export const Test = () => { - + */} Before you begin: @@ -335,7 +348,7 @@ export const Test = () => { return ( - + {Math.floor(time / 60)}:{String(time % 60).padStart(2, "0")} @@ -348,8 +361,8 @@ export const Test = () => { */} - - {currentModule?.questions[0]?.context && ( + + {currentQuestion?.context && ( {currentQuestion?.context} @@ -357,15 +370,16 @@ export const Test = () => { )} - + - {currentQuestion?.text} + {currentQuestion?.text && + renderQuestionText(currentQuestion.text)} - + {renderAnswerInput(currentQuestion)} @@ -377,10 +391,63 @@ export const Test = () => { Back - + {/* Menu + */} + + setShowNavigator(true)} + className="px-8 border rounded-full py-3 font-satoshi-medium text-black" + > + Go to + {showNavigator && ( + + + + + Jump to Question + + setShowNavigator(false)} + className="text-gray-500 hover:text-black" + > + ✕ + + + + + {currentModule?.questions.map((q, idx) => { + const isCurrent = idx === questionIndex; + const isAnswered = !!answers[q.id]; + + return ( + { + goToQuestion(idx); + setShowNavigator(false); + }} + className={`w-12 h-12 rounded-lg flex items-center justify-center font-satoshi-medium border transition + ${ + isCurrent + ? "bg-purple-600 text-white border-purple-600" + : isAnswered + ? "bg-green-100 border-green-400 text-green-700" + : "bg-white border-gray-300 hover:bg-gray-100" + } + `} + > + {idx + 1} + + ); + })} + + + + )} + { ); case "BREAK": return ( - - 🧘 Break Time + + Break Time Next module starts in {time}s useSatExam.getState().skipBreak()} diff --git a/src/stores/useSatExam.ts b/src/stores/useSatExam.ts index 237a101..dfff9e0 100644 --- a/src/stores/useSatExam.ts +++ b/src/stores/useSatExam.ts @@ -34,6 +34,7 @@ interface SatExamState { startExam: () => void; nextQuestion: () => void; prevQuestion: () => void; + goToQuestion: (index: number) => void; startBreak: () => void; skipBreak: () => void; @@ -82,6 +83,15 @@ export const useSatExam = create()( } }, + goToQuestion: (index: number) => + set((state) => { + const total = state.currentModuleQuestions?.questions.length ?? 0; + + if (index < 0 || index >= total) return state; + + return { questionIndex: index }; + }), + startBreak: () => { const endTime = Date.now() + BREAK_DURATION * 1000;
{currentQuestion?.context} @@ -357,15 +370,16 @@ export const Test = () => {
- {currentQuestion?.text} + {currentQuestion?.text && + renderQuestionText(currentQuestion.text)}
Next module starts in {time}s