Added the capability for users to resume their previously stopped sessions
This commit is contained in:
@@ -59,12 +59,18 @@ export default function MultipleChoice({
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -93,7 +99,7 @@ export default function MultipleChoice({
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
@@ -103,7 +109,7 @@ export default function MultipleChoice({
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
|
||||
101
src/components/Medium/SessionCard.tsx
Normal file
101
src/components/Medium/SessionCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
export default function SessionCard({
|
||||
session,
|
||||
reload,
|
||||
loadSession,
|
||||
}: {
|
||||
session: Session;
|
||||
reload: () => void;
|
||||
loadSession: (session: Session) => Promise<void>;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const deleteSession = async () => {
|
||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await axios
|
||||
.delete(`/api/sessions/${session.sessionId}`)
|
||||
.then(() => {
|
||||
toast.success(`Successfully delete session "${session.sessionId}"`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later");
|
||||
})
|
||||
.finally(() => {
|
||||
reload();
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
||||
<span className="flex gap-1">
|
||||
<b>ID:</b>
|
||||
{session.sessionId}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<b>Date:</b>
|
||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||
</span>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
data-tip={capitalize(module)}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<button
|
||||
onClick={async () => await loadSession(session)}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Resume"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Delete"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) setExerciseIndex(-1);
|
||||
if (showSolutions) return setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (exam.variant !== "partial") setPartIndex(-1);
|
||||
}, [exam.variant, setPartIndex]);
|
||||
// useEffect(() => {
|
||||
// if (exam.variant !== "partial") setPartIndex(-1);
|
||||
// }, [exam.variant, setPartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
@@ -214,7 +214,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exerciseIndex === -1 && partIndex === -1 && exam.variant !== "partial" && (
|
||||
{partIndex === -1 && exam.variant !== "partial" && (
|
||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {User} from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import Button from "@/components/Low/Button";
|
||||
@@ -13,6 +13,10 @@ import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -25,13 +29,32 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const {stats} = useStats(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
|
||||
const state = useExamStore((state) => state);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
@@ -94,7 +117,28 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-8 flex w-full justify-between gap-8">
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reload}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {ExamState} from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export type Session = ExamState & {user: string};
|
||||
export type Session = ExamState & {user: string; id: string; date: string};
|
||||
|
||||
export default function useSessions(user?: string) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {useRouter} from "next/router";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import useSessions from "@/hooks/useSessions";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
interface Props {
|
||||
page: "exams" | "exercises";
|
||||
@@ -36,15 +37,17 @@ export default function ExamPage({page}: Props) {
|
||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
|
||||
const partIndex = useExamStore((state) => state.partIndex);
|
||||
const resetStore = useExamStore((state) => state.reset);
|
||||
const assignment = useExamStore((state) => state.assignment);
|
||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||
const exerciseIndex = useExamStore((state) => state.exerciseIndex);
|
||||
|
||||
const {exam, setExam} = useExamStore((state) => state);
|
||||
const {exams, setExams} = useExamStore((state) => state);
|
||||
const {sessionId, setSessionId} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||
@@ -52,10 +55,23 @@ export default function ExamPage({page}: Props) {
|
||||
const {user} = useUser({redirectTo: "/login"});
|
||||
const router = useRouter();
|
||||
|
||||
const reset = () => {
|
||||
resetStore();
|
||||
setVariant("full");
|
||||
setAvoidRepeated(false);
|
||||
setHasBeenUploaded(false);
|
||||
setShowAbandonPopup(false);
|
||||
setIsEvaluationLoading(false);
|
||||
setStatsAwaitingEvaluation([]);
|
||||
setTimeSpent(0);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const saveSession = async () => {
|
||||
await axios.post("/api/sessions", {
|
||||
id: sessionId,
|
||||
sessionId,
|
||||
date: new Date().toISOString(),
|
||||
userSolutions,
|
||||
moduleIndex,
|
||||
selectedModules,
|
||||
@@ -65,6 +81,7 @@ export default function ExamPage({page}: Props) {
|
||||
exam,
|
||||
partIndex,
|
||||
exerciseIndex,
|
||||
questionIndex,
|
||||
user: user?.id,
|
||||
});
|
||||
};
|
||||
@@ -82,7 +99,7 @@ export default function ExamPage({page}: Props) {
|
||||
if (sessionId.length > 0 && userSolutions.length > 0 && selectedModules.length > 0 && exams.length > 0 && !!exam && timeSpent > 0)
|
||||
saveSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex]);
|
||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeSpent % 20 === 0 && timeSpent > 0) saveSession();
|
||||
@@ -90,10 +107,11 @@ export default function ExamPage({page}: Props) {
|
||||
}, [timeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedModules.length > 0) {
|
||||
setSessionId(uuidv4());
|
||||
if (selectedModules.length > 0 && sessionId.length === 0) {
|
||||
const shortUID = new ShortUniqueId();
|
||||
setSessionId(shortUID.randomUUID(8));
|
||||
}
|
||||
}, [setSessionId, selectedModules]);
|
||||
}, [setSessionId, selectedModules, sessionId]);
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [exam, user]);
|
||||
@@ -258,6 +276,10 @@ export default function ExamPage({page}: Props) {
|
||||
|
||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||
setModuleIndex(moduleIndex + 1);
|
||||
|
||||
setPartIndex(0);
|
||||
setExerciseIndex(-1);
|
||||
setQuestionIndex(0);
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
@@ -377,7 +399,9 @@ export default function ExamPage({page}: Props) {
|
||||
abandonPopupTitle="Leave Exercise"
|
||||
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
|
||||
abandonConfirmButtonText="Confirm"
|
||||
onAbandon={() => router.reload()}
|
||||
onAbandon={() => {
|
||||
reset();
|
||||
}}
|
||||
onCancel={() => setShowAbandonPopup(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ExamState {
|
||||
exam?: Exam;
|
||||
partIndex: number;
|
||||
exerciseIndex: number;
|
||||
questionIndex: number;
|
||||
}
|
||||
|
||||
export interface ExamFunctions {
|
||||
@@ -24,13 +25,14 @@ export interface ExamFunctions {
|
||||
setShowSolutions: (showSolutions: boolean) => void;
|
||||
setHasExamEnded: (hasExamEnded: boolean) => void;
|
||||
setSelectedModules: (modules: Module[]) => void;
|
||||
setAssignment: (assignment: Assignment) => void;
|
||||
setAssignment: (assignment?: Assignment) => void;
|
||||
setTimeSpent: (timeSpent: number) => void;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
setModuleIndex: (moduleIndex: number) => void;
|
||||
setExam: (exam?: Exam) => void;
|
||||
setPartIndex: (partIndex: number) => void;
|
||||
setExerciseIndex: (exerciseIndex: number) => void;
|
||||
setQuestionIndex: (questionIndex: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -46,7 +48,8 @@ export const initialState: ExamState = {
|
||||
exam: undefined,
|
||||
moduleIndex: 0,
|
||||
partIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
exerciseIndex: -1,
|
||||
questionIndex: 0,
|
||||
};
|
||||
|
||||
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||
@@ -57,13 +60,14 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
||||
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
||||
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
|
||||
setAssignment: (assignment: Assignment) => set(() => ({assignment})),
|
||||
setAssignment: (assignment?: Assignment) => set(() => ({assignment})),
|
||||
setTimeSpent: (timeSpent) => set(() => ({timeSpent})),
|
||||
setSessionId: (sessionId: string) => set(() => ({sessionId})),
|
||||
setExam: (exam?: Exam) => set(() => ({exam})),
|
||||
setModuleIndex: (moduleIndex: number) => set(() => ({moduleIndex})),
|
||||
setPartIndex: (partIndex: number) => set(() => ({partIndex})),
|
||||
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
|
||||
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
|
||||
|
||||
reset: () => set(() => initialState),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user