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 {`Media; + } 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;