add user review
This commit is contained in:
parent
d2bb26e644
commit
b661043755
|
|
@ -42,7 +42,7 @@
|
||||||
height: 30px;
|
height: 30px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(50% - 30px);
|
top: calc(50% - 30px);
|
||||||
left: calc(100% - 15px);
|
left: calc(100% - 12px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +110,18 @@
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.user-main-layout{
|
||||||
|
height: calc(100vh - 58px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.exercise-history .nav.nav-pills .nav-item a,
|
.exercise-history .nav.nav-pills .nav-item a,
|
||||||
.topic-page .nav.nav-pills .nav-item a,
|
.topic-page .nav.nav-pills .nav-item a,
|
||||||
.setting-page .nav.nav-pills .nav-item a{
|
.setting-page .nav.nav-pills .nav-item a{
|
||||||
|
|
@ -228,6 +240,53 @@
|
||||||
color: #0090FF;
|
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 .form-check,
|
||||||
.exercise-page .options-tf .form-check,
|
.exercise-page .options-tf .form-check,
|
||||||
.exercise-page .options-mp .form-check {
|
.exercise-page .options-mp .form-check {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ const UserLayout = ({ children }) => {
|
||||||
<div className="row min-h-100">
|
<div className="row min-h-100">
|
||||||
{/* <SideNav /> */}
|
{/* <SideNav /> */}
|
||||||
{getSideNav() ? <SideNav /> : ""}
|
{getSideNav() ? <SideNav /> : ""}
|
||||||
<main className="col p-4 overflow-auto bg-light">
|
<main className="col p-4 overflow-auto bg-light user-main-layout">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import logoW from '../../../assets/images/logo-w.png';
|
||||||
import logoutIllustration from '../../../assets/images/illustration/logout.png';
|
import logoutIllustration from '../../../assets/images/illustration/logout.png';
|
||||||
import useAuth from '../../../roles/guest/auth/hooks/useAuth';
|
import useAuth from '../../../roles/guest/auth/hooks/useAuth';
|
||||||
|
|
||||||
|
import { headerSection, headerTopic, headerLevel } from '../../../utils/Constant';
|
||||||
|
|
||||||
import Report from '../../../roles/user/report/views/Report';
|
import Report from '../../../roles/user/report/views/Report';
|
||||||
|
|
||||||
import { unSlugify } from '../../../utils/Constant';
|
import { unSlugify } from '../../../utils/Constant';
|
||||||
|
|
@ -68,6 +70,12 @@ const UserNavbar = () => {
|
||||||
</Button>
|
</Button>
|
||||||
<div className="d-none d-md-block navbar-title col-md-6 col-lg-8">
|
<div className="d-none d-md-block navbar-title col-md-6 col-lg-8">
|
||||||
{hasTopicAndLevel ? (
|
{hasTopicAndLevel ? (
|
||||||
|
lastSegment === 'review' ? (
|
||||||
|
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
||||||
|
{headerSection} : {headerTopic}
|
||||||
|
<span className='text-dark'> - {headerLevel}</span>
|
||||||
|
</h5>
|
||||||
|
):(
|
||||||
lastSegment === 'exercise' ? (
|
lastSegment === 'exercise' ? (
|
||||||
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
||||||
{`${unSlugify(pathSegments[3])} : ${unSlugify(pathSegments[4])} `}
|
{`${unSlugify(pathSegments[3])} : ${unSlugify(pathSegments[4])} `}
|
||||||
|
|
@ -78,6 +86,7 @@ const UserNavbar = () => {
|
||||||
{unSlugify(pathSegments[4])}
|
{unSlugify(pathSegments[4])}
|
||||||
</h5>
|
</h5>
|
||||||
)
|
)
|
||||||
|
)
|
||||||
):('')}
|
):('')}
|
||||||
</div>
|
</div>
|
||||||
<Navbar.Collapse id="navbar-nav" className='col-md-3 col-lg-2 d-none d-md-flex items-center'>
|
<Navbar.Collapse id="navbar-nav" className='col-md-3 col-lg-2 d-none d-md-flex items-center'>
|
||||||
|
|
|
||||||
66
src/roles/user/review/hooks/useReview.jsx
Normal file
66
src/roles/user/review/hooks/useReview.jsx
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
81
src/roles/user/review/services/reviewService.jsx
Normal file
81
src/roles/user/review/services/reviewService.jsx
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
148
src/roles/user/review/views/Review.jsx
Normal file
148
src/roles/user/review/views/Review.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<MultipleChoiceQuestion
|
||||||
|
question={currentQuestion}
|
||||||
|
studentAnswer={currentQuestion.ANSWER_STUDENT}
|
||||||
|
index={currentQuestionIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'TFQ':
|
||||||
|
return (
|
||||||
|
<TrueFalseQuestion
|
||||||
|
question={currentQuestion}
|
||||||
|
studentAnswer={currentQuestion.ANSWER_STUDENT}
|
||||||
|
index={currentQuestionIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'MPQ':
|
||||||
|
return (
|
||||||
|
<MatchingPairsQuestion
|
||||||
|
question={currentQuestion}
|
||||||
|
studentAnswer={currentQuestion.ANSWER_STUDENT}
|
||||||
|
index={currentQuestionIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <p>Unknown question type.</p>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const popover = (
|
||||||
|
<Popover id="popover-basic">
|
||||||
|
<Popover.Header as="h4">Tips</Popover.Header>
|
||||||
|
<Popover.Body className='p-2'>
|
||||||
|
<ul className='ps-3 m-0'>
|
||||||
|
<li>Click the <strong>left arrow</strong> key to go to the previous question</li>
|
||||||
|
<li>Click the <strong>right arrow</strong> key to go to the next question</li>
|
||||||
|
</ul>
|
||||||
|
</Popover.Body>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-2">
|
||||||
|
<Skeleton containerClassName='w-100' className='w-100 mb-1 rounded-3' count={6} style={{height:"4vh"}} />
|
||||||
|
</div>
|
||||||
|
<div className="col-10">
|
||||||
|
<Skeleton containerClassName='w-100' className='w-100 mb-1 rounded-3' style={{height:"5vh"}} />
|
||||||
|
<Skeleton containerClassName='w-100' className='w-50 mb-1 rounded-3' style={{height:"20vh"}} />
|
||||||
|
<Skeleton containerClassName='w-100' className='w-100 mb-1 rounded-3' style={{height:"30vh"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <h1 className='text-center'>Exercise questions not yet available</h1>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container fluid className='exercise-page'>
|
||||||
|
<Row>
|
||||||
|
<Col sm={2}>
|
||||||
|
<div className='p-3 rounded-4 bg-white'>
|
||||||
|
<div className="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h4 className='mb-0 text-gd fw-bold'>Review</h4>
|
||||||
|
<OverlayTrigger trigger="click" placement="right" overlay={popover}>
|
||||||
|
<i className=" bi bi-info-circle cursor-pointer text-secondary"></i>
|
||||||
|
</OverlayTrigger>
|
||||||
|
</div>
|
||||||
|
<ListGroup variant="flush" className='review-list'>
|
||||||
|
{questions.map((q, index) => (
|
||||||
|
<ListGroup.Item
|
||||||
|
key={q.ID_ADMIN_EXERCISE}
|
||||||
|
active={index === currentQuestionIndex}
|
||||||
|
onClick={() => setCurrentQuestionIndex(index)}
|
||||||
|
className={`border-0 rounded-3 number-label fw-bold ${q.IS_CORRECT === 1 ? 'correct' : 'incorrect'}`}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<i className={`me-2 bi bi-circle ${answers[index] !== null ? 'd-none' : 'd-block'}`}></i>
|
||||||
|
<i className={`me-2 bi bi-check2-circle ${answers[index] !== null ? 'd-block' : 'd-none'}`}></i>
|
||||||
|
{index+1}
|
||||||
|
</ListGroup.Item>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col sm={10}>
|
||||||
|
<div className='p-4 rounded-4 bg-white'>
|
||||||
|
<div className="pb-4 d-flex justify-content-between align-items-center">
|
||||||
|
<Button
|
||||||
|
variant='outline-blue'
|
||||||
|
className={`rounded-35 ${currentQuestionIndex === 0 ? 'invisible' : 'visible'}`}
|
||||||
|
onClick={prevQuestion}
|
||||||
|
disabled={currentQuestionIndex === 0}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-left"></i>
|
||||||
|
</Button>
|
||||||
|
<h5 className='m-0'>{`Questions ${currentQuestionIndex + 1} of ${questions.length}`}</h5>
|
||||||
|
<Button
|
||||||
|
variant="blue"
|
||||||
|
className={`rounded-35 ${currentQuestionIndex === questions.length - 1 ? 'd-none' : ''}`}
|
||||||
|
onClick={nextQuestion}
|
||||||
|
disabled={currentQuestionIndex === questions.length - 1}
|
||||||
|
>
|
||||||
|
Next Questions <i className="bi bi-arrow-right"></i>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='p-3 border rounded-3'>
|
||||||
|
{renderQuestion()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Review;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
34
src/roles/user/review/views/components/ExerciseMedia.jsx
Normal file
34
src/roles/user/review/views/components/ExerciseMedia.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
{image !== null && (
|
||||||
|
<div className='my-1'>
|
||||||
|
<img src={`${mediaPath}/image/${image}`} alt="" />
|
||||||
|
{/* <h3>{`${MEDIA_URL}/exercise/image/${image}`}</h3> */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{audio !== null && (
|
||||||
|
<div className='my-1'>
|
||||||
|
{/* <audio controls>
|
||||||
|
<source src={audio} type="audio/mpeg" />
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio> */}
|
||||||
|
<h3>{`${mediaPath}/audio/${audio}`}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{video !== null && (
|
||||||
|
// <video src={video} controls className='my-1'></video>
|
||||||
|
<div className="my-1">
|
||||||
|
<h3>{video}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExerciseMedia;
|
||||||
227
src/roles/user/review/views/components/MatchingPairsQuestion.jsx
Normal file
227
src/roles/user/review/views/components/MatchingPairsQuestion.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div className={`correction-label ${question.IS_CORRECT === 1 ? `correct` : `incorrect` }`}>
|
||||||
|
<i className={`me-2 bi ${question.IS_CORRECT === 1 ? `bi-check-circle-fill` : `bi-x-circle-fill` }`}></i>
|
||||||
|
Your matching pair’s answers are {question.RESULT_SCORE_STUDENT}/{question.SCORE_WEIGHT} correct.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mediaUrls.length > 0 && <MediaViewer mediaUrls={mediaUrls} />}
|
||||||
|
|
||||||
|
<p>{question.QUESTION.split('\n').map((line, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</React.Fragment>
|
||||||
|
))}</p>
|
||||||
|
|
||||||
|
<div className="w-100 options-mp d-flex justify-content-between">
|
||||||
|
{/* Bagian kiri */}
|
||||||
|
<div>
|
||||||
|
{pairs.map((pair, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`p-0 mb-3 form-check mp-choice`}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="option-label"
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: pair.color,
|
||||||
|
border: selectedLeft === index ? '2px dashed #ffffff' : '2px solid #ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ms-2 option-text"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
selectedLeft === index
|
||||||
|
? "#ffffff"
|
||||||
|
: (pair.right === '' ? '#000000' : "#ffffff"),
|
||||||
|
backgroundColor:
|
||||||
|
selectedLeft === index
|
||||||
|
? pair.color
|
||||||
|
: (pair.right === '' ? '#ffffff' : pair.color),
|
||||||
|
border:
|
||||||
|
selectedLeft === index
|
||||||
|
? '2px dashed #ffffff'
|
||||||
|
: (pair.right === '' ? '1px solid #000000' : '2px solid #ffffff'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pair.left}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bagian kanan */}
|
||||||
|
<div>
|
||||||
|
{rightOptions.map((right, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`p-0 mb-3 form-check mp-choice`}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="option-label"
|
||||||
|
style={{
|
||||||
|
color: pairs.find((pair) => 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}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ms-2 option-text"
|
||||||
|
style={{
|
||||||
|
color: (pairs.find((pair) => 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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchingPairsQuestion;
|
||||||
122
src/roles/user/review/views/components/MediaViewer.jsx
Normal file
122
src/roles/user/review/views/components/MediaViewer.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<iframe
|
||||||
|
src={`https://www.youtube.com/embed/${youtubeId}`}
|
||||||
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
title="YouTube Video"
|
||||||
|
className='video-box'
|
||||||
|
></iframe>
|
||||||
|
);
|
||||||
|
} else if (url.includes('drive.google.com')) {
|
||||||
|
const driveId = url.includes('file/d/') ? url.split('file/d/')[1].split('/')[0] : '';
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={`https://drive.google.com/file/d/${driveId}/preview`}
|
||||||
|
allow="autoplay"
|
||||||
|
title="Google Drive Video"
|
||||||
|
className='video-box'
|
||||||
|
></iframe>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<video controls className='video-box'>
|
||||||
|
<source src={url} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Skeleton containerClassName='w-50 d-block' className='w-100 mb-1 rounded-3' style={{height:"20vh"}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='my-1'>
|
||||||
|
{isMediaUrl(mediaUrls) ? (
|
||||||
|
loadedMedia.map((media, index) => {
|
||||||
|
if (media.type === 'image') {
|
||||||
|
return <img key={index} src={media.url} alt={`Media ${index}`} />;
|
||||||
|
} else if (media.type === 'audio') {
|
||||||
|
return <audio key={index} controls src={media.url}></audio>;
|
||||||
|
} else {
|
||||||
|
return <div key={index}>Unknown media type</div>;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
renderVideo(mediaUrls[0]) // Panggil fungsi renderVideo dengan URL yang benar
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MediaViewer;
|
||||||
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div className={`correction-label ${question.IS_CORRECT === 1 ? `correct` : `incorrect` }`}>
|
||||||
|
<i className={`me-2 bi ${question.IS_CORRECT === 1 ? `bi-check-circle-fill` : `bi-x-circle-fill` }`}></i>
|
||||||
|
Your answer is {question.IS_CORRECT === 1 ? `correct` : `incorrect` }.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mediaUrls.length > 0 && <MediaViewer mediaUrls={mediaUrls} />}
|
||||||
|
|
||||||
|
<p>{question.QUESTION.split('\n').map((line, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</React.Fragment>
|
||||||
|
))}</p>
|
||||||
|
|
||||||
|
<div className="options">
|
||||||
|
{['OPTION_A', 'OPTION_B', 'OPTION_C', 'OPTION_D', 'OPTION_E'].map(
|
||||||
|
(optionKey) => {
|
||||||
|
if (options[optionKey]) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={optionKey}
|
||||||
|
className={`p-0 mb-3 form-check ${savedAnswer === getOptionLetter(optionKey) ? 'selected-answer' : ''}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center'}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{getOptionLetter(optionKey)}</span>
|
||||||
|
<span className="ms-2 option-text">{options[optionKey]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultipleChoiceQuestion;
|
||||||
54
src/roles/user/review/views/components/TrueFalseQuestion.jsx
Normal file
54
src/roles/user/review/views/components/TrueFalseQuestion.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div className={`correction-label ${question.IS_CORRECT === 1 ? `correct` : `incorrect` }`}>
|
||||||
|
<i className={`me-2 bi ${question.IS_CORRECT === 1 ? `bi-check-circle-fill` : `bi-x-circle-fill` }`}></i>
|
||||||
|
Your answer is {question.IS_CORRECT === 1 ? `correct` : `incorrect` }.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mediaUrls.length > 0 && <MediaViewer mediaUrls={mediaUrls} />}
|
||||||
|
|
||||||
|
<p>{question.QUESTION.split('\n').map((line, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</React.Fragment>
|
||||||
|
))}</p>
|
||||||
|
|
||||||
|
<div className="options-tf">
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`p-0 mb-3 form-check ${savedAnswer === '1' ? 'selected-answer' : ''}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center'}}
|
||||||
|
>
|
||||||
|
<span className="option-label">A</span>
|
||||||
|
<span className="ms-2 option-text">TRUE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`p-0 mb-3 form-check ${savedAnswer === '0' ? 'selected-answer' : ''}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center'}}
|
||||||
|
>
|
||||||
|
<span className="option-label">B</span>
|
||||||
|
<span className="ms-2 option-text">FALSE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrueFalseQuestion;
|
||||||
Loading…
Reference in New Issue
Block a user