Merge pull request #1577 from pateljannat/issues-115

fix: misc issues
This commit is contained in:
Jannat Patel
2025-06-16 17:09:33 +05:30
committed by GitHub
18 changed files with 681 additions and 576 deletions

View File

@@ -83,6 +83,7 @@ declare module 'vue' {
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default'] QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default'] QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default'] Rating: typeof import('./src/components/Controls/Rating.vue')['default']
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default'] ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View File

@@ -1,7 +1,9 @@
<template> <template>
<FrappeUIProvider> <FrappeUIProvider>
<Layout> <Layout>
<router-view /> <div class="text-base">
<router-view />
</div>
</Layout> </Layout>
<Dialogs /> <Dialogs />
</FrappeUIProvider> </FrappeUIProvider>

View File

@@ -191,7 +191,7 @@ import {
h, h,
onUnmounted, onUnmounted,
} from 'vue' } 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'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'

View File

@@ -70,9 +70,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge } from 'frappe-ui' import { formatTime } from '@/utils'
import { formatTime } from '../utils' import { Clock, Globe } from 'lucide-vue-next'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'

View File

@@ -148,7 +148,7 @@
</template> </template>
<script setup> <script setup>
import { Button, createResource, Tooltip, toast } from 'frappe-ui' import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue' import { getCurrentInstance, inject, ref, watch } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
@@ -197,13 +197,22 @@ const props = defineProps({
const outline = createResource({ const outline = createResource({
url: 'lms.lms.utils.get_course_outline', url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName], cache: ['course_outline', props.courseName],
params: { makeParams() {
course: props.courseName, return {
progress: props.getProgress, course: props.courseName,
progress: props.getProgress,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
outline.reload()
}
)
const deleteLesson = createResource({ const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson', url: 'lms.lms.api.delete_lesson',
makeParams(values) { makeParams(values) {

View File

@@ -64,7 +64,7 @@
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { computed, ref, inject } from 'vue' import { watch, ref, inject } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import ReviewModal from '@/components/Modals/ReviewModal.vue' import ReviewModal from '@/components/Modals/ReviewModal.vue'
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
const reviews = createResource({ const reviews = createResource({
url: 'lms.lms.utils.get_reviews', url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName], cache: ['course_reviews', props.courseName],
params: { makeParams() {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
reviews.reload()
}
)
const showReviewModal = ref(false) const showReviewModal = ref(false)
function openReviewModal() { function openReviewModal() {

View File

@@ -94,7 +94,7 @@
</template> </template>
<script setup> <script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui' 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, onUnmounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'

View File

@@ -69,7 +69,7 @@
<script setup> <script setup>
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, onUnmounted } 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'

View File

@@ -54,7 +54,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue' import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'

View File

@@ -3,44 +3,59 @@
v-model="show" v-model="show"
:options="{ :options="{
title: __('Attendance for Class - {0}').format(live_class?.title), title: __('Attendance for Class - {0}').format(live_class?.title),
size: 'xl', size: '4xl',
}" }"
> >
<template #body-content> <template #body-content>
<div class="space-y-5"> <div
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
>
<div>
{{ __('Member') }}
</div>
<div class="grid grid-cols-3 gap-20">
<div>
{{ __('Joined at') }}
</div>
<div class="text-center">
{{ __('Left at') }}
</div>
<div>
{{ __('Attended for') }}
</div>
</div>
</div>
<div class="divide-y text-base">
<div <div
v-for="participant in participants.data" v-for="participant in participants.data"
@click="redirectToProfile(participant.member_username)" @click="redirectToProfile(participant.member_username)"
class="cursor-pointer text-base w-fit" class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
> >
<Tooltip placement="right"> <div class="flex items-center space-x-2">
<div class="flex items-center space-x-2"> <Avatar
<Avatar :image="participant.member_image"
:image="participant.member_image" :label="participant.member_name"
:label="participant.member_name" size="xl"
size="xl" />
/> <div class="space-y-1">
<div class="space-y-1"> <div class="font-medium">
<div class="font-medium"> {{ participant.member_name }}
{{ participant.member_name }} </div>
</div> <div>
<div> {{ participant.member }}
{{ participant.member }}
</div>
</div> </div>
</div> </div>
<template #body> </div>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl" <div class="grid grid-cols-3 gap-20 text-right">
> <div>
{{ dayjs(participant.joined_at).format('HH:mm a') }} - {{ dayjs(participant.joined_at).format('HH:mm a') }}
{{ dayjs(participant.left_at).format('HH:mm a') }} </div>
<br /> <div>
{{ __('attended for') }} {{ participant.duration }} {{ dayjs(participant.left_at).format('HH:mm a') }}
{{ __('minutes') }} </div>
</div> <div>{{ participant.duration }} {{ __('minutes') }}</div>
</template> </div>
</Tooltip>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -4,15 +4,13 @@
<div class="text-2xl font-semibold text-ink-gray-9"> <div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Related Courses') }} {{ __('Related Courses') }}
</div> </div>
<div class="text-sm text-ink-gray-7">
{{ relatedCourses.data.length }} {{ __('courses') }}
</div>
</div> </div>
<div <div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
> >
<router-link <router-link
v-for="course in relatedCourses.data" v-for="course in relatedCourses.data"
:key="course.name"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }" :to="{ name: 'CourseDetail', params: { courseName: course.name } }"
class="cursor-pointer" class="cursor-pointer"
> >
@@ -24,11 +22,8 @@
<script setup> <script setup>
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { useRoute } from 'vue-router'
import { watch } from 'vue' import { watch } from 'vue'
import CourseCard from '@/components/CourseCard.vue'
const route = useRoute()
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -40,22 +35,18 @@ const props = defineProps({
const relatedCourses = createResource({ const relatedCourses = createResource({
url: 'lms.lms.utils.get_related_courses', url: 'lms.lms.utils.get_related_courses',
cache: ['related_courses', props.courseName], cache: ['related_courses', props.courseName],
params: { makeParams() {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: true, auto: true,
}) })
watch( watch(
() => route.params.courseName, () => props.courseName,
(newCourseName, oldCourseName) => { () => {
if (newCourseName && newCourseName !== oldCourseName) { relatedCourses.reload()
relatedCourses.update({
cache: ['related_courses', newCourseName],
params: { course: newCourseName },
})
relatedCourses.reload()
}
} }
) )
</script> </script>

View File

@@ -116,7 +116,7 @@ import {
EllipsisVertical, EllipsisVertical,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue' import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '../utils' import { formatTime } from '@/utils'
import { Button, createResource, call } from 'frappe-ui' import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue' import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue' import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'

View File

@@ -1,9 +1,6 @@
<template> <template>
<div> <div>
<div <div v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
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"
>
{{ {{
__('This video contains {0} {1}:').format( __('This video contains {0} {1}:').format(
quizzes.length, quizzes.length,
@@ -12,8 +9,10 @@
}} }}
<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>
{{ __('at {0}').format(formatTimestamp(quiz.time)) }} {{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
</span>
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
</div> </div>
</div> </div>
<div <div
@@ -65,15 +64,28 @@
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" /> <Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<input
type="range" <div class="relative flex items-center w-full flex-1">
min="0" <input
:max="duration" type="range"
step="0.1" min="0"
v-model="currentTime" :max="duration"
@input="changeCurrentTime" step="0.1"
class="duration-slider w-full h-1" v-model="currentTime"
/> @input="changeCurrentTime"
class="duration-slider h-1"
/>
<!-- QUIZ MARKERS -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<div
v-for="(quiz, index) in quizzes"
:key="index"
:style="getQuizMarkerStyle(quiz.time)"
class="absolute top-0 h-full w-2 bg-surface-amber-3"
></div>
</div>
</div>
<span class="text-sm font-medium"> <span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }} {{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span> </span>
@@ -116,11 +128,27 @@
:saveQuizzes="saveQuizzes" :saveQuizzes="saveQuizzes"
:duration="duration" :duration="duration"
/> />
<Dialog
v-model="showQuizLoader"
:options="{
size: 'sm',
}"
>
<template #body>
<div class="p-5 text-base">
{{
__(
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
}}
</div>
</template>
</Dialog>
</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, Dialog } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils' import { formatSeconds, formatTimestamp } from '@/utils'
import Play from '@/components/Icons/Play.vue' import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue' import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
@@ -133,6 +161,8 @@ 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 showQuiz = ref(false)
const showQuizLoader = ref(false)
const quizLoadTimer = ref(0)
const currentQuiz = ref(null) const currentQuiz = ref(null)
const nextQuiz = ref({}) const nextQuiz = ref({})
@@ -175,12 +205,24 @@ const updateCurrentTime = () => {
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 quizLoadTimer.value = 7
} }
} }
}, 0) }, 0)
} }
watch(quizLoadTimer, () => {
if (quizLoadTimer.value > 0) {
showQuizLoader.value = true
setTimeout(() => {
quizLoadTimer.value -= 1
}, 1000)
} else {
showQuizLoader.value = false
showQuiz.value = true
}
})
const resumeVideo = (restart = false) => { const resumeVideo = (restart = false) => {
showQuiz.value = false showQuiz.value = false
currentQuiz.value = null currentQuiz.value = null
@@ -259,6 +301,13 @@ const toggleFullscreen = () => {
videoContainer.value.requestFullscreen() videoContainer.value.requestFullscreen()
} }
} }
const getQuizMarkerStyle = (time) => {
const percentage = ((time - 7) / Math.ceil(duration.value)) * 100
return {
left: `${percentage}%`,
}
}
</script> </script>
<style scoped> <style scoped>
@@ -278,11 +327,10 @@ iframe {
} }
.duration-slider { .duration-slider {
flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
border-radius: 10px; border-radius: 10px;
background-color: theme('colors.gray.100'); background-color: theme('colors.gray.600');
cursor: pointer; cursor: pointer;
} }
@@ -290,20 +338,20 @@ iframe {
width: 2px; width: 2px;
border-radius: 50%; border-radius: 50%;
-webkit-appearance: none; -webkit-appearance: none;
background-color: theme('colors.gray.500'); background-color: theme('colors.white');
} }
@media screen and (-webkit-min-device-pixel-ratio: 0) { @media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] { input[type='range'] {
overflow: hidden; overflow: hidden;
width: 150px; width: 100%;
-webkit-appearance: none; -webkit-appearance: none;
} }
input[type='range']::-webkit-slider-thumb { input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
cursor: pointer; cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.600'); box-shadow: -500px 0 0 500px theme('colors.white');
} }
} }
</style> </style>

View File

@@ -83,12 +83,12 @@
:avg_rating="course.data.rating" :avg_rating="course.data.rating"
:membership="course.data.membership" :membership="course.data.membership"
/> />
<RelatedCourses :courseName="course.data.name" />
</div> </div>
<div class="hidden md:block"> <div class="hidden md:block">
<CourseCardOverlay :course="course" /> <CourseCardOverlay :course="course" />
</div> </div>
</div> </div>
<RelatedCourses :courseName="course.data.name" />
</div> </div>
</div> </div>
</template> </template>
@@ -109,10 +109,8 @@ import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue' import RelatedCourses from '@/components/RelatedCourses.vue'
import { useRoute } from 'vue-router'
const { brand } = sessionStore() const { brand } = sessionStore()
const route = useRoute()
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -124,22 +122,18 @@ const props = defineProps({
const course = createResource({ const course = createResource({
url: 'lms.lms.utils.get_course_details', url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName], cache: ['course', props.courseName],
params: { makeParams() {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: true, auto: true,
}) })
watch( watch(
() => route.params.courseName, () => props.courseName,
(newCourseName, oldCourseName) => { () => {
if (newCourseName && newCourseName !== oldCourseName) { course.reload()
course.update({
cache: ['course', newCourseName],
params: { course: newCourseName },
})
course.reload()
}
} }
) )

View File

@@ -199,6 +199,21 @@
) )
" "
/> />
<MultiSelect
v-model="related_courses"
doctype="LMS Course"
:label="__('Related Courses')"
:filters="{ name: ['!=', courseResource.data?.name] }"
:onCreate="
(close) => {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
}
"
/>
</div> </div>
<div class="px-10 pb-5 space-y-5 border-b"> <div class="px-10 pb-5 space-y-5 border-b">
@@ -319,6 +334,7 @@ const newTag = ref('')
const { brand } = sessionStore() const { brand } = sessionStore()
const router = useRouter() const router = useRouter()
const instructors = ref([]) const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance() const app = getCurrentInstance()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties const { $dialog } = app.appContext.config.globalProperties
@@ -400,6 +416,9 @@ const courseCreationResource = createResource({
instructors: instructors.value.map((instructor) => ({ instructors: instructors.value.map((instructor) => ({
instructor: instructor, instructor: instructor,
})), })),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...values, ...values,
}, },
} }
@@ -418,6 +437,9 @@ const courseEditResource = createResource({
instructors: instructors.value.map((instructor) => ({ instructors: instructors.value.map((instructor) => ({
instructor: instructor, instructor: instructor,
})), })),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...course, ...course,
}, },
} }
@@ -440,6 +462,11 @@ const courseResource = createResource({
data.instructors.forEach((instructor) => { data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor) instructors.value.push(instructor.instructor)
}) })
} else if (key == 'related_courses') {
related_courses.value = []
data.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key] } else if (Object.hasOwn(course, key)) course[key] = data[key]
}) })
let checkboxes = [ let checkboxes = [

View File

@@ -72,7 +72,7 @@ const persona = reactive({
const submitPersona = () => { const submitPersona = () => {
let responses = { let responses = {
site: user.data?.sitename, site: user.data?.sitename,
no_of_students: persona.noOfStudents, role: persona.role,
use_case: persona.useCase, use_case: persona.useCase,
} }
call('lms.lms.api.capture_user_persona', { call('lms.lms.api.capture_user_persona', {

View File

@@ -3,6 +3,7 @@ import VideoBlock from '@/components/VideoBlock.vue'
import UploadPlugin from '@/components/UploadPlugin.vue' import UploadPlugin from '@/components/UploadPlugin.vue'
import { h, createApp } from 'vue' import { h, createApp } from 'vue'
import { Upload as UploadIcon } from 'lucide-vue-next' import { Upload as UploadIcon } from 'lucide-vue-next'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from '../translation' import translationPlugin from '../translation'
export class Upload { export class Upload {
@@ -54,6 +55,7 @@ export class Upload {
}, },
}) })
app.use(translationPlugin) app.use(translationPlugin)
app.config.globalProperties.$dialog = createDialog
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} else if (this.isAudio(file.file_type)) { } else if (this.isAudio(file.file_type)) {

958
yarn.lock

File diff suppressed because it is too large Load Diff