feat: display quiz when time is reached

This commit is contained in:
Jannat Patel
2025-05-30 18:55:26 +05:30
parent 60334ca04a
commit 622a2ff072
6 changed files with 184 additions and 70 deletions

View File

@@ -3,23 +3,14 @@
v-model="show" v-model="show"
:options="{ :options="{
title: __('Add quiz to this video'), title: __('Add quiz to this video'),
size: 'xl', size: '2xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick({ close }) {
addQuizToVideo(close)
},
},
],
}" }"
> >
<template #body-content> <template #body-content>
<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 (seconds)')" :label="__('Time in Video (minutes)')"
v-model="quiz.time" v-model="quiz.time"
type="number" type="number"
placeholder="Time" placeholder="Time"
@@ -31,7 +22,7 @@
doctype="LMS Quiz" doctype="LMS Quiz"
class="flex-1" class="flex-1"
/> />
<Button @click="addQuiz()"> <Button @click="addQuiz()" variant="solid">
<template #prefix> <template #prefix>
<Plus class="w-4 h-4 stroke-1.5" /> <Plus class="w-4 h-4 stroke-1.5" />
</template> </template>
@@ -39,43 +30,63 @@
</Button> </Button>
</div> </div>
<ListView <div class="mt-10 mb-5">
v-if="quizzes.length" <div class="font-medium mb-4">
class="mt-10" {{ __('Quizzes in this video') }}
:columns="columns" </div>
:rows="allQuizzes" <ListView
row-key="name" v-if="allQuizzes.length"
:options="{ :columns="columns"
showTooltip: false, :rows="allQuizzes"
selectable: false, row-key="quiz"
}" :options="{
> showTooltip: false,
<ListHeader }"
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
> >
<ListHeaderItem :item="item" v-for="item in columns"> <ListHeader
<template #prefix="{ item }"> class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
<component >
v-if="item.icon" <ListHeaderItem :item="item" v-for="item in columns">
:is="item.icon" <template #prefix="{ item }">
class="h-4 w-4 stroke-1.5 ml-4" <component
/> v-if="item.icon"
</template> :is="item.icon"
</ListHeaderItem> class="h-4 w-4 stroke-1.5 ml-4"
</ListHeader> />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows> <ListRows>
<ListRow :row="row" v-for="row in allQuizzes"> <ListRow :row="row" v-for="row in allQuizzes">
<template #default="{ column, item }"> <template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align"> <ListRowItem :item="row[column.key]" :align="column.align">
<div class="leading-5 text-sm"> <div class="leading-5 text-sm">
{{ row[column.key] }} {{ row[column.key] }}
</div> </div>
</ListRowItem> </ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeQuiz(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template> </template>
</ListRow> </ListSelectBanner>
</ListRows> </ListView>
</ListView>
<div v-else class="text-ink-gray-5 italic text-xs">
{{ __('No quizzes added yet.') }}
</div>
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
@@ -91,10 +102,11 @@ import {
ListRows, ListRows,
ListRow, ListRow,
ListRowItem, ListRowItem,
ListSelectBanner,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
type Quiz = { type Quiz = {
@@ -124,11 +136,6 @@ const props = defineProps({
}, },
}) })
const addQuizToVideo = (close: () => void) => {
props.saveQuizzes(allQuizzes.value)
close()
}
const addQuiz = () => { const addQuiz = () => {
if (quiz.time > props.duration) { if (quiz.time > props.duration) {
toast.error(__('Time in video exceeds the total duration of the video.')) toast.error(__('Time in video exceeds the total duration of the video.'))
@@ -139,10 +146,23 @@ const addQuiz = () => {
quiz: quiz.quiz, quiz: quiz.quiz,
}) })
props.saveQuizzes(allQuizzes.value)
quiz.time = 0 quiz.time = 0
quiz.quiz = '' quiz.quiz = ''
} }
const removeQuiz = (selections: string, unselectAll: () => void) => {
Array.from(selections).forEach((selection) => {
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
if (index !== -1) {
allQuizzes.value.splice(index, 1)
}
unselectAll()
})
props.saveQuizzes(allQuizzes.value)
}
watch( watch(
() => props.quizzes, () => props.quizzes,
(newQuizzes) => { (newQuizzes) => {
@@ -159,7 +179,7 @@ const columns = computed(() => {
}, },
{ {
key: 'time', key: 'time',
label: __('Time in Video (seconds)'), label: __('Time in Video (minutes)'),
align: 'center', align: 'center',
}, },
] ]

View File

@@ -1,8 +1,11 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3" class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
> >
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5"> <div class="leading-5">
{{ {{
__('This quiz consists of {0} questions.').format(questions.length) __('This quiz consists of {0} questions.').format(questions.length)
@@ -247,18 +250,23 @@
) )
}} }}
</div> </div>
<Button <div class="space-x-2">
@click="resetQuiz()" <Button
class="mt-2" @click="resetQuiz()"
v-if=" class="mt-2"
!quiz.data.max_attempts || v-if="
attempts?.data.length < quiz.data.max_attempts !quiz.data.max_attempts ||
" attempts?.data.length < quiz.data.max_attempts
> "
<span> >
{{ __('Try Again') }} <span>
</span> {{ __('Try Again') }}
</Button> </span>
</Button>
<Button v-if="inVideo" @click="props.onSubmit()">
{{ __('Resume Video') }}
</Button>
</div>
</div> </div>
<div <div
v-if=" v-if="
@@ -315,6 +323,14 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inVideo: {
type: Boolean,
default: false,
},
onSubmit: {
type: Function,
default: () => {},
},
}) })
const quiz = createResource({ const quiz = createResource({

View File

@@ -293,7 +293,7 @@ const tabsStructure = computed(() => {
type: 'checkbox', type: 'checkbox',
}, },
{ {
label: 'Certified Participants', label: 'Certified Members',
name: 'certified_participants', name: 'certified_participants',
type: 'checkbox', type: 'checkbox',
}, },

View File

@@ -1,6 +1,26 @@
<template> <template>
<div> <div>
<div ref="videoContainer" class="video-block relative group"> <div
v-if="quizzes.length && !showQuiz"
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(
quizzes.length,
quizzes.length == 1 ? 'quiz' : 'quizzes'
)
}}
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
{{ __('at {0} minutes').format(quiz.time) }}
</div>
</div>
<div
v-if="!showQuiz"
ref="videoContainer"
class="video-block relative group"
>
<video <video
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
@@ -78,6 +98,12 @@
</Button> </Button>
</div> </div>
</div> </div>
<Quiz
v-if="showQuiz"
:quizName="currentQuiz"
:inVideo="true"
:onSubmit="resumeVideo"
/>
<div v-if="!readOnly" @click="showQuizModal = true"> <div v-if="!readOnly" @click="showQuizModal = true">
<Button> <Button>
{{ __('Add Quiz to Video') }} {{ __('Add Quiz to Video') }}
@@ -106,6 +132,9 @@ let currentTime = ref(0)
let duration = ref(0) let duration = ref(0)
let muted = ref(false) let muted = ref(false)
const showQuizModal = ref(false) const showQuizModal = ref(false)
const showQuiz = ref(false)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const props = defineProps({ const props = defineProps({
file: { file: {
@@ -130,15 +159,54 @@ const props = defineProps({
}) })
onMounted(() => { onMounted(() => {
updateCurrentTime()
updateNextQuiz()
})
const updateCurrentTime = () => {
setTimeout(() => { setTimeout(() => {
videoRef.value.onloadedmetadata = () => { videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration duration.value = videoRef.value.duration
} }
videoRef.value.ontimeupdate = () => { videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value.currentTime currentTime.value = videoRef.value?.currentTime || currentTime.value
if (currentTime.value >= nextQuiz.value.time * 60) {
videoRef.value.pause()
playing.value = false
videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true
updateNextQuiz()
}
} }
}, 0) }, 0)
}) }
const resumeVideo = () => {
showQuiz.value = false
currentQuiz.value = null
updateCurrentTime()
setTimeout(() => {
videoRef.value.currentTime = currentTime.value
videoRef.value.play()
playing.value = true
}, 0)
}
const updateNextQuiz = () => {
if (!props.quizzes.length) return
props.quizzes.sort((a, b) => a.time - b.time)
const nextQuizIndex = props.quizzes.findIndex(
(quiz) => quiz.time * 60 > currentTime.value
)
if (nextQuizIndex !== -1) {
nextQuiz.value = props.quizzes[nextQuizIndex]
} else {
nextQuiz.value = {}
}
}
const fileURL = computed(() => { const fileURL = computed(() => {
if (isYoutube) { if (isYoutube) {
@@ -156,6 +224,7 @@ const isYoutube = computed(() => {
}) })
const playVideo = () => { const playVideo = () => {
console.log(currentTime.value)
videoRef.value.play() videoRef.value.play()
playing.value = true playing.value = true
} }
@@ -166,6 +235,7 @@ const pauseVideo = () => {
} }
const togglePlay = () => { const togglePlay = () => {
console.log(currentTime.value)
if (playing.value) { if (playing.value) {
pauseVideo() pauseVideo()
} else { } else {
@@ -184,6 +254,8 @@ const toggleMute = () => {
const changeCurrentTime = () => { const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value videoRef.value.currentTime = currentTime.value
console.log('Current Time:', currentTime.value)
updateNextQuiz()
} }
const toggleFullscreen = () => { const toggleFullscreen = () => {

View File

@@ -47,6 +47,7 @@ export class Upload {
const app = createApp(VideoBlock, { const app = createApp(VideoBlock, {
file: file.file_url, file: file.file_url,
readOnly: this.readOnly, readOnly: this.readOnly,
quizzes: file.quizzes || [],
saveQuizzes: (quizzes) => { saveQuizzes: (quizzes) => {
if (this.readOnly) return if (this.readOnly) return
this.data.quizzes = quizzes this.data.quizzes = quizzes

View File

@@ -96,6 +96,11 @@ def get_quiz_progress(lesson):
for block in content.get("blocks"): for block in content.get("blocks"):
if block.get("type") == "quiz": if block.get("type") == "quiz":
quizzes.append(block.get("data").get("quiz")) quizzes.append(block.get("data").get("quiz"))
if block.get("type") == "upload":
quizzes_in_video = block.get("data").get("quizzes")
if quizzes_in_video and len(quizzes_in_video) > 0:
for row in quizzes_in_video:
quizzes.append(row.get("quiz"))
elif lesson_details.body: elif lesson_details.body:
macros = find_macros(lesson_details.body) macros = find_macros(lesson_details.body)