Skip to content

Instantly share code, notes, and snippets.

@rumansaleem
Last active July 2, 2024 15:31
Show Gist options
  • Save rumansaleem/af27500a73721483b974fc6348231279 to your computer and use it in GitHub Desktop.
Save rumansaleem/af27500a73721483b974fc6348231279 to your computer and use it in GitHub Desktop.
Vue Quiz Component Gist

Vue Components for a Quiz Platform

This Gist is part of a bigger project, which is a quiz hosting platform built for CSDU's annual technical fest "SANKALAN".

Quiz Screenshot UI

Full Codebase Repository: https://github.com/csdu/sankalan-portal

It was built with Laravel, Vue 2.x (later upgraded to 3.x keeping use of options API) and TailwindCSS.

<template>
<slot :timer="timer" :format="format">
<span v-if="days" >{{ format(days, 2) }}:</span>
<span v-if="hours" >{{ format(hours, 2) }}:</span>
<span>{{ format(minutes, 2) }}:</span>
<span>{{ format(seconds, 2) }}.</span>
<span class="text-sm">{{ format(milliseconds, 3) }}</span>
</slot>
</template>
<script>
export default {
props: {
duration: {required: true},
hurry: {default: 15}
},
emits: ['hurryup', 'timeup'],
data() {
return {
intervalTimer: null,
ended: false,
startTime: new Date().getTime(),
currentTime: new Date().getTime(),
framesDuration: 1000/30,
}
},
computed: {
timeSpent() {
return this.currentTime - this.startTime;
},
timeLeft() {
return (this.duration * 1000) - this.timeSpent;
},
milliseconds() {
return this.timeLeft % 1000;
},
seconds() {
return Math.floor(this.timeLeft / 1000) % 60;
},
minutes() {
return Math.floor(this.timeLeft / 1000 / 60) % 60;
},
hours() {
return Math.floor(this.timeLeft / 1000 / 60 / 60) % 24;
},
days() {
return Math.floor(this.timeLeft / 1000 / 60 / 60 / 24);
},
timer() {
return {
milliseconds: this.milliseconds,
seconds: this.seconds,
minutes: this.minutes,
hours: this.hours,
days: this.days,
}
}
},
methods: {
refreshTime() {
this.currentTime = new Date().getTime();
if(this.timeLeft <= this.framesDuration) {
this.currentTime += this.timeLeft; // to make timer goto zero
clearInterval(this.intervalTimer);
this.$emit('timeup');
this.ended = true;
}
if(this.timeLeft <= this.hurry * 1000) {
this.$emit('hurryup');
}
},
format(number, minDigits) {
const digits = number.toString().length;
if(digits >= minDigits) {
return number;
}
return (new Array(minDigits-digits)).fill(0).join("").concat(number)
}
},
mounted() {
this.intervalTimer = setInterval(this.refreshTime, this.framesDuration);
}
}
</script>
<template>
<div class="flex flex-1" v-if="isLive">
<div class="question-area flex flex-col w-full lg:w-3/4 px-4 overflow-auto py-4">
<quiz-question
class="flex flex-col"
:data-question="currentQuestion"
:data-question-attachments="currentQuestionAttachments"
:index="currentQuestionIndex"
:value="currentResponse"
@input="saveResponse"
></quiz-question>
<div class="navigate flex">
<button
:disabled="loading"
v-if="!isFirstQuestion"
class="mr-2 btn"
:class="{'cursor-wait' : loading}"
@click="previousQuestion()"
>Previous</button>
<button
:disabled="loading"
v-if="!isLastQuestion"
class="mr-2 btn is-blue"
:class="{'cursor-wait' : loading}"
@click="nextQuestion()"
>Next</button>
<span v-if="loading" class="inline-flex items-center ml-auto">
<svg viewBox="0 0 110 110" class="text-blue w-8 mr-2">
<circle
id="loader-circle"
cx="55"
cy="55"
r="50"
stroke-dasharray="314.16"
stroke="currentColor"
fill="none"
stroke-width="10"
/>
<animate
xlink:href="#loader-circle"
attributeName="stroke-dashoffset"
attributeType="XML"
from="314.16"
to="314.16"
dur="2s"
begin="0s"
keyTimes="0; 0.25; 0.5; 0.75; 1;"
values="314.16; 0; -314.16; 0; 314.16;"
repeatCount="indefinite"
/>
<animateTransform
xlink:href="#loader-circle"
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 55 55"
to="360 55 55"
dur="3s"
repeatCount="indefinite"
/>
</svg>
<span class="text-gray-600">saving response...</span>
</span>
<!-- <button v-else class="btn is-green" @click="submit()">End Quiz</button> -->
<!-- <button :disabled=loading class="btn is-green mx-2" :class="{'cursor-wait' : loading}" @click="saveResponse()">Save</button> -->
</div>
</div>
<div class="navigation flex flex-col md:w-64 px-3 overflow-auto py-4">
<ul class="questions-nav list-reset justify-center flex flex-wrap -mx-1 -mb-1 mt-4">
<li v-for="questionNumber in questions.length" :key="questionNumber">
<button
class="mx-1 my-1 w-6 h-6 p-0 flex justify-center items-center text-xs border border-black text-black rounded"
v-text="questionNumber"
@click.prevent="setCurrentQuestion(questionNumber-1)"
:class="{
'border-black bg-black text-white': isCurrentQuestion(questionNumber-1),
'border-green bg-green hover:bg-green-dark text-white': isQuestionAnswered(questionNumber-1),
'border-red bg-red hover:bg-red-dark text-white': isQuestionSkipped(questionNumber-1)
}"
></button>
</li>
</ul>
<div class="flex justify-center">
<countdown-timer
:duration="timeLimit"
:hurry="300"
class="inline-flex justify-center items-baseline my-8 text-lg font-bold font-mono"
:class="{'animation-vibrate text-red': hurry}"
@timeup="endQuiz"
@hurryup="hurry=true"
></countdown-timer>
</div>
<div class="mb-auto text-center">
<button class="btn is-red" @click.prevent="submit">End Quiz</button>
</div>
</div>
</div>
<div
class="fixed inset-x-0 top-0 z-50 w-full h-screen flex flex-col justify-center items-center"
v-else
>
<svg viewBox="0 0 100 100" class="text-green w-32" v-if="submission.done && submission.success">
<path
d="M10 50 L40 80 L90 10"
stroke="currentColor"
fill="none"
stroke-width="15"
stroke-dasharray="129"
/>
</svg>
<svg viewBox="0 0 100 100" class="text-red w-32" v-else-if="submission.done">
<path
d="M10 10 L90 90"
stroke="currentColor"
fill="none"
stroke-width="15"
stroke-dasharray="114"
/>
<path
d="M90 10 L10 90"
stroke="currentColor"
fill="none"
stroke-width="15"
stroke-dasharray="114"
/>
</svg>
<svg viewBox="0 0 110 110" class="text-blue w-32" v-else>
<circle
id="loader-circle"
cx="55"
cy="55"
r="50"
stroke-dasharray="314.16"
stroke="currentColor"
fill="none"
stroke-width="10"
/>
<animate
xlink:href="#loader-circle"
attributeName="stroke-dashoffset"
attributeType="XML"
from="314.16"
to="314.16"
dur="2s"
begin="0s"
keyTimes="0; 0.25; 0.5; 0.75; 1;"
values="314.16; 0; -314.16; 0; 314.16;"
repeatCount="indefinite"
/>
<animateTransform
xlink:href="#loader-circle"
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 55 55"
to="360 55 55"
dur="3s"
repeatCount="indefinite"
/>
</svg>
<p class="text-center my-6" v-text="submission.text"></p>
<p class="text-center my-6" v-if="submission.success">
You will be redirected to dashboard in 3 seconds.
Click
<button
@click="redirect"
class="font-normal text-blue hover:undeline"
>here</button> to redirect manually.
</p>
</div>
</template>
<script>
import CountdownTimer from "./CountdownTimer.vue";
import QuizQuestion from "./QuizQuestion.vue";
export default {
components: { CountdownTimer, QuizQuestion },
props: {
action: { required: true },
redirectTo: { required: true },
saveAction: { required: true },
dataQuestions: { default: [] },
dataQuestionsAttachments: { default: [] },
timeLimit: { default: 30 },
dataResponses: { default: () => [] }
},
data() {
return {
isLive: true,
submission: {
done: false,
success: false,
text: "Please wait! While we record your response."
},
keyEvents: {
ArrowRight: () => this.nextQuestion(),
ArrowLeft: () => this.previousQuestion(),
Enter: () => this.submit()
},
hurry: false,
currentQuestionIndex: 0,
responses: [],
questions: [],
loading: false
};
},
computed: {
currentQuestion() {
return this.questions[this.currentQuestionIndex];
},
currentQuestionAttachments() {
return this.dataQuestionsAttachments.filter(questionAttachment => {
return questionAttachment.question_id === this.currentQuestion.id;
});
},
currentResponse() {
return this.responses[this.currentQuestionIndex];
},
isFirstQuestion() {
return this.currentQuestionIndex === 0;
},
isLastQuestion() {
return this.currentQuestionIndex === this.questions.length - 1;
}
},
methods: {
isCurrentQuestion(index) {
return this.currentQuestionIndex == index;
},
hasQuestionResponse(index) {
return this.responses[index] && this.responses[index].key;
},
isQuestionAnswered(index) {
return (
!this.isCurrentQuestion(index) &&
this.questions[index].visited &&
this.hasQuestionResponse(index)
);
},
isQuestionSkipped(index) {
return (
!this.isCurrentQuestion(index) &&
this.questions[index].visited &&
!this.hasQuestionResponse(index)
);
},
setCurrentQuestion(index) {
// this.currentResponse = this.currentResponse;
if (index >= 0 && index < this.questions.length) {
this.questions[this.currentQuestionIndex].visited = true;
this.currentQuestionIndex = index;
}
},
nextQuestion() {
this.setCurrentQuestion(this.currentQuestionIndex + 1);
// this.currentResponse = '';
},
previousQuestion() {
this.setCurrentQuestion(this.currentQuestionIndex - 1);
// this.currentResponse = '';
},
submit() {
if (
confirm(
"You still have time left. Are you sure you want to submit your Response?"
)
) {
this.endQuiz();
}
},
endQuiz() {
this.isLive = false;
axios
.post(this.action)
.catch(this.onFailure)
.then(this.onSuccess);
},
onSuccess({ data }) {
this.submission = {
done: true,
success: data.message.level == "success",
text: data.message.message
};
setTimeout(this.redirect, 3 * 1000);
},
redirect() {
window.location.replace(this.redirectTo);
},
onFailure({ response }) {
this.submission = {
done: true,
success: response.data.message.level != "danger",
text: response.data.message.message
};
},
saveResponse(response) {
if (response == null) {
return flash("Answer can not be null!", "danger");
}
this.loading = true;
axios
.post(this.saveAction, {
question_id: this.currentQuestion.id,
response_key: response.key
})
.catch(error => {
flash("Error occurred in saving response!", "danger");
})
.then(({ data }) => {
this.responses.splice(this.currentQuestionIndex, 1, response);
flash(data.message, "info");
})
.finally(() => {
this.loading = false;
});
}
},
created() {
this.responses = new Array(this.dataQuestions.length).fill(null);
this.dataQuestions.sort((a, b) => (a.qno > b.qno ? 1 : -1));
this.questions = this.dataQuestions.map(question => {
question.visited = false;
return question;
});
this.dataResponses.forEach(response => {
const index = this.dataQuestions.findIndex(question => {
return response.question_id == question.id;
});
if (index > -1) {
this.responses[index] = { key: response.response_keys };
}
});
},
mounted() {
window.addEventListener("keydown", ({ code }) => {
if (this.keyEvents.hasOwnProperty(code)) {
this.keyEvents[code]();
return false;
}
});
}
};
</script>
<style>
.animation-vibrate {
animation: vibrate 1s infinite alternate linear;
}
@keyframes vibrate {
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
60% {
transform: rotate(5deg) scale(1.2);
}
80% {
transform: rotate(-5deg) scale(1.2);
}
100% {
transform: rotate(0) scale(1.2);
}
}
</style>
<template>
<div class="question outline-none">
<div class="card px-3 pt-3 pb-6 relative overflow-hidden flex">
<strong class="float-left mr-2" v-text="`Q${dataQuestion.qno}.`"></strong>
<article class="markdown-body w-full" v-html="dataQuestion.text"></article>
</div>
<pre
v-if="dataQuestion.code"
class="card p-4 font-mono my-4 leading-normal tracking-wide whitespace-pre-wrap max-h-64 overflow-y-auto"
v-text="escapedCode"
></pre>
<div
class="flex justify-center my-4"
v-for="questionAttachment in dataQuestionAttachments"
:key="questionAttachment.id"
>
<img :src="'/question_attachments/' +questionAttachment.id" class="max-w-full rounded shadow-lg" />
</div>
<ul class="choices-list list-reset my-8 flex flex-wrap -mx-2" v-if="choicesCount">
<li
v-for="(choice, choiceIndex) in dataQuestion.choices"
:key="choice.id"
class="mb-3 w-full md:w-1/2 px-2"
>
<label
:for="`choice-${choice.key}`"
class="relative flex items-center btn hover:bg-grey-light border shadow cursor-pointer pl-6 h-full"
@mouseover="highlightOption(choiceIndex)"
:class="{
'bg-white': !isHighlighted(choiceIndex) && !isSelected(choiceIndex),
'bg-green-dark': isHighlighted(choiceIndex) && isSelected(choiceIndex),
'bg-green': !isHighlighted(choiceIndex) && isSelected(choiceIndex),
'bg-grey-light border border-grey-darker': isHighlighted(choiceIndex),
'text-white hover:bg-green-dark border-green-dark': isSelected(choiceIndex),
}"
>
<div v-if="isHighlighted(choiceIndex)" class="absolute -ml-3 inset-y-0 left-0 flex items-center">
<span
class="inline-block w-2 h-2 rounded-full"
:class="isSelected(choiceIndex) ? 'bg-white' : 'bg-green'"
></span>
</div>
<input
:id="`choice-${choice.key}`"
type="radio"
class="hidden"
:name="`question-${choice.question_id}`"
@click="toggleOption(choiceIndex)"
:value="choice.key"
/>
<div>
<img
v-if="choice.illustration"
:src="choice.illustration"
:alt="choice.text"
class="rounded my-2 max-w-full"
/>
<pre v-if="choice.code" v-html="choice.code" class="my-2"></pre>
<p class="ml-1" v-html="choice.text"></p>
</div>
</label>
</li>
</ul>
<!-- Input Answer -->
<div class="card my-4 p-4" v-else>
<div class="flex" v-if="editing">
<input
ref="input"
type="text"
class="flex-1 mr-1 control"
v-model="answer.key"
autofocus
@keydown.stop
@keydown.enter.prevent.stop="saveResponse"
/>
<button @click="saveResponse" class="btn btn-green is-sm">Save</button>
</div>
<div class="flex" v-else>
<p class="flex-1 mr-1" :class="{'text-grey': !answer}" v-text="answer.key"></p>
<button @click="editResponse" class="btn is-blue is-sm">Edit</button>
</div>
</div>
</div>
</template>
<script>
import {nextTick} from 'vue';
export default {
props: {
dataQuestion: { required: true },
dataQuestionAttachments: { required: true },
index: { required: true },
value: { default: null }
},
emits: ['input'],
data() {
return {
highlightedOptionIndex: 0,
editing: false,
answer: this.value || { key: "" },
keyEvents: {
ArrowDown: () => this.highlightNextOption(),
ArrowUp: () => this.highlightPreviousOption(),
Space: () => this.toggleOption(this.highlightedOptionIndex),
Delete: () => this.clearOption(),
Backspace: () => this.clearOption()
}
};
},
computed: {
choicesCount() {
return this.dataQuestion.choices.length;
},
escapedCode() {
if (this.dataQuestion.code) {
return this.dataQuestion.code
.replace(/\n/g, "\\n")
.replace(/<br>/g, "\n");
}
return null;
}
},
methods: {
highlightOption(index) {
this.highlightedOptionIndex = index;
},
highlightNextOption() {
this.highlightedOptionIndex =
(this.highlightedOptionIndex + 1) % this.choicesCount;
},
highlightPreviousOption() {
this.highlightedOptionIndex =
this.highlightedOptionIndex <= 0
? this.choicesCount - 1
: this.highlightedOptionIndex - 1;
},
toggleOption(index) {
if (this.isSelected(index)) {
this.answer = { key: "" };
this.$emit("input", this.answer);
} else {
this.answer = this.dataQuestion.choices[index];
this.$emit("input", this.answer);
}
},
isSelected(index) {
const choice = this.dataQuestion.choices[index];
return choice && this.value && this.value.key == choice.key;
},
isHighlighted(index) {
return this.highlightedOptionIndex == index;
},
clearResponse() {
this.$emit("input", { key: "" });
},
editResponse() {
this.editing = true;
nextTick(() => this.$refs.input.focus());
},
saveResponse() {
this.editing = false;
this.$emit("input", this.answer);
}
},
watch: {
value() {
this.answer = this.value || { key: "" };
}
},
mounted() {
if (this.dataQuestion.choices.length > 0) {
window.addEventListener("keydown", ({ code }) => {
if (this.keyEvents.hasOwnProperty(code)) {
this.keyEvents[code]();
return false;
}
});
}
},
created() {
document.addEventListener("DOMContentLoaded", event => {
document.querySelectorAll("pre code").forEach(block => {
hljs.highlightBlock(block);
});
});
},
updated() {
document.querySelectorAll("pre code").forEach(block => {
hljs.highlightBlock(block);
});
}
};
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment