diff --git a/front/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx b/front/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx index 85fbb22e..596dcf6f 100644 --- a/front/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx +++ b/front/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx @@ -1,11 +1,11 @@ "use client"; import Link from "next/link"; import { getUriWithOrg } from "@services/config/config"; -import Canva from "@components/Pages/Activities/DynamicCanva/DynamicCanva"; -import VideoActivity from "@components/Pages/Activities/Video/Video"; +import Canva from "@components/Objects/Activities/DynamicCanva/DynamicCanva"; +import VideoActivity from "@components/Objects/Activities/Video/Video"; import { Check } from "lucide-react"; import { markActivityAsComplete } from "@services/courses/activity"; -import DocumentPdfActivity from "@components/Pages/Activities/DocumentPdf/DocumentPdf"; +import DocumentPdfActivity from "@components/Objects/Activities/DocumentPdf/DocumentPdf"; import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators"; import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper"; import { useRouter } from "next/navigation"; diff --git a/front/components/Pages/Activities/DocumentPdf/DocumentPdf.tsx b/front/components/Objects/Activities/DocumentPdf/DocumentPdf.tsx similarity index 100% rename from front/components/Pages/Activities/DocumentPdf/DocumentPdf.tsx rename to front/components/Objects/Activities/DocumentPdf/DocumentPdf.tsx diff --git a/front/components/Pages/Activities/DynamicCanva/DynamicCanva.tsx b/front/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx similarity index 93% rename from front/components/Pages/Activities/DynamicCanva/DynamicCanva.tsx rename to front/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 69ebf363..4c945527 100644 --- a/front/components/Pages/Activities/DynamicCanva/DynamicCanva.tsx +++ b/front/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -10,6 +10,7 @@ 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; @@ -46,6 +47,10 @@ function Canva(props: Editor) { editable: true, activity: props.activity, }), + QuizBlock.configure({ + editable: isEditable, + activity: props.activity, + }), Youtube.configure({ controls: true, modestBranding: true, diff --git a/front/components/Pages/Activities/Video/Video.tsx b/front/components/Objects/Activities/Video/Video.tsx similarity index 100% rename from front/components/Pages/Activities/Video/Video.tsx rename to front/components/Objects/Activities/Video/Video.tsx diff --git a/front/components/Objects/Editor/Extensions/Quiz/QuizBlock.ts b/front/components/Objects/Editor/Extensions/Quiz/QuizBlock.ts index 36d7706a..b6af7d51 100644 --- a/front/components/Objects/Editor/Extensions/Quiz/QuizBlock.ts +++ b/front/components/Objects/Editor/Extensions/Quiz/QuizBlock.ts @@ -6,7 +6,6 @@ import QuizBlockComponent from "./QuizBlockComponent"; export default Node.create({ name: "blockQuiz", group: "block", - atom: true, addAttributes() { @@ -14,6 +13,9 @@ export default Node.create({ quizId: { value: null, }, + questions: { + default: [], + }, }; }, diff --git a/front/components/Objects/Editor/Extensions/Quiz/QuizBlockComponent.tsx b/front/components/Objects/Editor/Extensions/Quiz/QuizBlockComponent.tsx index 9b1f3ec2..c3e350f2 100644 --- a/front/components/Objects/Editor/Extensions/Quiz/QuizBlockComponent.tsx +++ b/front/components/Objects/Editor/Extensions/Quiz/QuizBlockComponent.tsx @@ -1,164 +1,323 @@ import { NodeViewWrapper } from "@tiptap/react"; import { v4 as uuidv4 } from "uuid"; +import { twJoin, twMerge } from 'tailwind-merge' import React from "react"; -import styled from "styled-components"; -import { submitQuizBlock } from "@services/blocks/Quiz/quiz"; +import { BadgeHelp, Check, Info, Minus, MoreVertical, Plus, RefreshCcw, X } from "lucide-react"; +import ReactConfetti from "react-confetti"; -function ImageBlockComponent(props: any) { - const [questions, setQuestions] = React.useState([]) as any; - const [answers, setAnswers] = React.useState([]) as any; +interface Answer { + answer_id: string; + answer: string; + correct: boolean; +} +interface Question { + question_id: string; + question: string; + type: "multiple_choice" | 'custom_answer' + answers: Answer[]; +} + +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 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]); - function addSampleQuestion() { - setQuestions([ - ...questions, - { - question_id: "question_" + uuidv4(), - question_value: "", - options: [ - { - option_id: "option_" + uuidv4(), - option_data: "", - option_type: "text", - }, - ], - }, - ]); } - const deleteQuestion = (index: number) => { - let modifiedQuestions = [...questions]; - modifiedQuestions.splice(index, 1); - setQuestions(modifiedQuestions); - + const refreshUserSubmission = () => { + setUserAnswers([]); + setSubmitted(false); + } - // remove the answers from the answers array - let modifiedAnswers = [...answers]; - modifiedAnswers = modifiedAnswers.filter((answer: any) => answer.question_id !== questions[index].question_id); - setAnswers(modifiedAnswers); - }; + const handleUserSubmission = () => { - const onQuestionChange = (e: any, index: number) => { - let modifiedQuestions = [...questions]; - modifiedQuestions[index].question_value = e.target.value; - setQuestions(modifiedQuestions); - }; + if (userAnswers.length === 0) { + setSubmissionMessage("Please answer at least one question!"); + return; + } - const addOption = (question_id: string) => { - // find the question index from the question_id and add the option to that question index - let modifiedQuestions = [...questions]; - let questionIndex = modifiedQuestions.findIndex((question: any) => question.question_id === question_id); - modifiedQuestions[questionIndex].options.push({ - option_id: "option_" + uuidv4(), - option_data: "", - option_type: "text", + 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; + } }); - setQuestions(modifiedQuestions); - }; - const deleteOption = (question_id: string, option_id: string) => { - // find the option index from the option_id and delete the option from that option index - let modifiedQuestions = [...questions]; - let questionIndex = modifiedQuestions.findIndex((question: any) => question.question_id === question_id); - let optionIndex = modifiedQuestions[questionIndex].options.findIndex((option: any) => option.option_id === option_id); - modifiedQuestions[questionIndex].options.splice(optionIndex, 1); - setQuestions(modifiedQuestions); + // check if all answers are correct + const allCorrect = correctAnswers.every((answer: boolean) => answer === true); - // remove the answer from the answers array - let answerIndex = answers.findIndex((answer: any) => answer.option_id === option_id); - if (answerIndex !== -1) { - let modifiedAnswers = [...answers]; - modifiedAnswers.splice(answerIndex, 1); - setAnswers(modifiedAnswers); + 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 markOptionAsCorrect = (question_id: string, option_id: string) => { - // find the option index from the option_id and mark the option as correct - let answer = { - question_id: question_id, - option_id: option_id, - }; - setAnswers([...answers, answer]); - - }; - const saveQuiz = async () => { - // save the questions and answers to the backend - - - - try { - let res = await submitQuizBlock(props.extension.options.activity.activity_id, {questions : questions , answers : answers}) - - props.updateAttributes({ - quizId: { - value : res.block_id + + } + + const getAnswerID = (answerIndex: number, questionId : string) => { + const alphabet = Array.from({ length: 26 }, (_, i) => String.fromCharCode('A'.charCodeAt(0) + i)); + 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) => { + props.updateAttributes({ + questions: questions, + }); + setQuestions(questions); + + }; + const addSampleQuestion = () => { + const newQuestion = { + question_id: uuidv4(), + question: "", + type: "multiple_choice", + answers: [ + { + answer_id: uuidv4(), + answer: "", + correct: false }, - }); - + ] } - catch (error) { - + setQuestions([...questions, newQuestion]); + } + + const addAnswer = (question_id: string) => { + const newAnswer = { + answer_id: uuidv4(), + answer: "", + correct: false } - - }; + // check if there is already more thqn 5 answers + const question: any = questions.find((question: Question) => question.question_id === question_id); + if (question.answers.length >= 5) { + return; + } - const onOptionChange = (e: any, questionIndex: number, optionIndex: number) => { - let modifiedQuestions = [...questions]; - modifiedQuestions[questionIndex].options[optionIndex].option_data = e.target.value; - setQuestions(modifiedQuestions); - }; - React.useEffect(() => { - // fetch the questions and options from the backend - - - - }, [questions, answers]); + + const newQuestions = questions.map((question: Question) => { + if (question.question_id === question_id) { + question.answers.push(newAnswer); + } + return question; + }); + + saveQuestions(newQuestions); + } + + const changeAnswerValue = (question_id: string, answer_id: string, value: string) => { + const newQuestions = questions.map((question: Question) => { + if (question.question_id === question_id) { + question.answers.map((answer: Answer) => { + if (answer.answer_id === answer_id) { + answer.answer = value; + } + return answer; + }); + } + return question; + }); + saveQuestions(newQuestions); + } + + const changeQuestionValue = (question_id: string, value: string) => { + const newQuestions = questions.map((question: Question) => { + if (question.question_id === question_id) { + question.question = value; + } + return question; + }); + saveQuestions(newQuestions); + } + + const deleteQuestion = (question_id: string) => { + const newQuestions = questions.filter((question: Question) => question.question_id !== question_id); + saveQuestions(newQuestions); + } + + const deleteAnswer = (question_id: string, answer_id: string) => { + const newQuestions = questions.map((question: Question) => { + if (question.question_id === question_id) { + question.answers = question.answers.filter((answer: Answer) => answer.answer_id !== answer_id); + } + return question; + }); + saveQuestions(newQuestions); + } + + const markAnswerCorrect = (question_id: string, answer_id: string) => { + const newQuestions = questions.map((question: Question) => { + if (question.question_id === question_id) { + question.answers.map((answer: Answer) => { + if (answer.answer_id === answer_id) { + answer.correct = true; + } else { + answer.correct = false; + } + + return answer; + }); + } + return question; + }); + saveQuestions(newQuestions); + } + return ( - - Questions -
- {questions.map((question: any, qIndex: number) => ( - <> -
- Question : onQuestionChange(e, qIndex)} /> - -
- Answers :
- {question.options.map((option: any, oIndex: number) => ( - <> -
- onOptionChange(e, qIndex, oIndex)} /> - - - // check if checkbox is checked or not - // if checked then add the answer to the answers array - // if unchecked then remove the answer from the answers array - e.target.checked ? markOptionAsCorrect(question.question_id, option.option_id) : null - } - /> +
+
+ {(submitted && submissionMessage == "All answers are correct!") && + + } +
+ +

Quiz

+
+
+ +
+ {isEditable ? +
+ +
+ : +
+
refreshUserSubmission()} className="cursor-pointer px-2"> + +
+ +
+ } +
+ + {questions.map((question: Question) => ( +
+
+
+
+ {isEditable ? + 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"> + : +

{question.question}

+ }
- - ))} - - + {isEditable && +
deleteQuestion(question.question_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"> + +
+ } +
+
+ {question.answers.map((answer: Answer) => ( +
(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)} + > +
userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) ? 'bg-red-400 text-red-800 outline-none' : '', + )}> +

{getAnswerID(question.answers.indexOf(answer),question.question_id)}

+
+ {isEditable ? + 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"> + : +

{answer.answer}

+ } + {isEditable && +
+
markAnswerCorrect(question.question_id, answer.answer_id)} + className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-lime-300 hover:bg-lime-400 transition-all ease-linear text-sm cursor-pointer "> + +
+
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"> + +
+
+ } + +
+ ))} + {isEditable && +
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"> + +
+ } +
+
+
))} - + +
); } -const QuizBlockWrapper = styled.div` - background-color: #0000001d; - border-radius: 5px; - padding: 20px; - height: 100%; -`; -export default ImageBlockComponent; + +export default QuizBlockComponent; diff --git a/front/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/front/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index d5ebc0b9..22d5804f 100644 --- a/front/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/front/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -1,6 +1,6 @@ import styled from "styled-components"; import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon, DividerVerticalIcon, ListBulletIcon } from "@radix-ui/react-icons"; -import { AlertCircle, AlertTriangle, FileText, GraduationCap, ImagePlus, Info, Sigma, Video, Youtube } from "lucide-react"; +import { AlertCircle, AlertTriangle, FileText, GraduationCap, HelpCircle, ImagePlus, Info, Sigma, Video, Youtube } from "lucide-react"; import ToolTip from "@components/StyledElements/Tooltip/Tooltip"; export const ToolbarButtons = ({ editor, props }: any) => { @@ -59,7 +59,7 @@ export const ToolbarButtons = ({ editor, props }: any) => { {/* TODO: fix this : toggling only works one-way */} - + editor.chain().focus().toggleNode("calloutInfo").run()}> @@ -113,7 +113,7 @@ export const ToolbarButtons = ({ editor, props }: any) => { .chain() .focus() .insertContent({ - type: "blockMathEquation", + type: "blockMathEquation", }) .run() } @@ -136,7 +136,7 @@ export const ToolbarButtons = ({ editor, props }: any) => { - {/* + editor @@ -148,9 +148,9 @@ export const ToolbarButtons = ({ editor, props }: any) => { .run() } > - + - */} + ); }; diff --git a/front/package-lock.json b/front/package-lock.json index 53741010..3e29e5b6 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -30,6 +30,7 @@ "re-resizable": "^6.9.9", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", + "react-confetti": "^6.1.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-katex": "^3.0.1", @@ -37,6 +38,7 @@ "react-youtube": "^10.1.0", "styled-components": "^6.0.0-beta.9", "swr": "^2.0.1", + "tailwind-merge": "^1.14.0", "uuid": "^9.0.0", "y-indexeddb": "^9.0.9", "y-webrtc": "^10.2.3", @@ -7842,6 +7844,20 @@ "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-confetti": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz", + "integrity": "sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==", + "dependencies": { + "tween-functions": "^1.2.0" + }, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "react": "^16.3.0 || ^17.0.1 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -8750,6 +8766,15 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", @@ -8922,6 +8947,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tween-functions": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz", + "integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/front/package.json b/front/package.json index 592c2f7c..604a5242 100644 --- a/front/package.json +++ b/front/package.json @@ -31,6 +31,7 @@ "re-resizable": "^6.9.9", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", + "react-confetti": "^6.1.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-katex": "^3.0.1", @@ -38,6 +39,7 @@ "react-youtube": "^10.1.0", "styled-components": "^6.0.0-beta.9", "swr": "^2.0.1", + "tailwind-merge": "^1.14.0", "uuid": "^9.0.0", "y-indexeddb": "^9.0.9", "y-webrtc": "^10.2.3",