Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical

This commit is contained in:
Joao Ramos
2024-08-20 11:12:05 +01:00
9 changed files with 2700 additions and 2511 deletions

View File

@@ -25,8 +25,8 @@
"@react-spring/web": "^9.7.4", "@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1", "@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0", "@types/node": "18.13.0",
"@types/react": "18.0.27", "@types/react": "^18.3.3",
"@types/react-dom": "18.0.10", "@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1.3.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",

View File

@@ -1,10 +1,10 @@
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { moduleLabels } from "@/utils/moduleUtils"; import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import { motion } from "framer-motion"; import {motion} from "framer-motion";
import { ReactNode, useEffect, useState } from "react"; import {ReactNode, useEffect, useState} from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar"; import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal"; import TimerEndedModal from "../TimerEndedModal";
@@ -18,15 +18,13 @@ interface Props {
partLabel?: string; partLabel?: string;
} }
export default function ModuleTitle({ export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel}: Props) {
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel
}: Props) {
const [timer, setTimer] = useState(minTimer * 60); const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false); const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded); const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const { timeSpent } = useExamStore((state) => state); const {timeSpent} = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]); useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
@@ -48,7 +46,7 @@ export default function ModuleTitle({
if (timer < 300 && !warningMode) setWarningMode(true); if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]); }, [timer, warningMode]);
const moduleIcon: { [key in Module]: ReactNode } = { const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />, listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />, writing: <BsPen className="text-ielts-writing w-6 h-6" />,
@@ -70,9 +68,9 @@ export default function ModuleTitle({
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy", "absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt", warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)} )}
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }} initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
animate={{ scale: warningMode && !disableTimer ? 1.1 : 1 }} animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
transition={{ repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut" }}> transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
<BsStopwatch className="w-6 h-6" /> <BsStopwatch className="w-6 h-6" />
<span className="text-base font-semibold w-12"> <span className="text-base font-semibold w-12">
{timer > 0 && ( {timer > 0 && (
@@ -90,11 +88,25 @@ export default function ModuleTitle({
</span> </span>
</motion.div> </motion.div>
<div className="w-full"> <div className="w-full">
{partLabel && <div className="text-3xl space-y-4">{partLabel.split('\n\n').map((line, index) => { {partLabel && (
if(index == 0) return <p className="font-bold">{line}</p> <div className="text-3xl space-y-4">
else return <p className="text-2xl font-semibold">{line}</p> {partLabel.split("\n\n").map((line, index) => {
})}</div>} if (index == 0)
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5" )}> return (
<p key={index} className="font-bold">
{line}
</p>
);
else
return (
<p key={index} className="text-2xl font-semibold">
{line}
</p>
);
})}
</div>
)}
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div> <div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">

View File

@@ -49,15 +49,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && ( {userSolution && !isUserSolutionCorrect() && (
<div <div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light" className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
contentEditable={disabled}> contentEditable={disabled}>
{userSolution} {userSolution}
</div> </div>
)} )}
<div <div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")} {!solutions ? userInput : solutions.join(" / ")}
</div> </div>
</span> </span>

View File

@@ -1,287 +1,280 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, {useState, useEffect, useRef, useCallback} from "react";
import { animated } from '@react-spring/web'; import {animated} from "@react-spring/web";
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6"; import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
import HighlightContent from '../HighlightContent'; import HighlightContent from "../HighlightContent";
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces'; import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => { const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false); const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0); const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>(''); const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]); const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]); const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null); const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]); const segmentsRef = useRef<SegmentRef[]>([]);
const toggleAutoPlay = useCallback(() => { const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => { setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) { if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0); setCurrentTime(0);
} }
return !prev; return !prev;
}); });
}, [currentTime]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime]);
const handleAnimationComplete = useCallback(() => { const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false); setIsAutoPlaying(false);
}, []); }, []);
const handleResetAnimation = useCallback((newTime: number) => { const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime); setCurrentTime(newTime);
}, []); }, []);
const getMaxTime = (): number => { const getMaxTime = (): number => {
return tip.exercise?.segments.reduce((sum, segment) => return (
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0 tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
) ?? 0; );
}; };
useEffect(() => { useEffect(() => {
const timeline: TimelineEvent[] = []; const timeline: TimelineEvent[] = [];
let currentTimePosition = 0; let currentTimePosition = 0;
segmentsRef.current = []; segmentsRef.current = [];
tip.exercise?.segments.forEach((segment, index) => { tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html'); const doc = parser.parseFromString(segment.html, "text/html");
const words: string[] = []; const words: string[] = [];
const walkTree = (node: Node) => { const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || [])); words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree); Array.from(node.childNodes).forEach(walkTree);
} }
}; };
walkTree(doc.body); walkTree(doc.body);
const textDuration = words.length * segment.wordDelay; const textDuration = words.length * segment.wordDelay;
segmentsRef.current.push({ segmentsRef.current.push({
...segment, ...segment,
words: words, words: words,
startTime: currentTimePosition, startTime: currentTimePosition,
endTime: currentTimePosition + textDuration endTime: currentTimePosition + textDuration,
}); });
timeline.push({ timeline.push({
type: 'text', type: "text",
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + textDuration, end: currentTimePosition + textDuration,
segmentIndex: index segmentIndex: index,
}); });
currentTimePosition += textDuration; currentTimePosition += textDuration;
timeline.push({ timeline.push({
type: 'highlight', type: "highlight",
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + segment.holdDelay, end: currentTimePosition + segment.holdDelay,
content: segment.highlight, content: segment.highlight,
segmentIndex: index segmentIndex: index,
}); });
currentTimePosition += segment.holdDelay; currentTimePosition += segment.holdDelay;
}); });
timelineRef.current = timeline; timelineRef.current = timeline;
}, [tip.exercise?.segments]); }, [tip.exercise?.segments]);
const updateText = useCallback(() => { const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find( const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
event => currentTime >= event.start && currentTime < event.end
);
if (currentEvent) { if (currentEvent) {
if (currentEvent.type === 'text') { if (currentEvent.type === "text") {
const segment = segmentsRef.current[currentEvent.segmentIndex]; const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start; const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length); const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex) .slice(0, currentEvent.segmentIndex)
.map(seg => seg.html) .map((seg) => seg.html)
.join(''); .join("");
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html'); const doc = parser.parseFromString(segment.html, "text/html");
let wordCount = 0; let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => { const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) { if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0); const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) { if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true)); action(node.cloneNode(true));
wordCount += words.filter(w => !/\s+/.test(w)).length; wordCount += words.filter((w) => !/\s+/.test(w)).length;
} else { } else {
const remainingWords = wordsToShow - wordCount; const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce((acc, word) => { const newTextContent = words.reduce(
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) { (acc, word) => {
acc.text += word; if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.nonSpaceWords++; acc.text += word;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) { acc.nonSpaceWords++;
acc.text += word; } else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
} acc.text += word;
return acc; }
}, { text: '', nonSpaceWords: 0 }).text; return acc;
const newNode = node.cloneNode(false); },
newNode.textContent = newTextContent; {text: "", nonSpaceWords: 0},
action(newNode); ).text;
wordCount = wordsToShow; const newNode = node.cloneNode(false);
} newNode.textContent = newTextContent;
} else if (node.nodeType === Node.ELEMENT_NODE) { action(newNode);
const clone = node.cloneNode(false); wordCount = wordsToShow;
action(clone); }
Array.from(node.childNodes).some(child => { } else if (node.nodeType === Node.ELEMENT_NODE) {
return walkTree(child, childNode => (clone as Node).appendChild(childNode)); const clone = node.cloneNode(false);
}); action(clone);
} Array.from(node.childNodes).some((child) => {
return wordCount >= wordsToShow; return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
}; });
const fragment = document.createDocumentFragment(); }
walkTree(doc.body, node => fragment.appendChild(node)); return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node));
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes) const currentSegmentHtml = Array.from(fragment.childNodes)
.map(node => serializer.serializeToString(node)) .map((node) => serializer.serializeToString(node))
.join(''); .join("");
const newHtml = previousSegmentsHtml + currentSegmentHtml; const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases([]); setHighlightedPhrases([]);
} else if (currentEvent.type === 'highlight') { } else if (currentEvent.type === "highlight") {
const newHtml = segmentsRef.current const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1) .slice(0, currentEvent.segmentIndex + 1)
.map(seg => seg.html) .map((seg) => seg.html)
.join(''); .join("");
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []); setHighlightedPhrases(currentEvent.content || []);
} }
} }
}, [currentTime]); }, [currentTime]);
useEffect(() => { useEffect(() => {
updateText(); updateText();
}, [currentTime, updateText]); }, [currentTime, updateText]);
useEffect(() => { useEffect(() => {
if (isAutoPlaying) { if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1]; const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) { if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0); setCurrentTime(0);
} }
setIsPlaying(true); setIsPlaying(true);
} else { } else {
setIsPlaying(false); setIsPlaying(false);
} }
}, [isAutoPlaying, currentTime]); }, [isAutoPlaying, currentTime]);
useEffect(() => { useEffect(() => {
const animate = () => { const animate = () => {
if (isPlaying) { if (isPlaying) {
setCurrentTime((prevTime) => { setCurrentTime((prevTime) => {
const newTime = prevTime + 50; const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1]; const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) { if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false); setIsPlaying(false);
handleAnimationComplete(); handleAnimationComplete();
return lastEvent.end; return lastEvent.end;
} }
return newTime; return newTime;
}); });
} }
animationRef.current = requestAnimationFrame(animate); animationRef.current = requestAnimationFrame(animate);
}; };
animationRef.current = requestAnimationFrame(animate); animationRef.current = requestAnimationFrame(animate);
return () => { return () => {
if (animationRef.current) { if (animationRef.current) {
cancelAnimationFrame(animationRef.current); cancelAnimationFrame(animationRef.current);
} }
}; };
}, [isPlaying, handleAnimationComplete]); }, [isPlaying, handleAnimationComplete]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10); const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime); setCurrentTime(newTime);
handleResetAnimation(newTime); handleResetAnimation(newTime);
}; };
const handleSliderMouseDown = () => { const handleSliderMouseDown = () => {
setIsPlaying(false); setIsPlaying(false);
}; };
const handleSliderMouseUp = () => { const handleSliderMouseUp = () => {
if (isAutoPlaying) { if (isAutoPlaying) {
setIsPlaying(true); setIsPlaying(true);
} }
}; };
if (tip.standalone || !tip.exercise) { if (tip.standalone || !tip.exercise) {
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1> <h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10"> <div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3> <h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} /> <div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div> </div>
</div> </div>
); );
} }
return (
return ( <div className="container mx-auto">
<div className="container mx-auto"> <div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4"> <h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3> <div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} /> </div>
</div> <div className="flex flex-col space-y-4">
<div className='flex flex-col space-y-4'> <div className="flex flex-row items-center space-x-4 py-4">
<div className='flex flex-row items-center space-x-4 py-4'> <button
<button onClick={toggleAutoPlay}
onClick={toggleAutoPlay} className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" aria-label={isAutoPlaying ? "Pause" : "Play"}>
aria-label={isAutoPlaying ? 'Pause' : 'Play'} {isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
> </button>
{isAutoPlaying ? ( <input
<FaRegCircleStop className="w-6 h-6" /> type="range"
) : ( min="0"
<FaRegCirclePlay className="w-6 h-6" /> max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
)} value={currentTime}
</button> onChange={handleSliderChange}
<input onMouseDown={handleSliderMouseDown}
type="range" onMouseUp={handleSliderMouseUp}
min="0" onTouchStart={handleSliderMouseDown}
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} onTouchEnd={handleSliderMouseUp}
value={currentTime} className="flex-grow"
onChange={handleSliderChange} />
onMouseDown={handleSliderMouseDown} </div>
onMouseUp={handleSliderMouseUp} <div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
onTouchStart={handleSliderMouseDown} <div className="flex-1 bg-white p-6 rounded-lg shadow">
onTouchEnd={handleSliderMouseUp} {/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
className='flex-grow' <div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
/> <HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div> </div>
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'> <div className="flex-1">
<div className='flex-1 bg-white p-6 rounded-lg shadow'> <div className="bg-gray-50 rounded-lg shadow">
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/} <div className="p-6 space-y-4">
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} /> <animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} /> </div>
</div> </div>
<div className='flex-1'> </div>
<div className='bg-gray-50 rounded-lg shadow'> </div>
<div className='p-6 space-y-4'> </div>
<animated.div </div>
dangerouslySetInnerHTML={{ __html: walkthroughHtml }} );
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}; };
export default ExerciseWalkthrough; export default ExerciseWalkthrough;

View File

@@ -1,22 +1,32 @@
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import { renderExercise } from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import HighlightContent from "@/components/HighlightContent"; import HighlightContent from "@/components/HighlightContent";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import { infoButtonStyle } from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { Exercise, FillBlanksExercise, FillBlanksMCOption, LevelExam, LevelPart, MultipleChoiceExercise, ShuffleMap, UserSolution, WritingExam } from "@/interfaces/exam"; import {
Exercise,
FillBlanksExercise,
FillBlanksMCOption,
LevelExam,
LevelPart,
MultipleChoiceExercise,
ShuffleMap,
UserSolution,
WritingExam,
} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { defaultUserSolutions } from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import { countExercises } from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
import { mdiArrowRight } from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
import { Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState } from "react"; import {Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState} from "react";
import { BsChevronDown, BsChevronUp } from "react-icons/bs"; import {BsChevronDown, BsChevronUp} from "react-icons/bs";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import { v4 } from "uuid"; import {v4} from "uuid";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;
@@ -26,9 +36,13 @@ interface Props {
} }
function TextComponent({ function TextComponent({
part, contextWord, setContextWordLine part,
contextWord,
setContextWordLine,
}: { }: {
part: LevelPart, contextWord: string | undefined, setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>> part: LevelPart;
contextWord: string | undefined;
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>;
}) { }) {
const textRef = useRef<HTMLDivElement>(null); const textRef = useRef<HTMLDivElement>(null);
const [lineNumbers, setLineNumbers] = useState<number[]>([]); const [lineNumbers, setLineNumbers] = useState<number[]>([]);
@@ -42,21 +56,21 @@ function TextComponent({
const containerWidth = textRef.current.clientWidth; const containerWidth = textRef.current.clientWidth;
setLineHeight(lineHeightValue); setLineHeight(lineHeightValue);
const offscreenElement = document.createElement('div'); const offscreenElement = document.createElement("div");
offscreenElement.style.position = 'absolute'; offscreenElement.style.position = "absolute";
offscreenElement.style.top = '-9999px'; offscreenElement.style.top = "-9999px";
offscreenElement.style.left = '-9999px'; offscreenElement.style.left = "-9999px";
offscreenElement.style.whiteSpace = 'pre-wrap'; offscreenElement.style.whiteSpace = "pre-wrap";
offscreenElement.style.width = `${containerWidth}px`; offscreenElement.style.width = `${containerWidth}px`;
offscreenElement.style.font = computedStyle.font; offscreenElement.style.font = computedStyle.font;
offscreenElement.style.lineHeight = computedStyle.lineHeight; offscreenElement.style.lineHeight = computedStyle.lineHeight;
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
const textContent = textRef.current.textContent || ''; const textContent = textRef.current.textContent || "";
textContent.split(/(\s+)/).forEach((word: string) => { textContent.split(/(\s+)/).forEach((word: string) => {
const span = document.createElement('span'); const span = document.createElement("span");
span.textContent = word; span.textContent = word;
span.style.display = 'inline-block'; span.style.display = "inline-block";
span.style.height = `calc(1em + 16px)`; span.style.height = `calc(1em + 16px)`;
offscreenElement.appendChild(span); offscreenElement.appendChild(span);
}); });
@@ -73,9 +87,9 @@ function TextComponent({
currentLineTop = firstChild.getBoundingClientRect().top; currentLineTop = firstChild.getBoundingClientRect().top;
} }
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span'); const spans = offscreenElement.querySelectorAll<HTMLSpanElement>("span");
spans.forEach(span => { spans.forEach((span) => {
const rect = span.getBoundingClientRect(); const rect = span.getBoundingClientRect();
const top = rect.top; const top = rect.top;
@@ -85,8 +99,7 @@ function TextComponent({
lines.push([]); lines.push([]);
} }
lines[lines.length - 1].push(span.textContent?.trim() || ''); lines[lines.length - 1].push(span.textContent?.trim() || "");
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
contextWordLine = currentLine; contextWordLine = currentLine;
@@ -115,9 +128,11 @@ function TextComponent({
return () => { return () => {
if (textRef.current) { if (textRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
resizeObserver.unobserve(textRef.current); resizeObserver.unobserve(textRef.current);
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [part.context, part.showContextLines, contextWord]); }, [part.context, part.showContextLines, contextWord]);
if (typeof part.showContextLines === "undefined") { if (typeof part.showContextLines === "undefined") {
@@ -142,8 +157,13 @@ function TextComponent({
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<div className="flex mt-2"> <div className="flex mt-2">
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4"> <div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
{part.context!.split('\n\n').map((line, index) => { {part.context!.split("\n\n").map((line, index) => {
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p> return (
<p key={`line-${index}`}>
<span className="mr-6">{index + 1}</span>
{line}
</p>
);
})} })}
</div> </div>
</div> </div>
@@ -151,22 +171,18 @@ function TextComponent({
); );
} }
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every( return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
word => word && typeof word === 'object' && 'id' in word && 'options' in word };
);
}
export default function Level({exam, showSolutions = false, onFinish, editing = false}: Props) {
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) { const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const { userSolutions, setUserSolutions } = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const { partIndex, setPartIndex } = useExamStore((state) => state); const {partIndex, setPartIndex} = useExamStore((state) => state);
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
//const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) //const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
const [currentExercise, setCurrentExercise] = useState<Exercise>(); const [currentExercise, setCurrentExercise] = useState<Exercise>();
@@ -197,7 +213,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
onFinish(userSolutions); onFinish(userSolutions);
}; };
const getExercise = () => { const getExercise = () => {
if (exerciseIndex === -1) { if (exerciseIndex === -1) {
return undefined; return undefined;
@@ -287,12 +302,10 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
}; };
useEffect(() => { useEffect(() => {
//console.log("Getting another exercise");
//setShuffleMaps([]);
setCurrentExercise(getExercise()); setCurrentExercise(getExercise());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]); }, [partIndex, exerciseIndex]);
useEffect(() => { useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (currentExercise && currentExercise.type === "multipleChoice") { if (currentExercise && currentExercise.type === "multipleChoice") {
@@ -307,7 +320,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace( const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
`in line ${originalLineNumber}`, `in line ${originalLineNumber}`,
`in line ${contextWordLine || originalLineNumber}` `in line ${contextWordLine || originalLineNumber}`,
); );
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt; currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
@@ -315,16 +328,20 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
setContextWord(undefined); setContextWord(undefined);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]); }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} }
if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); setMultipleChoicesDone((prev) => [
...prev.filter((x) => x.id !== currentExercise!.id),
{id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex},
]);
} }
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
@@ -355,11 +372,11 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
let stat = { ...solution, module: "level" as Module, exam: exam.id } let stat = {...solution, module: "level" as Module, exam: exam.id};
/*if (exam.shuffle) { /*if (exam.shuffle) {
stat.shuffleMaps = shuffleMaps stat.shuffleMaps = shuffleMaps
}*/ }*/
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]); onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...stat}]);
} else { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
@@ -368,11 +385,14 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} }
if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); setMultipleChoicesDone((prev) => [
...prev.filter((x) => x.id !== currentExercise!.id),
{id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex},
]);
} }
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
@@ -391,7 +411,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
exercisesDone + exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + (exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex + storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount}, 0) multipleChoicesDone.reduce((acc, curr) => {
return acc + curr.amount;
}, 0)
); );
}; };
@@ -404,21 +426,21 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
</h4> </h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span> <span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div> </div>
<TextComponent <TextComponent part={exam.parts[partIndex]} contextWord={contextWord} setContextWordLine={setContextWordLine} />
part={exam.parts[partIndex]}
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</> </>
</div> </div>
); );
const partLabel = () => { const partLabel = () => {
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}` return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${
currentExercise.words[currentExercise.words.length - 1].id
})\n\n${currentExercise.prompt}`;
if (currentExercise?.type === "multipleChoice") if (currentExercise?.type === "multipleChoice")
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}` return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${
} currentExercise.questions[currentExercise.questions.length - 1].id
})\n\n${currentExercise.prompt}`;
};
return ( return (
<> <>

View File

@@ -1,5 +1,5 @@
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {Tab} from "@headlessui/react"; import {Tab, TabGroup, TabList, TabPanel, TabPanels} from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import CodeList from "./CodeList"; import CodeList from "./CodeList";
import DiscountList from "./DiscountList"; import DiscountList from "./DiscountList";
@@ -14,8 +14,8 @@ export default function Lists({user}: {user: User}) {
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
return ( return (
<Tab.Group> <TabGroup>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1"> <TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
@@ -90,35 +90,35 @@ export default function Lists({user}: {user: User}) {
Discount List Discount List
</Tab> </Tab>
)} )}
</Tab.List> </TabList>
<Tab.Panels className="mt-2"> <TabPanels className="mt-2">
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} /> <UserList user={user} />
</Tab.Panel> </TabPanel>
{checkAccess(user, ["developer"]) && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} /> <ExamList user={user} />
</Tab.Panel> </TabPanel>
)} )}
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} /> <GroupList user={user} />
</Tab.Panel> </TabPanel>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} /> <CodeList user={user} />
</Tab.Panel> </TabPanel>
)} )}
{checkAccess(user, ["developer", "admin"]) && ( {checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} /> <PackageList user={user} />
</Tab.Panel> </TabPanel>
)} )}
{checkAccess(user, ["developer", "admin"]) && ( {checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} /> <DiscountList user={user} />
</Tab.Panel> </TabPanel>
)} )}
</Tab.Panels> </TabPanels>
</Tab.Group> </TabGroup>
); );
} }

View File

@@ -1,357 +1,371 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from "react";
import { useRouter } from 'next/router'; import {useRouter} from "next/router";
import axios from 'axios'; import axios from "axios";
import { Tab } from "@headlessui/react"; import {Tab} from "@headlessui/react";
import { AiOutlineFileSearch } from "react-icons/ai"; import {AiOutlineFileSearch} from "react-icons/ai";
import { MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement } from "react-icons/md"; import {MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement} from "react-icons/md";
import { BsChatLeftDots } from "react-icons/bs"; import {BsChatLeftDots} from "react-icons/bs";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import clsx from "clsx"; import clsx from "clsx";
import Exercise from "@/training/Exercise"; import Exercise from "@/training/Exercise";
import TrainingScore from "@/training/TrainingScore"; import TrainingScore from "@/training/TrainingScore";
import { ITrainingContent, ITrainingTip } from "@/training/TrainingInterfaces"; import {ITrainingContent, ITrainingTip} from "@/training/TrainingInterfaces";
import { Stat, User } from '@/interfaces/user'; import {Stat, User} from "@/interfaces/user";
import Head from "next/head"; import Head from "next/head";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import { ToastContainer } from 'react-toastify'; import {ToastContainer} from "react-toastify";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import qs from 'qs'; import qs from "qs";
import StatsGridItem from '@/components/StatGridItem'; import StatsGridItem from "@/components/StatGridItem";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { usePDFDownload } from "@/hooks/usePDFDownload"; import {usePDFDownload} from "@/hooks/usePDFDownload";
import useAssignments from '@/hooks/useAssignments'; import useAssignments from "@/hooks/useAssignments";
import useUsers from '@/hooks/useUsers'; import useUsers from "@/hooks/useUsers";
import Dropdown from "@/components/Dropdown"; import Dropdown from "@/components/Dropdown";
import InfiniteCarousel from '@/components/InfiniteCarousel'; import InfiniteCarousel from "@/components/InfiniteCarousel";
import { LuExternalLink } from "react-icons/lu"; import {LuExternalLink} from "react-icons/lu";
import { uniqBy } from 'lodash'; import {uniqBy} from "lodash";
import { getExamById } from '@/utils/exams'; import {getExamById} from "@/utils/exams";
import { convertToUserSolutions } from '@/utils/stats'; import {convertToUserSolutions} from "@/utils/stats";
import { sortByModule } from '@/utils/moduleUtils'; import {sortByModule} from "@/utils/moduleUtils";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if (shouldRedirectHome(user)) { if (shouldRedirectHome(user)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
permanent: false, permanent: false,
}, },
}; };
} }
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
const TrainingContent: React.FC<{ user: User }> = ({ user }) => { const TrainingContent: React.FC<{user: User}> = ({user}) => {
// Record stuff // Record stuff
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity); const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent); const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null); const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]); const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
const [currentTipIndex, setCurrentTipIndex] = useState(0); const [currentTipIndex, setCurrentTipIndex] = useState(0);
const { assignments } = useAssignments({}); const {assignments} = useAssignments({});
const { users } = useUsers(); const {users} = useUsers();
const router = useRouter(); const router = useRouter();
const { id } = router.query; const {id} = router.query;
useEffect(() => { useEffect(() => {
const fetchTrainingContent = async () => { const fetchTrainingContent = async () => {
if (!id || typeof id !== 'string') return; if (!id || typeof id !== "string") return;
try { try {
setLoading(true); setLoading(true);
const response = await axios.get<ITrainingContent>(`/api/training/${id}`); const response = await axios.get<ITrainingContent>(`/api/training/${id}`);
const trainingContent = response.data; const trainingContent = response.data;
const withExamsStats = { const withExamsStats = {
...trainingContent, ...trainingContent,
exams: await Promise.all(trainingContent.exams.map(async (exam) => { exams: await Promise.all(
const stats = await Promise.all(exam.stat_ids.map(async (statId) => { trainingContent.exams.map(async (exam) => {
const statResponse = await axios.get<Stat>(`/api/stats/${statId}`); const stats = await Promise.all(
return statResponse.data; exam.stat_ids.map(async (statId) => {
})); const statResponse = await axios.get<Stat>(`/api/stats/${statId}`);
return { ...exam, stats }; return statResponse.data;
})) }),
}; );
return {...exam, stats};
}),
),
};
const tips = await axios.get<ITrainingTip[]>('/api/training/walkthrough', { const tips = await axios.get<ITrainingTip[]>("/api/training/walkthrough", {
params: { ids: trainingContent.tip_ids }, params: {ids: trainingContent.tip_ids},
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' }) paramsSerializer: (params) => qs.stringify(params, {arrayFormat: "repeat"}),
}); });
setTrainingTips(tips.data); setTrainingTips(tips.data);
setTrainingContent(withExamsStats); setTrainingContent(withExamsStats);
} catch (error) { } catch (error) {
router.push('/training'); router.push("/training");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchTrainingContent(); fetchTrainingContent();
}, [id]); }, [id, router]);
const handleNext = () => { const handleNext = () => {
setCurrentTipIndex((prevIndex) => (prevIndex + 1)); setCurrentTipIndex((prevIndex) => prevIndex + 1);
}; };
const handlePrevious = () => { const handlePrevious = () => {
setCurrentTipIndex((prevIndex) => (prevIndex - 1)); setCurrentTipIndex((prevIndex) => prevIndex - 1);
}; };
const goToExam = (examNumber: number) => { const goToExam = (examNumber: number) => {
const stats = trainingContent?.exams[examNumber].stats!; const stats = trainingContent?.exams[examNumber].stats!;
const examPromises = uniqBy(stats, "exam").map((stat) => { const examPromises = uniqBy(stats, "exam").map((stat) => {
return getExamById(stat.module, stat.exam); return getExamById(stat.module, stat.exam);
}); });
const { timeSpent, inactivity } = stats[0]; const {timeSpent, inactivity} = stats[0];
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent); if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity); if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(stats)); setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true); setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule)); setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules( setSelectedModules(
exams exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
); );
router.push("/exercises"); router.push("/exercises");
} }
}); });
} };
return ( return (
<> <>
<Head> <Head>
<title>Training | EnCoach</title> <title>Training | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <Layout user={user}>
{loading ? ( {loading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
</div> </div>
) : (trainingContent && ( ) : (
<div className="flex flex-col gap-8"> trainingContent && (
<div className="flex flex-row items-center"> <div className="flex flex-col gap-8">
<span className="bg-gray-200 text-gray-800 px-3 py-0.5 rounded-full font-semibold text-lg mr-2">{trainingContent.exams.length}</span> <div className="flex flex-row items-center">
<span>Exams Selected</span> <span className="bg-gray-200 text-gray-800 px-3 py-0.5 rounded-full font-semibold text-lg mr-2">
</div> {trainingContent.exams.length}
<div className='h-[15vh] mb-4'> </span>
<InfiniteCarousel height="150px" <span>Exams Selected</span>
overlay={ </div>
<LuExternalLink size={20} /> <div className="h-[15vh] mb-4">
} <InfiniteCarousel
overlayFunc={goToExam} height="150px"
overlayClassName='bottom-6 right-5 cursor-pointer' overlay={<LuExternalLink size={20} />}
> overlayFunc={goToExam}
{trainingContent.exams.map((exam, examIndex) => ( overlayClassName="bottom-6 right-5 cursor-pointer">
<StatsGridItem {trainingContent.exams.map((exam, examIndex) => (
key={`exam-${examIndex}`} <StatsGridItem
width='380px' key={`exam-${examIndex}`}
height='150px' width="380px"
examNumber={examIndex + 1} height="150px"
stats={exam.stats || []} examNumber={examIndex + 1}
timestamp={exam.date} stats={exam.stats || []}
user={user} timestamp={exam.date}
assignments={assignments} user={user}
users={users} assignments={assignments}
setExams={setExams} users={users}
setShowSolutions={setShowSolutions} setExams={setExams}
setUserSolutions={setUserSolutions} setShowSolutions={setShowSolutions}
setSelectedModules={setSelectedModules} setUserSolutions={setUserSolutions}
setInactivity={setInactivity} setSelectedModules={setSelectedModules}
setTimeSpent={setTimeSpent} setInactivity={setInactivity}
renderPdfIcon={renderPdfIcon} setTimeSpent={setTimeSpent}
/> renderPdfIcon={renderPdfIcon}
))} />
</InfiniteCarousel> ))}
</div> </InfiniteCarousel>
<div className='flex flex-col'> </div>
<div className='flex flex-row gap-10 -md:flex-col h-full'> <div className="flex flex-col">
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full"> <div className="flex flex-row gap-10 -md:flex-col h-full">
<div className="flex flex-row items-center mb-6 gap-1"> <div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full">
<MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} /> <div className="flex flex-row items-center mb-6 gap-1">
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2> <MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} />
</div> <h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2>
<TrainingScore </div>
trainingContent={trainingContent} <TrainingScore trainingContent={trainingContent} gridView={false} />
gridView={false} <div className="w-full h-px bg-[#D9D9D929] my-6"></div>
/> <div className="flex flex-row gap-2 items-center mb-6">
<div className="w-full h-px bg-[#D9D9D929] my-6"></div> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<div className="flex flex-row gap-2 items-center mb-6"> <mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="24" height="24" fill="#D9D9D9" />
<mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> </mask>
<rect width="24" height="24" fill="#D9D9D9" /> <g mask="url(#mask0_112_168)">
</mask> <path
<g mask="url(#mask0_112_168)"> d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z"
<path d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z" fill="#53B2F9" /> fill="#53B2F9"
</g> />
</svg> </g>
<h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3> </svg>
</div> <h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3>
<ul className='overflow-auto scrollbar-hide flex-grow'> </div>
{trainingContent.exams.flatMap((exam, index) => ( <ul className="overflow-auto scrollbar-hide flex-grow">
<li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border"> {trainingContent.exams.flatMap((exam, index) => (
<div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2"> <li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border">
<div className='flex items-center border-r-2 border-[#D9D9D929] pr-2'> <div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2">
<span className='mr-1'>Exam</span> <div className="flex items-center border-r-2 border-[#D9D9D929] pr-2">
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">{index + 1}</span> <span className="mr-1">Exam</span>
</div> <span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">
<span className="pl-2">{exam.score}%</span> {index + 1}
</div> </span>
<div className="flex flex-row items-center gap-2"> </div>
<BsChatLeftDots size={16} /> <span className="pl-2">{exam.score}%</span>
<p className="text-sm">{exam.performance_comment}</p> </div>
</div> <div className="flex flex-row items-center gap-2">
</li> <BsChatLeftDots size={16} />
))} <p className="text-sm">{exam.performance_comment}</p>
</ul> </div>
</div> </li>
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full"> ))}
<div className='flex flex-col'> </ul>
<div className="flex flex-row items-center mb-4 gap-1"> </div>
<AiOutlineFileSearch color="#40A1EA" size={24} /> <div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3> <div className="flex flex-col">
</div> <div className="flex flex-row items-center mb-4 gap-1">
<Tab.Group> <AiOutlineFileSearch color="#40A1EA" size={24} />
<div className="flex flex-col gap-4"> <h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3>
<Tab.List> </div>
<div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar"> <Tab.Group>
{trainingContent.weak_areas.map((x, index) => ( <div className="flex flex-col gap-4">
<Tab <Tab.List>
key={index} <div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar">
className={({ selected }) => {trainingContent.weak_areas.map((x, index) => (
clsx( <Tab
'text-[#53B2F9] pb-2 border-b-2', key={index}
'focus:outline-none', className={({selected}) =>
selected ? 'border-[#1B78BE]' : 'border-[#1B78BE0F]' clsx(
) "text-[#53B2F9] pb-2 border-b-2",
} "focus:outline-none",
> selected ? "border-[#1B78BE]" : "border-[#1B78BE0F]",
{x.area} )
</Tab> }>
))} {x.area}
</div> </Tab>
</Tab.List> ))}
<Tab.Panels> </div>
{trainingContent.weak_areas.map((x, index) => ( </Tab.List>
<Tab.Panel <Tab.Panels>
key={index} {trainingContent.weak_areas.map((x, index) => (
className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]" <Tab.Panel key={index} className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]">
> <p>{x.comment}</p>
<p>{x.comment}</p> </Tab.Panel>
</Tab.Panel> ))}
))} </Tab.Panels>
</Tab.Panels> </div>
</div> </Tab.Group>
</Tab.Group> </div>
</div> <div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div> <div className="flex flex-row items-center mb-4 gap-1">
<div className="flex flex-row items-center mb-4 gap-1"> <MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} /> <h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2> </div>
</div>
<div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4"> <div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4">
<div className='flex flex-col'> <div className="flex flex-col">
<div className="flex flex-row items-center gap-1 mb-4"> <div className="flex flex-row items-center gap-1 mb-4">
<div className="flex items-center justify-center w-[48px] h-[48px]"> <div className="flex items-center justify-center w-[48px] h-[48px]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> width="24"
<rect width="24" height="24" fill="#D9D9D9" /> height="24"
</mask> viewBox="0 0 24 24"
<g mask="url(#mask0_112_445)"> fill="none"
<path d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z" fill="#1C1B1F" /> xmlns="http://www.w3.org/2000/svg">
</g> <mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
</svg> <rect width="24" height="24" fill="#D9D9D9" />
</div> </mask>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3> <g mask="url(#mask0_112_445)">
</div> <path
<ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide"> d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z"
{trainingContent.exams.map((exam, index) => ( fill="#1C1B1F"
<li key={index} className="border rounded-lg bg-white"> />
<Dropdown title={ </g>
<div className='flex flex-row items-center'> </svg>
<span className="mr-1">Exam</span> </div>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">{index + 1}</span> <h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div> </div>
} open={index == 0}> <ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide">
<span>{exam.detailed_summary}</span> {trainingContent.exams.map((exam, index) => (
</Dropdown> <li key={index} className="border rounded-lg bg-white">
</li> <Dropdown
))} title={
</ul> <div className="flex flex-row items-center">
</div> <span className="mr-1">Exam</span>
</div> <span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">
</div> {index + 1}
</div> </span>
</div> </div>
<div className="flex -md:hidden"> }
<div className="rounded-3xl p-6 shadow-training-inset w-full"> open={index == 0}>
<div className="flex flex-col p-10"> <span>{exam.detailed_summary}</span>
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} /> </Dropdown>
</div> </li>
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8"> ))}
<Button </ul>
color="purple" </div>
variant="outline" </div>
onClick={handlePrevious} </div>
disabled={currentTipIndex == 0} </div>
className="max-w-[200px] self-end w-full"> </div>
Previous <div className="flex -md:hidden">
</Button> <div className="rounded-3xl p-6 shadow-training-inset w-full">
<Button <div className="flex flex-col p-10">
color="purple" <Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
disabled={currentTipIndex == (trainingTips.length - 1)} </div>
onClick={handleNext} <div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
className="max-w-[200px] self-end w-full"> <Button
Next color="purple"
</Button> variant="outline"
</div> onClick={handlePrevious}
</div> disabled={currentTipIndex == 0}
className="max-w-[200px] self-end w-full">
</div> Previous
</div> </Button>
))} <Button
</Layout> color="purple"
</> disabled={currentTipIndex == trainingTips.length - 1}
); onClick={handleNext}
} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</div>
</div>
)
)}
</Layout>
</>
);
};
export default TrainingContent; export default TrainingContent;

View File

@@ -84,6 +84,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
return () => { return () => {
router.events.off("routeChangeStart", handleRouteChange); router.events.off("routeChangeStart", handleRouteChange);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events, setTrainingStats]); }, [router.events, setTrainingStats]);
useEffect(() => { useEffect(() => {
@@ -104,6 +105,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewContentLoading]); }, [isNewContentLoading]);
useEffect(() => { useEffect(() => {

3804
yarn.lock

File diff suppressed because it is too large Load Diff