diff --git a/src/assets/styles/user.css b/src/assets/styles/user.css
index 8bbbd27..125a28d 100644
--- a/src/assets/styles/user.css
+++ b/src/assets/styles/user.css
@@ -42,7 +42,7 @@
height: 30px;
position: absolute;
top: calc(50% - 30px);
- left: calc(100% - 15px);
+ left: calc(100% - 12px);
padding: 0;
}
@@ -110,6 +110,18 @@
object-fit: cover;
}
+
+
+
+
+.user-main-layout{
+ height: calc(100vh - 58px);
+ overflow-y: auto;
+}
+
+
+
+
.exercise-history .nav.nav-pills .nav-item a,
.topic-page .nav.nav-pills .nav-item a,
.setting-page .nav.nav-pills .nav-item a{
@@ -228,6 +240,53 @@
color: #0090FF;
}
+
+
+.exercise-page .review-list .number-label{
+ color: #959EA9;
+ display: flex;
+}
+
+.exercise-page .review-list .number-label:not(.active):hover{
+ background-color: #00BC650c;
+}
+
+.exercise-page .review-list .number-label.correct.active{
+ color: #ffffff;
+ background-color: #00BC65;
+}
+
+.exercise-page .review-list .number-label.incorrect.active{
+ color: #ffffff;
+ background-color: #E9342D;
+}
+
+.exercise-page .review-list .number-label.correct:not(.active){
+ color: #00BC65;
+}
+
+.exercise-page .review-list .number-label.incorrect:not(.active){
+ color: #E9342D;
+}
+
+.exercise-page .correction-label{
+ width: 55%;
+ display: flex;
+ align-items: center;
+ color: #ffffff;
+ margin-bottom: 12px;
+ padding: 8px 16px;
+ border-radius: 8px;
+}
+
+.exercise-page .correction-label.correct{
+ background-color: #00BC65;
+}
+
+.exercise-page .correction-label.incorrect{
+ background-color: #E9342D;
+}
+
.exercise-page .options .form-check,
.exercise-page .options-tf .form-check,
.exercise-page .options-mp .form-check {
diff --git a/src/components/layout/user/UserLayout.jsx b/src/components/layout/user/UserLayout.jsx
index 0c3c953..0c07ba5 100644
--- a/src/components/layout/user/UserLayout.jsx
+++ b/src/components/layout/user/UserLayout.jsx
@@ -56,7 +56,7 @@ const UserLayout = ({ children }) => {
{/* */}
{getSideNav() ? : ""}
-
+
{children}
diff --git a/src/components/layout/user/UserNavbar.jsx b/src/components/layout/user/UserNavbar.jsx
index 84ed9d6..8c027e6 100644
--- a/src/components/layout/user/UserNavbar.jsx
+++ b/src/components/layout/user/UserNavbar.jsx
@@ -6,6 +6,8 @@ import logoW from '../../../assets/images/logo-w.png';
import logoutIllustration from '../../../assets/images/illustration/logout.png';
import useAuth from '../../../roles/guest/auth/hooks/useAuth';
+import { headerSection, headerTopic, headerLevel } from '../../../utils/Constant';
+
import Report from '../../../roles/user/report/views/Report';
import { unSlugify } from '../../../utils/Constant';
@@ -68,15 +70,22 @@ const UserNavbar = () => {
{hasTopicAndLevel ? (
- lastSegment === 'exercise' ? (
+ lastSegment === 'review' ? (
- {`${unSlugify(pathSegments[3])} : ${unSlugify(pathSegments[4])} `}
- - {unSlugify(pathSegments[5])}
+ {headerSection} : {headerTopic}
+ - {headerLevel}
):(
-
- {unSlugify(pathSegments[4])}
-
+ lastSegment === 'exercise' ? (
+
+ {`${unSlugify(pathSegments[3])} : ${unSlugify(pathSegments[4])} `}
+ - {unSlugify(pathSegments[5])}
+
+ ):(
+
+ {unSlugify(pathSegments[4])}
+
+ )
)
):('')}
diff --git a/src/roles/user/review/hooks/useReview.jsx b/src/roles/user/review/hooks/useReview.jsx
new file mode 100644
index 0000000..cfe850d
--- /dev/null
+++ b/src/roles/user/review/hooks/useReview.jsx
@@ -0,0 +1,66 @@
+import { useState, useEffect } from 'react';
+import exerciseService from '../services/reviewService';
+
+export const useReview = (stdLearning) => {
+ const [questions, setQuestions] = useState([]);
+ const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
+ const [answers, setAnswers] = useState({});
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = async () => {
+ try {
+ const response = await exerciseService.fetchReview(stdLearning);
+ setQuestions(response.payload.stdExercises);
+ } catch (error) {
+ console.error("something wrong : ", error);
+ setError(error);
+ }finally{
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, [stdLearning]);
+
+ const nextQuestion = () => {
+ if (currentQuestionIndex < questions.length - 1) {
+ setCurrentQuestionIndex(currentQuestionIndex + 1);
+ }
+ };
+
+ const prevQuestion = () => {
+ if (currentQuestionIndex > 0) {
+ setCurrentQuestionIndex(currentQuestionIndex - 1);
+ }
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (event) => {
+ if (event.key === 'ArrowLeft') {
+ prevQuestion();
+ } else if (event.key === 'ArrowRight') {
+ nextQuestion();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [prevQuestion, nextQuestion]);
+
+ return {
+ questions,
+ currentQuestion: questions[currentQuestionIndex],
+ currentQuestionIndex,
+ setCurrentQuestionIndex,
+ answers,
+ isLoading,
+ error,
+ nextQuestion,
+ prevQuestion,
+ };
+};
diff --git a/src/roles/user/review/services/reviewService.jsx b/src/roles/user/review/services/reviewService.jsx
new file mode 100644
index 0000000..e4bc055
--- /dev/null
+++ b/src/roles/user/review/services/reviewService.jsx
@@ -0,0 +1,81 @@
+import axiosInstance from '../../../../utils/axiosInstance';
+
+const getSoalNumber = (title) => {
+ const match = title.match(/\d+$/);
+ return match ? parseInt(match[0], 10) : 0;
+};
+
+const getLevelId = async (topicId, levelName) => {
+ try {
+ const response = await axiosInstance.get(`/level/topic/${topicId}`);
+ const filteredData = response.data.data.levels.filter(item => item.NAME_LEVEL === levelName);
+ return filteredData[0].ID_LEVEL;
+ } catch (error) {
+ return [];
+ }
+};
+
+const createStdLearning = async (data) => {
+ try {
+ const response = await axiosInstance.post(`/stdLearning`, data);
+ return response.data;
+ } catch (error) {
+ console.error('Error creating std_learning:', error);
+ throw error;
+ }
+};
+
+const fetchReview = async (stdLearning) => {
+ try {
+ const response = await axiosInstance.get(`/studentAnswers/${stdLearning}`);
+
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+};
+
+
+
+
+const sumbitAnswer = async (dataAnswer) => {
+ try {
+ const response = await axiosInstance.post(`/stdExercise`, dataAnswer);
+ return response.data;
+ } catch (error) {
+ console.error('Error submit exercise:', error);
+ throw error;
+ }
+};
+
+const checkStdLearning = async (level) => {
+ try {
+ const response = await axiosInstance.get(`/stdLearning/level/${level}`);
+ return response.data.payload.ID_STUDENT_LEARNING;
+ } catch (error) {
+ // console.error('Error checking std_learning:', error);
+ // throw error;
+ return null;
+ }
+};
+
+const getScore = async (stdLearning) => {
+ try {
+ const response = await axiosInstance.get(`/stdLearning/score/${stdLearning}`);
+ return response.data;
+ } catch (error) {
+ console.error('Error fetching result:', error);
+ throw error;
+ }
+};
+
+
+export default {
+ getLevelId,
+ checkStdLearning,
+ createStdLearning,
+ fetchReview,
+
+ sumbitAnswer,
+ getScore
+};
diff --git a/src/roles/user/review/views/Review.jsx b/src/roles/user/review/views/Review.jsx
new file mode 100644
index 0000000..a170323
--- /dev/null
+++ b/src/roles/user/review/views/Review.jsx
@@ -0,0 +1,148 @@
+import React from 'react';
+import { useParams } from 'react-router-dom';
+import { useReview } from '../hooks/useReview';
+import { Container, Row, Col, ListGroup, Button, OverlayTrigger, Popover } from 'react-bootstrap';
+import MultipleChoiceQuestion from './components/MultipleChoiceQuestion';
+import TrueFalseQuestion from './components/TrueFalseQuestion';
+import MatchingPairsQuestion from './components/MatchingPairsQuestion';
+import Skeleton from 'react-loading-skeleton';
+
+
+const Review = () => {
+ const { stdLearning } = useParams();
+ const {
+ questions,
+ currentQuestion,
+ currentQuestionIndex,
+ setCurrentQuestionIndex,
+ answers,
+ isLoading,
+ error,
+ nextQuestion,
+ prevQuestion,
+ } = useReview(stdLearning);
+
+ const renderQuestion = () => {
+ switch (currentQuestion.QUESTION_TYPE) {
+ case 'MCQ':
+ return (
+
+ );
+ case 'TFQ':
+ return (
+
+ );
+ case 'MPQ':
+ return (
+
+ );
+ default:
+ return Unknown question type.
;
+ }
+ };
+
+ const popover = (
+
+ Tips
+
+
+ - Click the left arrow key to go to the previous question
+ - Click the right arrow key to go to the next question
+
+
+
+ );
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) return Exercise questions not yet available
;
+
+ return (
+
+
+
+
+
+
Review
+
+
+
+
+
+ {questions.map((q, index) => (
+ setCurrentQuestionIndex(index)}
+ className={`border-0 rounded-3 number-label fw-bold ${q.IS_CORRECT === 1 ? 'correct' : 'incorrect'}`}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+ {index+1}
+
+ ))}
+
+
+
+
+
+
+
+
+
{`Questions ${currentQuestionIndex + 1} of ${questions.length}`}
+
+
+
+ {renderQuestion()}
+
+
+
+
+
+ );
+};
+
+export default Review;
+
+
+
diff --git a/src/roles/user/review/views/components/ExerciseMedia.jsx b/src/roles/user/review/views/components/ExerciseMedia.jsx
new file mode 100644
index 0000000..36d7399
--- /dev/null
+++ b/src/roles/user/review/views/components/ExerciseMedia.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { MEDIA_URL } from '../../../../../utils/Constant';
+
+const ExerciseMedia = ({ image, audio, video }) => {
+ const mediaPath = `${MEDIA_URL}/exercise`;
+
+ return (
+
+ {image !== null && (
+
+

+ {/*
{`${MEDIA_URL}/exercise/image/${image}`}
*/}
+
+ )}
+ {audio !== null && (
+
+ {/*
*/}
+
{`${mediaPath}/audio/${audio}`}
+
+ )}
+ {video !== null && (
+ //
+
+
{video}
+
+ )}
+
+ );
+};
+
+export default ExerciseMedia;
diff --git a/src/roles/user/review/views/components/MatchingPairsQuestion.jsx b/src/roles/user/review/views/components/MatchingPairsQuestion.jsx
new file mode 100644
index 0000000..135a168
--- /dev/null
+++ b/src/roles/user/review/views/components/MatchingPairsQuestion.jsx
@@ -0,0 +1,227 @@
+import React, { useState, useEffect } from 'react';
+
+import MediaViewer from './MediaViewer';
+import { MEDIA_URL } from '../../../../../utils/Constant';
+
+// const colors = ['#E9342D', '#FACC15', '#1FBC2F', '#0090FF', '#ED27D9'];
+// const colors = ['#0090FF', '#FC6454', '#46E59A', '#FBD025', '#E355D5'];
+const colors = ['#FC6454', '#FBD025', '#46E59A', '#0090FF','#E355D5'];
+
+const shuffleArray = (array) => {
+ return array
+ .map((value) => ({ value, sort: Math.random() }))
+ .sort((a, b) => a.sort - b.sort)
+ .map(({ value }) => value);
+};
+
+function arrayToString(arr) {
+ return arr.join(', ');
+}
+
+function stringToArray(str) {
+ return str.split(', ');
+}
+
+const MatchingPairsQuestion = ({ question, studentAnswer, index }) => {
+ const savedAnswer = studentAnswer !== null ? studentAnswer : null;
+ const [pairs, setPairs] = useState([]);
+ const [selectedLeft, setSelectedLeft] = useState(null);
+ const [selectedRight, setSelectedRight] = useState(null);
+ const [rightOptions, setRightOptions] = useState([]);
+ const [isComplete, setIsComplete] = useState(false);
+ const [isShuffled, setIsShuffled] = useState(false);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (!event.target.closest('.mp-choice')) {
+ setSelectedLeft(null);
+ setSelectedRight(null);
+ }
+ };
+
+ document.addEventListener('click', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('click', handleClickOutside);
+ };
+ }, []);
+
+ useEffect(() => {
+ const initialPairs = question.matchingPairs.map((pair) => ({
+ left: pair.LEFT_PAIR,
+ right: '',
+ color: colors[question.matchingPairs.indexOf(pair) % colors.length],
+ }));
+
+ if (savedAnswer !== null) {
+ const arrSavedAnswer = stringToArray(savedAnswer);
+ const updatedPairs = initialPairs.map((pair, index) => ({
+ ...pair,
+ right: arrSavedAnswer[index],
+ }));
+ setPairs(updatedPairs);
+ } else {
+ setPairs(initialPairs);
+ }
+ }, [question, savedAnswer]);
+
+ useEffect(() => {
+ setIsShuffled(true);
+ setRightOptions(shuffleArray(question.matchingPairs.map((pair) => pair.RIGHT_PAIR)));
+ }, [question]);
+
+ const handleLeftClick = (index) => {
+ setSelectedLeft(index);
+ const status = pairs.findIndex(item => item.right === rightOptions[selectedRight]);
+ if (selectedRight !== null) {
+ makePair(index, selectedRight, status);
+ }
+ };
+
+ const handleRightClick = (index) => {
+ setSelectedRight(index);
+ const status = pairs.findIndex(item => item.right === rightOptions[index]);
+ if (selectedLeft !== null) {
+ makePair(selectedLeft, index, status);
+ }
+ };
+
+ const makePair = (leftIndex, rightIndex, changePair) => {
+ const newPairs = [...pairs];
+ if (changePair > -1) {
+ setIsComplete(false);
+ pairs[changePair].right = '';
+ }
+
+ const selectedRightValue = rightOptions[rightIndex];
+ newPairs[leftIndex].right = selectedRightValue;
+ setPairs(newPairs);
+ // console.log(newPairs);
+
+ setSelectedLeft(null);
+ setSelectedRight(null);
+
+ const allPairsMatched = newPairs.every((pair) => pair.right !== '');
+ if (allPairsMatched && !isComplete) {
+ setIsComplete(true);
+
+ const rightAnswers = newPairs.map((pair) => pair.right);
+ onAnswer(index, arrayToString(rightAnswers), question.ID_ADMIN_EXERCISE);
+ }
+ };
+
+ const mediaUrls = [];
+ const mediaPath = `${MEDIA_URL}/exercise`;
+
+ if (question.IMAGE) mediaUrls.push(`${mediaPath}/image/${question.IMAGE}`);
+ if (question.AUDIO) mediaUrls.push(`${mediaPath}/audio/${question.AUDIO}`);
+ if (question.VIDEO) mediaUrls.push(question.VIDEO);
+
+ return (
+
+
+
+
+ Your matching pair’s answers are {question.RESULT_SCORE_STUDENT}/{question.SCORE_WEIGHT} correct.
+
+
+ {mediaUrls.length > 0 &&
}
+
+
{question.QUESTION.split('\n').map((line, index) => (
+
+ {line}
+
+
+ ))}
+
+
+ {/* Bagian kiri */}
+
+ {pairs.map((pair, index) => (
+
+
+ {index + 1}
+
+
+ {pair.left}
+
+
+ ))}
+
+
+ {/* Bagian kanan */}
+
+ {rightOptions.map((right, index) => (
+
+ pair.right === right) ? '#ffffff' : '#000000',
+ backgroundColor:
+ pairs.find((pair) => pair.right === right)?.color ||
+ (selectedRight === index ? '#ccc' : '#ffffff'),
+ border:
+ pairs.find((pair) => pair.right === right)?.color ||
+ (selectedRight === index ? '2px dashed #ffffff' : '1px solid #000000'),
+ }}
+ >
+ {index + 1}
+
+ pair.right === right) ? '#ffffff' : '#000000'),
+ backgroundColor:
+ pairs.find((pair) => pair.right === right)?.color ||
+ (selectedRight === index ? '#ccc' : '#ffffff'),
+ border:
+ pairs.find((pair) => pair.right === right)?.color ||
+ (selectedRight === index ? '2px dashed #ffffff' : '1px solid #000000'),
+ }}
+ >
+ {right}
+
+
+ ))}
+
+
+
+ );
+};
+
+export default MatchingPairsQuestion;
diff --git a/src/roles/user/review/views/components/MediaViewer.jsx b/src/roles/user/review/views/components/MediaViewer.jsx
new file mode 100644
index 0000000..869c9dd
--- /dev/null
+++ b/src/roles/user/review/views/components/MediaViewer.jsx
@@ -0,0 +1,122 @@
+import React, { useState, useEffect } from 'react';
+import Skeleton from 'react-loading-skeleton';
+
+const MediaViewer = ({ mediaUrls }) => {
+ const [loadedMedia, setLoadedMedia] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ function isMediaUrl(url) {
+ const urls = url[0];
+ const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'];
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp'];
+
+ const lowercasedUrl = urls.toLowerCase();
+ const isAudio = audioExtensions.some(extension => lowercasedUrl.endsWith(extension));
+ const isImage = imageExtensions.some(extension => lowercasedUrl.endsWith(extension));
+
+ return isAudio || isImage;
+ }
+
+ const preloadMedia = (urls) => {
+ return Promise.all(
+ urls.map((url) => {
+ return new Promise((resolve, reject) => {
+ const mediaType = url.split('.').pop();
+
+ if (['jpg', 'jpeg', 'png', 'gif'].includes(mediaType)) {
+ const img = new Image();
+ img.src = url;
+ img.onload = () => resolve({ type: 'image', url });
+ img.onerror = () => reject(`Failed to load image: ${url}`);
+ } else if (['mp3', 'wav'].includes(mediaType)) {
+ const audio = new Audio();
+ audio.src = url;
+ audio.onloadedmetadata = () => resolve({ type: 'audio', url });
+ audio.onerror = () => reject(`Failed to load audio: ${url}`);
+ } else {
+ resolve({ type: 'unknown', url });
+ }
+ });
+ })
+ );
+ };
+
+ const renderVideo = (url) => {
+ // Cek apakah ini link YouTube
+ if (url.includes('youtube.com') || url.includes('youtu.be')) {
+ const youtubeId = url.includes('youtube.com')
+ ? new URLSearchParams(new URL(url).search).get('v') // Ambil ID dari link YouTube
+ : url.split('/').pop(); // Ambil ID dari youtu.be link
+
+ return (
+
+ );
+ } else if (url.includes('drive.google.com')) {
+ const driveId = url.includes('file/d/') ? url.split('file/d/')[1].split('/')[0] : '';
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ };
+
+ useEffect(() => {
+ if (mediaUrls && mediaUrls.length > 0 && isMediaUrl(mediaUrls)) {
+ preloadMedia(mediaUrls)
+ .then((loaded) => {
+ setLoadedMedia(loaded);
+ setLoading(false);
+ })
+ .catch((error) => {
+ console.error(error);
+ setLoading(false);
+ });
+ } else {
+ setLoading(false);
+ }
+ }, [mediaUrls]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {isMediaUrl(mediaUrls) ? (
+ loadedMedia.map((media, index) => {
+ if (media.type === 'image') {
+ return

;
+ } else if (media.type === 'audio') {
+ return
;
+ } else {
+ return
Unknown media type
;
+ }
+ })
+ ) : (
+ renderVideo(mediaUrls[0]) // Panggil fungsi renderVideo dengan URL yang benar
+ )}
+
+ );
+};
+
+export default MediaViewer;
diff --git a/src/roles/user/review/views/components/MultipleChoiceQuestion.jsx b/src/roles/user/review/views/components/MultipleChoiceQuestion.jsx
new file mode 100644
index 0000000..77f2ffa
--- /dev/null
+++ b/src/roles/user/review/views/components/MultipleChoiceQuestion.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import MediaViewer from './MediaViewer';
+import { MEDIA_URL } from '../../../../../utils/Constant';
+
+const MultipleChoiceQuestion = ({ question, studentAnswer, index }) => {
+ const savedAnswer = studentAnswer !== null ? studentAnswer : null;
+ const options = question.multipleChoices[0];
+
+ const mediaUrls = [];
+ const mediaPath = `${MEDIA_URL}/exercise`;
+
+ if (question.IMAGE) mediaUrls.push(`${mediaPath}/image/${question.IMAGE}`);
+ if (question.AUDIO) mediaUrls.push(`${mediaPath}/audio/${question.AUDIO}`);
+ if (question.VIDEO) mediaUrls.push(question.VIDEO);
+
+ function getOptionLetter(option) {
+ const match = option.match(/OPTION_(\w)/);
+ return match ? match[1] : null;
+ }
+
+ return (
+
+
+
+
+ Your answer is {question.IS_CORRECT === 1 ? `correct` : `incorrect` }.
+
+
+ {mediaUrls.length > 0 &&
}
+
+
{question.QUESTION.split('\n').map((line, index) => (
+
+ {line}
+
+
+ ))}
+
+
+ {['OPTION_A', 'OPTION_B', 'OPTION_C', 'OPTION_D', 'OPTION_E'].map(
+ (optionKey) => {
+ if (options[optionKey]) {
+ return (
+
+ {getOptionLetter(optionKey)}
+ {options[optionKey]}
+
+ );
+ }
+ return null;
+ }
+ )}
+
+
+ );
+};
+
+export default MultipleChoiceQuestion;
diff --git a/src/roles/user/review/views/components/TrueFalseQuestion.jsx b/src/roles/user/review/views/components/TrueFalseQuestion.jsx
new file mode 100644
index 0000000..45dc299
--- /dev/null
+++ b/src/roles/user/review/views/components/TrueFalseQuestion.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import MediaViewer from './MediaViewer';
+import { MEDIA_URL } from '../../../../../utils/Constant';
+
+const TrueFalseQuestion = ({ question, studentAnswer, index }) => {
+ const savedAnswer = studentAnswer !== null ? studentAnswer : null;
+ const mediaUrls = [];
+ const mediaPath = `${MEDIA_URL}/exercise`;
+
+ if (question.IMAGE) mediaUrls.push(`${mediaPath}/image/${question.IMAGE}`);
+ if (question.AUDIO) mediaUrls.push(`${mediaPath}/audio/${question.AUDIO}`);
+ if (question.VIDEO) mediaUrls.push(question.VIDEO);
+
+ return (
+
+
+
+
+ Your answer is {question.IS_CORRECT === 1 ? `correct` : `incorrect` }.
+
+
+ {mediaUrls.length > 0 &&
}
+
+
{question.QUESTION.split('\n').map((line, index) => (
+
+ {line}
+
+
+ ))}
+
+
+
+
+ A
+ TRUE
+
+
+
+ B
+ FALSE
+
+
+
+
+ );
+};
+
+export default TrueFalseQuestion;