feat: implement quiz logic and improve design

This commit is contained in:
swve 2023-09-14 21:49:17 +02:00
parent f93fd96bb5
commit 8d29c5cddd
6 changed files with 164 additions and 38 deletions

View file

@ -0,0 +1,19 @@
import { getBackendUrl } from "@services/config/config";
import { getActivityMediaDirectory } from "@services/media/media";
import React from "react";
function DocumentPdfActivity({ activity, course }: { activity: any; course: any }) {
return (
<div className="m-8 bg-zinc-900 rounded-md mt-14">
<iframe
className="rounded-lg w-full h-[900px]"
src={getActivityMediaDirectory(activity.org_id, activity.course_id, activity.activity_id, activity.content.documentpdf.filename, 'documentpdf')}
/>
</div>
);
}
export default DocumentPdfActivity;

View file

@ -0,0 +1,130 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { styled } from "styled-components";
import Youtube from "@tiptap/extension-youtube";
// Custom Extensions
import InfoCallout from "@components/Objects/Editor/Extensions/Callout/Info/InfoCallout";
import WarningCallout from "@components/Objects/Editor/Extensions/Callout/Warning/WarningCallout";
import ImageBlock from "@components/Objects/Editor/Extensions/Image/ImageBlock";
import VideoBlock from "@components/Objects/Editor/Extensions/Video/VideoBlock";
import MathEquationBlock from "@components/Objects/Editor/Extensions/MathEquation/MathEquationBlock";
import PDFBlock from "@components/Objects/Editor/Extensions/PDF/PDFBlock";
import { OrderedList } from "@tiptap/extension-ordered-list";
import QuizBlock from "@components/Objects/Editor/Extensions/Quiz/QuizBlock";
interface Editor {
content: string;
activity: any;
//course: any;
}
function Canva(props: Editor) {
const isEditable = false;
const editor: any = useEditor({
editable: isEditable,
extensions: [
StarterKit,
// Custom Extensions
InfoCallout.configure({
editable: isEditable,
}),
WarningCallout.configure({
editable: isEditable,
}),
ImageBlock.configure({
editable: isEditable,
activity: props.activity,
}),
VideoBlock.configure({
editable: true,
activity: props.activity,
}),
MathEquationBlock.configure({
editable: false,
activity: props.activity,
}),
PDFBlock.configure({
editable: true,
activity: props.activity,
}),
QuizBlock.configure({
editable: isEditable,
activity: props.activity,
}),
Youtube.configure({
controls: true,
modestBranding: true,
}),
OrderedList.configure()
],
content: props.content,
});
return (
<CanvaWrapper>
<EditorContent editor={editor} />
</CanvaWrapper>
);
}
const CanvaWrapper = styled.div`
width: 100%;
margin: 0 auto;
// disable chrome outline
.ProseMirror {
h1 {
font-size: 30px;
font-weight: 600;
margin-bottom: 10px;
}
h2 {
font-size: 25px;
font-weight: 600;
margin-bottom: 10px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 10px;
}
h4 {
font-size: 18px;
font-weight: 600;
margin-top: 10px;
margin-bottom: 10px;
}
h5 {
font-size: 16px;
font-weight: 600;
margin-top: 10px;
margin-bottom: 10px;
}
ul, ol {
padding: 0 1rem;
padding-left: 20px;
list-style-type: decimal;
}
&:focus {
outline: none !important;
outline-style: none !important;
box-shadow: none !important;
}
}
`;
export default Canva;

View file

@ -0,0 +1,73 @@
import { getBackendUrl } from "@services/config/config";
import React from "react";
import styled from "styled-components";
import YouTube from 'react-youtube';
import { getActivityMediaDirectory } from "@services/media/media";
function VideoActivity({ activity, course }: { activity: any; course: any }) {
const [videoId, setVideoId] = React.useState('');
const [videoType, setVideoType] = React.useState('');
function getYouTubeEmbed(url: any) {
// Extract video ID from the YouTube URL
var videoId = url.match(/(?:\?v=|\/embed\/|\/\d\/|\/vi\/|\/v\/|https?:\/\/(?:www\.)?youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))([^#\&\?\/]+)/)[1];
// Create the embed object
var embedObject = {
videoId: videoId,
width: 560,
height: 315
};
return embedObject;
}
React.useEffect(() => {
if (activity.content.video) {
setVideoType('video');
}
if (activity.content.external_video) {
setVideoType('external_video');
setVideoId(getYouTubeEmbed(activity.content.external_video.uri).videoId);
}
}, [activity]);
return (
<div>
{videoType === 'video' && (
<div className="m-8 bg-zinc-900 rounded-md mt-14">
<video className="rounded-lg w-full h-[500px]" controls
src={getActivityMediaDirectory(activity.org_id, activity.course_id, activity.activity_id, activity.content.video.filename, 'video')}
></video>
</div>
)}
{videoType === 'external_video' && (
<div>
<YouTube
className="rounded-md overflow-hidden m-8 bg-zinc-900 mt-14"
opts={
{
width: '1300',
height: '500',
playerVars: {
autoplay: 0,
},
}
}
videoId={videoId} />
</div>
)}
</div>
);
}
export default VideoActivity;

View file

@ -1,7 +1,9 @@
import { NodeViewWrapper } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
import { twJoin, twMerge } from 'tailwind-merge'
import React from "react";
import { BadgeHelp, Check, Minus, MoreVertical, Plus, X } from "lucide-react";
import { BadgeHelp, Check, Info, Minus, MoreVertical, Plus, RefreshCcw, X } from "lucide-react";
import ReactConfetti from "react-confetti";
interface Answer {
answer_id: string;
@ -17,11 +19,80 @@ interface Question {
function QuizBlockComponent(props: any) {
const [questions, setQuestions] = React.useState(props.node.attrs.questions) as [Question[], any];
const [userAnswers, setUserAnswers] = React.useState([]) as [any[], any];
const [submitted, setSubmitted] = React.useState(false) as [boolean, any];
const [submissionMessage, setSubmissionMessage] = React.useState("") as [string, any];
const isEditable = props.extension.options.editable;
const getAlphabetFromIndex = (index: number) => {
const handleAnswerClick = (question_id: string, answer_id: string) => {
// if the quiz is submitted, do nothing
if (submitted) {
return;
}
const userAnswer = {
question_id: question_id,
answer_id: answer_id
}
const newAnswers = [...userAnswers, userAnswer];
// only accept one answer per question
const filteredAnswers = newAnswers.filter((answer: any) => answer.question_id !== question_id);
setUserAnswers([...filteredAnswers, userAnswer]);
}
const refreshUserSubmission = () => {
setUserAnswers([]);
setSubmitted(false);
}
const handleUserSubmission = () => {
if (userAnswers.length === 0) {
setSubmissionMessage("Please answer at least one question!");
return;
}
setSubmitted(true);
// check if all submitted answers are correct
const correctAnswers = questions.map((question: Question) => {
const correctAnswer: any = question.answers.find((answer: Answer) => answer.correct);
const userAnswer = userAnswers.find((userAnswer: any) => userAnswer.question_id === question.question_id);
if (correctAnswer.answer_id === userAnswer.answer_id) {
return true;
} else {
return false;
}
});
// check if all answers are correct
const allCorrect = correctAnswers.every((answer: boolean) => answer === true);
if (allCorrect) {
setSubmissionMessage("All answers are correct!");
console.log("All answers are correct!");
}
else {
setSubmissionMessage("Some answers are incorrect!");
console.log("Some answers are incorrect!");
}
}
const getAnswerID = (answerIndex: number, questionId : string) => {
const alphabet = Array.from({ length: 26 }, (_, i) => String.fromCharCode('A'.charCodeAt(0) + i));
return alphabet[index];
let alphabetID = alphabet[answerIndex];
// Get question index
const questionIndex = questions.findIndex((question: Question) => question.question_id === questionId);
let questionID = questionIndex + 1;
return `${alphabetID}`;
}
const saveQuestions = (questions: any) => {
@ -121,6 +192,7 @@ function QuizBlockComponent(props: any) {
} else {
answer.correct = false;
}
return answer;
});
}
@ -132,18 +204,38 @@ function QuizBlockComponent(props: any) {
return (
<NodeViewWrapper className="block-quiz">
<div
//style={{ background: "radial-gradient(152.15% 150.08% at 56.45% -6.67%, rgba(180, 255, 250, 0.10) 5.53%, rgba(202, 201, 255, 0.10) 66.76%)" }}
className="rounded-xl px-5 py-2 bg-slate-100 transition-all ease-linear"
>
<div className="flex space-x-2 pt-1 items-center text-sm">
<div className="grow flex space-x-2 items-center text-sm">
<div className="flex space-x-2 pt-1 items-center text-sm overflow-hidden">
{(submitted && submissionMessage == "All answers are correct!") &&
<ReactConfetti
numberOfPieces={submitted ? 1400 : 0}
recycle={false}
className="w-full h-screen"
/>
}
<div className="flex space-x-2 items-center text-sm">
<BadgeHelp className='text-slate-400' size={15} />
<p className="uppercase tracking-widest text-xs font-bold py-1 text-slate-400">Quiz</p>
</div>
<div>
<button onClick={addSampleQuestion} className="bg-slate-200 hover:bg-slate-300 text-slate-800 font-bold py-1 px-2 rounded-lg text-xs">Add Question</button>
<div className="grow flex items-center justify-center">
</div>
{isEditable ?
<div>
<button onClick={addSampleQuestion} className="bg-slate-200 hover:bg-slate-300 text-slate-800 font-bold py-1 px-2 rounded-lg text-xs">Add Question</button>
</div>
:
<div className="flex space-x-1 items-center">
<div onClick={() => refreshUserSubmission()} className="cursor-pointer px-2">
<RefreshCcw className='text-slate-400 cursor-pointer' size={15} />
</div>
<button onClick={() => handleUserSubmission()} className="bg-slate-200 hover:bg-slate-300 text-slate-800 font-bold py-1 px-2 rounded-lg text-xs">Submit</button>
</div>
}
</div>
{questions.map((question: Question) => (
@ -151,44 +243,71 @@ function QuizBlockComponent(props: any) {
<div className="question">
<div className="flex space-x-2 items-center">
<div className="flex-grow">
<input value={question.question} placeholder="Your Question" onChange={(e) => changeQuestionValue(question.question_id, e.target.value)} className="text-slate-800 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-md font-bold w-full"></input>
{isEditable ?
<input value={question.question} placeholder="Your Question" onChange={(e) => changeQuestionValue(question.question_id, e.target.value)} className="text-slate-800 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-md font-bold w-full"></input>
:
<p className="text-slate-800 bg-[#00008b00] rounded-md text-md font-bold w-full">{question.question}</p>
}
</div>
<div className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-red-600 hover:bg-red-700 text-sm transition-all ease-linear cursor-pointer">
<Minus
{isEditable &&
<div
onClick={() => deleteQuestion(question.question_id)}
className="mx-auto text-red-200" size={12} />
</div>
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer">
<Minus
className="mx-auto text-slate-400" size={12} />
</div>
}
</div>
<div className="answers flex py-2 space-x-3">
{question.answers.map((answer: Answer) => (
<div key={answer.answer_id}
className={`outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] ${answer.correct ? 'outline-green-200' : 'outline-white'} bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear`}>
<div className="bg-white font-bold text-neutral-900 text-base flex items-center h-full w-[40px] rounded-md">
<p className="mx-auto">{getAlphabetFromIndex(question.answers.indexOf(answer))}</p>
<div
key={answer.answer_id}
className={twMerge(
'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear',
answer.correct && isEditable ? 'outline-lime-200' : 'outline-white',
userAnswers.find((userAnswer: any) => (userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) && !isEditable) ? 'outline-slate-300' : '',
(submitted && answer.correct) ? 'outline-lime-300 text-lime' : '',
(submitted && !answer.correct) && userAnswers.find((userAnswer: any) => userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) ? 'outline-red-400' : '',
)
}
onClick={() => handleAnswerClick(question.question_id, answer.answer_id)}
>
<div className={twMerge(
"bg-white font-bold text-base flex items-center h-full w-[40px] rounded-l-md text-slate-800",
(submitted && answer.correct) ? 'bg-lime-300 text-lime-800 ' : '',
(submitted && !answer.correct) && userAnswers.find((userAnswer: any) => userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) ? 'bg-red-400 text-red-800 outline-none' : '',
)}>
<p className="mx-auto font-bold text-sm ">{getAnswerID(question.answers.indexOf(answer),question.question_id)}</p>
</div>
<input value={answer.answer} onChange={(e) => changeAnswerValue(question.question_id, answer.answer_id, e.target.value)} placeholder="Answer" className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-sm font-bold"></input>
<div className="flex space-x-1 items-center">
<div
onClick={() => markAnswerCorrect(question.question_id, answer.answer_id)}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-green-200 hover:bg-green-300 transition-all ease-linear text-sm cursor-pointer ">
<Check
className="mx-auto text-green-800" size={12} />
{isEditable ?
<input value={answer.answer} onChange={(e) => changeAnswerValue(question.question_id, answer.answer_id, e.target.value)} placeholder="Answer" className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-sm font-bold"></input>
:
<p className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] rounded-md ext-sm font-bold">{answer.answer}</p>
}
{isEditable &&
<div className="flex space-x-1 items-center">
<div
onClick={() => markAnswerCorrect(question.question_id, answer.answer_id)}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-lime-200 hover:bg-lime-300 transition-all ease-linear text-sm cursor-pointer ">
<Check
className="mx-auto text-lime-800" size={12} />
</div>
<div
onClick={() => deleteAnswer(question.question_id, answer.answer_id)}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer">
<Minus
className="mx-auto text-slate-400" size={12} />
</div>
</div>
<MoreVertical className="text-slate-300" size={15} />
<div
onClick={() => deleteAnswer(question.question_id, answer.answer_id)}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-red-600 hover:bg-red-700 text-sm transition-all ease-linear cursor-pointer">
<Minus
className="mx-auto text-red-200" size={12} />
</div>
</div>
}
</div>
))}
<div onClick={() => addAnswer(question.question_id)} className="outline outline-3 shadow w-[30px] flex-none flex items-center h-[30px] outline-white hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear">
<Plus className="mx-auto text-slate-800" size={15} />
</div>
{isEditable &&
<div onClick={() => addAnswer(question.question_id)} className="outline outline-3 w-[30px] flex-none flex items-center h-[30px] outline-white hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear">
<Plus className="mx-auto text-slate-800" size={15} />
</div>
}
</div>
</div>
</div>