feat: quiz in videos
This commit is contained in:
@@ -181,7 +181,16 @@
|
|||||||
import UserDropdown from '@/components/UserDropdown.vue'
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
|
import {
|
||||||
|
ref,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
watch,
|
||||||
|
reactive,
|
||||||
|
markRaw,
|
||||||
|
h,
|
||||||
|
onUnmounted,
|
||||||
|
} from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
@@ -626,4 +635,8 @@ watch(userResource, () => {
|
|||||||
const redirectToWebsite = () => {
|
const redirectToWebsite = () => {
|
||||||
window.open('https://frappe.io/learning', '_blank')
|
window.open('https://frappe.io/learning', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_message')
|
||||||
|
socket.off('update_message')
|
||||||
|
socket.off('delete_message')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { singularize, timeAgo } from '../utils'
|
import { singularize, timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareText } from 'lucide-vue-next'
|
import { MessageSquareText } from 'lucide-vue-next'
|
||||||
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
|
|||||||
const openTopicModal = () => {
|
const openTopicModal = () => {
|
||||||
showTopicModal.value = true
|
showTopicModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('new_discussion_topic')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
<div class="text-base">
|
<div class="text-base">
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('Time in Video (minutes)')"
|
:label="__('Time in Video (minutes:seconds)')"
|
||||||
v-model="quiz.time"
|
v-model="quiz.time"
|
||||||
type="number"
|
type="text"
|
||||||
placeholder="Time"
|
placeholder="2:15"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -58,19 +58,30 @@
|
|||||||
<div class="font-semibold text-lg text-ink-gray-9">
|
<div class="font-semibold text-lg text-ink-gray-9">
|
||||||
{{ quiz.data.title }}
|
{{ quiz.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="flex items-center justify-center space-x-2 mt-4">
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
!quiz.data.max_attempts ||
|
||||||
|
attempts.data?.length < quiz.data.max_attempts
|
||||||
|
"
|
||||||
|
variant="solid"
|
||||||
|
@click="startQuiz"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ inVideo ? __('Start the Quiz') : __('Start') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
!quiz.data.max_attempts ||
|
quiz.data.max_attempts &&
|
||||||
attempts.data?.length < quiz.data.max_attempts
|
attempts.data?.length >= quiz.data.max_attempts
|
||||||
"
|
"
|
||||||
@click="startQuiz"
|
class="leading-5 text-ink-gray-7"
|
||||||
class="mt-2"
|
|
||||||
>
|
>
|
||||||
<span>
|
|
||||||
{{ __('Start') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<div v-else class="leading-5 text-ink-gray-7">
|
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
||||||
@@ -263,7 +274,7 @@
|
|||||||
{{ __('Try Again') }}
|
{{ __('Try Again') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-if="inVideo" @click="props.onSubmit()">
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
{{ __('Resume Video') }}
|
{{ __('Resume Video') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +327,6 @@ let questions = reactive([])
|
|||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -327,7 +337,7 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
onSubmit: {
|
backToVideo: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
@@ -627,11 +637,15 @@ const getInstructions = (question) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markLessonProgress = () => {
|
const markLessonProgress = () => {
|
||||||
if (router.currentRoute.value.name == 'Lesson') {
|
let pathname = window.location.pathname.split('/')
|
||||||
|
if (pathname[2] != 'courses') return
|
||||||
|
let lessonIndex = pathname.pop().split('-')
|
||||||
|
|
||||||
|
if (lessonIndex.length == 2) {
|
||||||
call('lms.lms.api.mark_lesson_progress', {
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
course: router.currentRoute.value.params.courseName,
|
course: pathname[3],
|
||||||
chapter_number: router.currentRoute.value.params.chapterNumber,
|
chapter_number: lessonIndex[0],
|
||||||
lesson_number: router.currentRoute.value.params.lessonNumber,
|
lesson_number: lessonIndex[1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-if="quizzes.length && !showQuiz"
|
v-if="quizzes.length && !showQuiz && readOnly"
|
||||||
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
__('This video has {0} {1}:').format(
|
__('This video contains {0} {1}:').format(
|
||||||
quizzes.length,
|
quizzes.length,
|
||||||
quizzes.length == 1 ? 'quiz' : 'quizzes'
|
quizzes.length == 1 ? 'quiz' : 'quizzes'
|
||||||
)
|
)
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||||
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
|
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
|
||||||
{{ __('at {0} minutes').format(quiz.time) }}
|
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
v-if="showQuiz"
|
v-if="showQuiz"
|
||||||
:quizName="currentQuiz"
|
:quizName="currentQuiz"
|
||||||
:inVideo="true"
|
:inVideo="true"
|
||||||
:onSubmit="resumeVideo"
|
:backToVideo="resumeVideo"
|
||||||
/>
|
/>
|
||||||
<div v-if="!readOnly" @click="showQuizModal = true">
|
<div v-if="!readOnly" @click="showQuizModal = true">
|
||||||
<Button>
|
<Button>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button } from 'frappe-ui'
|
||||||
import { formatSeconds } from '@/utils'
|
import { formatSeconds } from '@/utils'
|
||||||
@@ -170,37 +170,45 @@ const updateCurrentTime = () => {
|
|||||||
}
|
}
|
||||||
videoRef.value.ontimeupdate = () => {
|
videoRef.value.ontimeupdate = () => {
|
||||||
currentTime.value = videoRef.value?.currentTime || currentTime.value
|
currentTime.value = videoRef.value?.currentTime || currentTime.value
|
||||||
if (currentTime.value >= nextQuiz.value.time * 60) {
|
if (currentTime.value >= nextQuiz.value.time) {
|
||||||
videoRef.value.pause()
|
videoRef.value.pause()
|
||||||
playing.value = false
|
playing.value = false
|
||||||
videoRef.value.onTimeupdate = null
|
videoRef.value.onTimeupdate = null
|
||||||
currentQuiz.value = nextQuiz.value.quiz
|
currentQuiz.value = nextQuiz.value.quiz
|
||||||
showQuiz.value = true
|
showQuiz.value = true
|
||||||
updateNextQuiz()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeVideo = () => {
|
const resumeVideo = (restart = false) => {
|
||||||
showQuiz.value = false
|
showQuiz.value = false
|
||||||
currentQuiz.value = null
|
currentQuiz.value = null
|
||||||
updateCurrentTime()
|
updateCurrentTime()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = restart ? 0 : currentTime.value
|
||||||
videoRef.value.play()
|
videoRef.value.play()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
|
updateNextQuiz()
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNextQuiz = () => {
|
const updateNextQuiz = () => {
|
||||||
if (!props.quizzes.length) return
|
if (!props.quizzes.length) return
|
||||||
|
|
||||||
|
props.quizzes.forEach((quiz) => {
|
||||||
|
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
|
||||||
|
let time = quiz.time.split(':')
|
||||||
|
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
|
||||||
|
quiz.time = timeInSeconds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
props.quizzes.sort((a, b) => a.time - b.time)
|
props.quizzes.sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
const nextQuizIndex = props.quizzes.findIndex(
|
const nextQuizIndex = props.quizzes.findIndex(
|
||||||
(quiz) => quiz.time * 60 > currentTime.value
|
(quiz) => quiz.time > currentTime.value
|
||||||
)
|
)
|
||||||
|
|
||||||
if (nextQuizIndex !== -1) {
|
if (nextQuizIndex !== -1) {
|
||||||
nextQuiz.value = props.quizzes[nextQuizIndex]
|
nextQuiz.value = props.quizzes[nextQuizIndex]
|
||||||
} else {
|
} else {
|
||||||
@@ -209,22 +217,10 @@ const updateNextQuiz = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fileURL = computed(() => {
|
const fileURL = computed(() => {
|
||||||
if (isYoutube) {
|
|
||||||
let url = props.file
|
|
||||||
if (url.includes('watch?v=')) {
|
|
||||||
url = url.replace('watch?v=', 'embed/')
|
|
||||||
}
|
|
||||||
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
|
|
||||||
}
|
|
||||||
return props.file
|
return props.file
|
||||||
})
|
})
|
||||||
|
|
||||||
const isYoutube = computed(() => {
|
|
||||||
return props.type == 'video/youtube'
|
|
||||||
})
|
|
||||||
|
|
||||||
const playVideo = () => {
|
const playVideo = () => {
|
||||||
console.log(currentTime.value)
|
|
||||||
videoRef.value.play()
|
videoRef.value.play()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
}
|
}
|
||||||
@@ -235,7 +231,6 @@ const pauseVideo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
console.log(currentTime.value)
|
|
||||||
if (playing.value) {
|
if (playing.value) {
|
||||||
pauseVideo()
|
pauseVideo()
|
||||||
} else {
|
} else {
|
||||||
@@ -254,7 +249,6 @@ const toggleMute = () => {
|
|||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
console.log('Current Time:', currentTime.value)
|
|
||||||
updateNextQuiz()
|
updateNextQuiz()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +259,13 @@ const toggleFullscreen = () => {
|
|||||||
videoContainer.value.requestFullscreen()
|
videoContainer.value.requestFullscreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (seconds) => {
|
||||||
|
const date = new Date(seconds * 1000)
|
||||||
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
|
||||||
|
const secs = String(date.getUTCSeconds()).padStart(2, '0')
|
||||||
|
return `${minutes}:${secs}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ import ProgressBar from '@/components/ProgressBar.vue'
|
|||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const socket = inject('$socket')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
const allowDiscussions = ref(false)
|
||||||
@@ -335,6 +336,11 @@ const props = defineProps({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startTimer()
|
startTimer()
|
||||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
socket.on('update_lesson_progress', (data) => {
|
||||||
|
if (data.course === props.courseName) {
|
||||||
|
lessonProgress.value = data.progress
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachFullscreenEvent = () => {
|
const attachFullscreenEvent = () => {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import {
|
|||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { computed, inject, ref, onMounted } from 'vue'
|
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -135,6 +135,10 @@ const markAllAsRead = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
import json
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
from lms.lms.utils import get_course_progress
|
from lms.lms.utils import get_course_progress
|
||||||
from ...md import find_macros
|
from ...md import find_macros
|
||||||
import json
|
from frappe.realtime import get_website_room
|
||||||
|
|
||||||
|
|
||||||
class CourseLesson(Document):
|
class CourseLesson(Document):
|
||||||
@@ -55,6 +56,7 @@ def save_progress(lesson, course):
|
|||||||
)
|
)
|
||||||
|
|
||||||
quiz_completed = get_quiz_progress(lesson)
|
quiz_completed = get_quiz_progress(lesson)
|
||||||
|
print("quiz_completed", quiz_completed)
|
||||||
assignment_completed = get_assignment_progress(lesson)
|
assignment_completed = get_assignment_progress(lesson)
|
||||||
|
|
||||||
if not already_completed and quiz_completed and assignment_completed:
|
if not already_completed and quiz_completed and assignment_completed:
|
||||||
@@ -76,6 +78,13 @@ def save_progress(lesson, course):
|
|||||||
enrollment.save()
|
enrollment.save()
|
||||||
enrollment.run_method("on_change")
|
enrollment.run_method("on_change")
|
||||||
|
|
||||||
|
frappe.publish_realtime(
|
||||||
|
event="update_lesson_progress",
|
||||||
|
room=get_website_room(),
|
||||||
|
message={"course": course, "lesson": lesson, "progress": progress},
|
||||||
|
after_commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
@@ -106,8 +115,10 @@ def get_quiz_progress(lesson):
|
|||||||
macros = find_macros(lesson_details.body)
|
macros = find_macros(lesson_details.body)
|
||||||
quizzes = [value for name, value in macros if name == "Quiz"]
|
quizzes = [value for name, value in macros if name == "Quiz"]
|
||||||
|
|
||||||
|
print(quizzes)
|
||||||
for quiz in quizzes:
|
for quiz in quizzes:
|
||||||
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
|
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
|
||||||
|
print(quiz, passing_percentage)
|
||||||
if not frappe.db.exists(
|
if not frappe.db.exists(
|
||||||
"LMS Quiz Submission",
|
"LMS Quiz Submission",
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user