add user review
This commit is contained in:
parent
d2bb26e644
commit
b661043755
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ const UserLayout = ({ children }) => {
|
|||
<div className="row min-h-100">
|
||||
{/* <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}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
</Button>
|
||||
<div className="d-none d-md-block navbar-title col-md-6 col-lg-8">
|
||||
{hasTopicAndLevel ? (
|
||||
lastSegment === 'exercise' ? (
|
||||
lastSegment === 'review' ? (
|
||||
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
||||
{`${unSlugify(pathSegments[3])} : ${unSlugify(pathSegments[4])} `}
|
||||
<span className='text-dark'> - {unSlugify(pathSegments[5])}</span>
|
||||
{headerSection} : {headerTopic}
|
||||
<span className='text-dark'> - {headerLevel}</span>
|
||||
</h5>
|
||||
):(
|
||||
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
||||
{unSlugify(pathSegments[4])}
|
||||
</h5>
|
||||
lastSegment === 'exercise' ? (
|
||||
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
||||
{`${unSlugify(pathSegments[3])} : ${unSlugify(pathSegments[4])} `}
|
||||
<span className='text-dark'> - {unSlugify(pathSegments[5])}</span>
|
||||
</h5>
|
||||
):(
|
||||
<h5 className={`text-center m-0 ${bgBlue() ? 'text-white' : 'text-blue'}`}>
|
||||
{unSlugify(pathSegments[4])}
|
||||
</h5>
|
||||
)
|
||||
)
|
||||
):('')}
|
||||
</div>
|
||||
|
|
|
|||
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