feat: batch details
This commit is contained in:
70
frontend/src/pages/Batch.vue
Normal file
70
frontend/src/pages/Batch.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="h-screen text-base">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="batch.doc">
|
||||
<div class="grid grid-cols-[70%,30%] h-full">
|
||||
<div class="border-r-2"></div>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-3">
|
||||
{{ batch.doc.title }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ dayjs(batch.doc.start_date).format('DD MMM YYYY') }} -
|
||||
{{ dayjs(batch.doc.end_date).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ formatTime(batch.doc.start_time) }} -
|
||||
{{ formatTime(batch.doc.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-html="batch.doc.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createDocumentResource } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Calendar, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = createDocumentResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
cache: ['batch', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{ label: 'All Batches', route: { name: 'Batches' } },
|
||||
{
|
||||
label: 'Batch Details',
|
||||
route: { name: 'BatchDetail', params: { batchName: props.batchName } },
|
||||
},
|
||||
{
|
||||
label: batch?.doc?.title,
|
||||
route: { name: 'Batch', params: { batchName: props.batchName } },
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,13 +1,123 @@
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
this is a batch
|
||||
</div>
|
||||
<div v-if="batch.doc" class="h-screen text-base">
|
||||
<header class="sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5">
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5 pb-10">
|
||||
<div>
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ batch.doc.title }}
|
||||
</div>
|
||||
<div class="my-3">
|
||||
{{ batch.doc.description }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-1/2">
|
||||
<div class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span> {{ batch.doc.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<span v-if="batch.doc.courses">·</span>
|
||||
<div class="flex items-center">
|
||||
<Calendar class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span>
|
||||
{{ dayjs(batch.doc.start_date).format('DD MMM YYYY') }} -
|
||||
{{ dayjs(batch.doc.end_date).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="batch.doc.start_date">·</span>
|
||||
<div class="flex items-center">
|
||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.doc.start_time) }} -
|
||||
{{ formatTime(batch.doc.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||
<div class="">
|
||||
<div v-html="batch.doc.batch_details" class="batch-description"></div>
|
||||
</div>
|
||||
<div>
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.doc.courses"
|
||||
v-for="course in batch.doc.courses"
|
||||
:key="course.course"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.course,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course.course" :key="course.course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createDocumentResource } from 'frappe-ui'
|
||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '../utils'
|
||||
import { computed, inject } from 'vue'
|
||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
const batch = createDocumentResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
cache: ['batch', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
||||
items.push({
|
||||
label: batch?.doc?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.doc?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,123 +1,140 @@
|
||||
<template>
|
||||
<div v-if="course.data" class="h-screen text-base">
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5">
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="breadcrumbs"
|
||||
/>
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div>
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3 w-1/3">
|
||||
<div v-if="course.data.avg_rating" class="flex items-center">
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="course.data.avg_rating">·</span>
|
||||
<div v-if="course.data.enrollment_count" class="flex items-center">
|
||||
<Users class="h-4 w-4 text-gray-700"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="course.data.enrollment_count">·</span>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-1" :class="{ 'avatar-group overlap': course.data.instructors.length > 1 }">
|
||||
<UserAvatar v-for="instructor in course.data.instructors" :user="instructor"/>
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 1">
|
||||
{{ course.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 2">
|
||||
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length > 2">
|
||||
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||
<div class="">
|
||||
<div v-html="course.data.description" class="course-description"></div>
|
||||
<div class="mt-10">
|
||||
<div class="text-2xl font-semibold">
|
||||
{{ __("Course Content") }}
|
||||
</div>
|
||||
<CourseOutline :courseName="course.data.name"/>
|
||||
</div>
|
||||
<CourseReviews v-if="course.data.avg_rating" :courseName="course.data.name" :avg_rating="course.data.avg_rating" :membership="course.data.membership"/>
|
||||
</div>
|
||||
<div>
|
||||
<CourseCardOverlay :course="course"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data" class="h-screen text-base">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div>
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="my-3">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-1/3">
|
||||
<div v-if="course.data.avg_rating" class="flex items-center">
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="course.data.avg_rating">·</span>
|
||||
<div v-if="course.data.enrollment_count" class="flex items-center">
|
||||
<Users class="h-4 w-4 text-gray-700" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="course.data.enrollment_count">·</span>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 1">
|
||||
{{ course.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 2">
|
||||
{{ course.data.instructors[0].first_name }} and
|
||||
{{ course.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length > 2">
|
||||
{{ course.data.instructors[0].first_name }} and
|
||||
{{ course.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||
<div class="">
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="course-description"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<div class="text-2xl font-semibold">
|
||||
{{ __('Course Content') }}
|
||||
</div>
|
||||
<CourseOutline :courseName="course.data.name" />
|
||||
</div>
|
||||
<CourseReviews
|
||||
v-if="course.data.avg_rating"
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.avg_rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Users, Star } from 'lucide-vue-next'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue';
|
||||
import CourseOutline from '@/components/CourseOutline.vue';
|
||||
import CourseReviews from '@/components/CourseReviews.vue';
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: "lms.lms.utils.get_course_details",
|
||||
cache: ["course", props.courseName],
|
||||
params: {
|
||||
course: props.courseName
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: "All Courses", route: { name: "Courses" } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: "CourseDetail", params: { course: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { course: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.course-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.course-description li {
|
||||
line-height: 1.7;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.course-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,276 +1,388 @@
|
||||
<template>
|
||||
<div v-if="lesson.data && course.data" class="h-screen text-base">
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5">
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="grid grid-cols-[70%,30%] h-full">
|
||||
<div v-if="lesson.data.no_preview" class="border-r-2 text-center pt-10">
|
||||
<p class="mb-4">
|
||||
{{ __("This lesson is not available for preview. Please enroll in the course to access it.") }}
|
||||
</p>
|
||||
<router-link :to='{ name: "CourseDetail", params: { courseName: courseName } }'>
|
||||
<Button variant="solid">
|
||||
{{ __("Start Learning") }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="border-r-2 container pt-5 pb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
<div>
|
||||
<router-link v-if="lesson.data.prev" :to='{name: "Lesson", params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.prev.split(".")[0],
|
||||
lessonNumber: lesson.data.prev.split(".")[1]
|
||||
}}'>
|
||||
<Button class="mr-2">
|
||||
<ChevronLeft class="w-4 h-4 stroke-1"/>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link v-if="lesson.data.next" :to='{name: "Lesson", params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.next.split(".")[0],
|
||||
lessonNumber: lesson.data.next.split(".")[1]
|
||||
}}'>
|
||||
<Button>
|
||||
<ChevronRight class="w-4 h-4 stroke-1"/>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<span class="mr-1" :class="{ 'avatar-group overlap': course.data.instructors.length > 1 }">
|
||||
<UserAvatar v-for="instructor in course.data.instructors" :user="instructor" />
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 1">
|
||||
{{ course.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 2">
|
||||
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length > 2">
|
||||
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
<!-- <div v-html="lesson.data.rendered_content" class="lesson-content mt-6"></div> -->
|
||||
<div class="lesson-content mt-6">
|
||||
<div v-for="block in lesson.data.body.split('\n\n')">
|
||||
<div v-if='block.includes("{{ YouTubeVideo")'>
|
||||
<iframe class="youtube-video" :src="getYouTubeVideoSource(block)" width="100%" height="400" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div v-else-if='block.includes("{{ Quiz")'>
|
||||
<Quiz v-if="user.data" :quizName="getId(block)"></Quiz>
|
||||
<div v-else class="border rounded-md text-center py-20">
|
||||
<div>
|
||||
{{ __("Please login to access the quiz.") }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-2">
|
||||
<span>
|
||||
{{ __("Login") }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if='block.includes("{{ Video")'>
|
||||
<video controls width='100%' controlsList='nodownload'>
|
||||
<source :src="getId(block)" type='video/mp4'>
|
||||
</video>
|
||||
</div>
|
||||
<div v-else-if='block.includes("{{ PDF")'>
|
||||
<iframe :src="getPDFSource(block)" width="100%" height="400" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div v-else-if='block.includes("{{ Audio")'>
|
||||
<audio width='100%' controls controlsList='nodownload'>
|
||||
<source :src="getId(block)" type='audio/mp3'>
|
||||
</audio>
|
||||
</div>
|
||||
<div v-else-if='block.includes("{{ Embed")'>
|
||||
<iframe width="100%" height="400" :src="getId(block)" frameborder="0" allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
<div v-else v-html="markdown.render(block)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
<div class="bg-gray-50 p-5 border-b-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div v-if="user && course.data.membership" class="text-sm mt-3">
|
||||
{{ Math.ceil(course.data.membership.progress) }}% completed
|
||||
</div>
|
||||
<div v-if="user && course.data.membership" class="w-full bg-gray-200 rounded-full h-1 my-2">
|
||||
<div class="bg-gray-900 h-1 rounded-full"
|
||||
:style="{ width: Math.ceil(course.data.membership.progress) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="lesson.data && course.data" class="h-screen text-base">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="grid grid-cols-[70%,30%] h-full">
|
||||
<div v-if="lesson.data.no_preview" class="border-r-2 text-center pt-10">
|
||||
<p class="mb-4">
|
||||
{{
|
||||
__(
|
||||
'This lesson is not available for preview. Please enroll in the course to access it.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||
>
|
||||
<Button variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="border-r-2 container pt-5 pb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
<div>
|
||||
<router-link
|
||||
v-if="lesson.data.prev"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.prev.split('.')[0],
|
||||
lessonNumber: lesson.data.prev.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="mr-2">
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="lesson.data.next"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.next.split('.')[0],
|
||||
lessonNumber: lesson.data.next.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<span
|
||||
class="mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 1">
|
||||
{{ course.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 2">
|
||||
{{ course.data.instructors[0].first_name }} and
|
||||
{{ course.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length > 2">
|
||||
{{ course.data.instructors[0].first_name }} and
|
||||
{{ course.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
<div class="lesson-content mt-6">
|
||||
<div v-if="lesson.data.youtube">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
:src="getYouTubeVideoSource(lesson.data.youtube)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-for="block in lesson.data.body.split('\n\n')">
|
||||
<div v-if="block.includes('{{ YouTubeVideo')">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
:src="getYouTubeVideoSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Quiz')">
|
||||
<Quiz v-if="user.data" :quizName="getId(block)"></Quiz>
|
||||
<div v-else class="border rounded-md text-center py-20">
|
||||
<div>
|
||||
{{ __('Please login to access the quiz.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-2">
|
||||
<span>
|
||||
{{ __('Login') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Video')">
|
||||
<video controls width="100%" controlsList="nodownload">
|
||||
<source :src="getId(block)" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ PDF')">
|
||||
<iframe
|
||||
:src="getPDFSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Audio')">
|
||||
<audio width="100%" controls controlsList="nodownload">
|
||||
<source :src="getId(block)" type="audio/mp3" />
|
||||
</audio>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Embed')">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="400"
|
||||
:src="getId(block)"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
<div v-else v-html="markdown.render(block)"></div>
|
||||
</div>
|
||||
<div v-if="lesson.data.quiz_id">
|
||||
<Quiz v-if="user.data" :quizName="getId(block)"></Quiz>
|
||||
<div v-else class="border rounded-md text-center py-20">
|
||||
<div>
|
||||
{{ __('Please login to access the quiz.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-2">
|
||||
<span>
|
||||
{{ __('Login') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
<div class="bg-gray-50 p-5 border-b-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div v-if="user && course.data.membership" class="text-sm mt-3">
|
||||
{{ Math.ceil(course.data.membership.progress) }}% completed
|
||||
</div>
|
||||
<div
|
||||
v-if="user && course.data.membership"
|
||||
class="w-full bg-gray-200 rounded-full h-1 my-2"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-900 h-1 rounded-full"
|
||||
:style="{
|
||||
width: Math.ceil(course.data.membership.progress) + '%',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Button } from "frappe-ui";
|
||||
import { computed, watch, onBeforeMount, onUnmounted, inject } from "vue";
|
||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||
import { computed, watch, onBeforeMount, onUnmounted, inject } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import CourseOutline from '@/components/CourseOutline.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { useRoute } from "vue-router";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
import Quiz from '@/components/Quiz.vue';
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
|
||||
const user = inject("$user");
|
||||
const route = useRoute();
|
||||
const user = inject('$user')
|
||||
const route = useRoute()
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
onBeforeMount(() => {
|
||||
localStorage.setItem("sidebar_is_collapsed", true);
|
||||
html: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
chapterNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lessonNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
chapterNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lessonNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const lesson = createResource({
|
||||
url: "lms.lms.utils.get_lesson",
|
||||
cache: ["lesson", props.courseName, props.chapterNumber, props.lessonNumber],
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
chapter: values ? values.chapter : props.chapterNumber,
|
||||
lesson: values ? values.lesson : props.lessonNumber,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
url: 'lms.lms.utils.get_lesson',
|
||||
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
chapter: values ? values.chapter : props.chapterNumber,
|
||||
lesson: values ? values.lesson : props.lessonNumber,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.membership) {
|
||||
current_lesson.submit({
|
||||
name: data.membership.name,
|
||||
lesson_name: data.name,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const current_lesson = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Enrollment',
|
||||
name: values.name,
|
||||
fieldname: 'current_lesson',
|
||||
value: values.lesson_name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: "lms.lms.utils.get_course_details",
|
||||
cache: ["course", props.courseName],
|
||||
params: {
|
||||
course: props.courseName
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: "All Courses", route: { name: "Courses" } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: "CourseDetail", params: { course: props.courseName } },
|
||||
})
|
||||
items.push({
|
||||
label: lesson?.data?.title,
|
||||
route: { name: "Lesson", params: { course: props.courseName, chapterNumber: props.chapterNumber, lessonNumber: props.lessonNumber } },
|
||||
})
|
||||
return items
|
||||
});
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { course: props.courseName } },
|
||||
})
|
||||
items.push({
|
||||
label: lesson?.data?.title,
|
||||
route: {
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
},
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
localStorage.setItem('sidebar_is_collapsed', true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
useStorage("sidebar_is_collapsed", false);
|
||||
});
|
||||
localStorage.setItem('sidebar_is_collapsed', false)
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||
([newChapterNumber, newLessonNumber], [oldChapterNumber, oldLessonNumber]) => {
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
}
|
||||
);
|
||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||
(
|
||||
[newChapterNumber, newLessonNumber],
|
||||
[oldChapterNumber, oldLessonNumber]
|
||||
) => {
|
||||
if (newChapterNumber && newLessonNumber) {
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getYouTubeVideoSource = (block) => {
|
||||
return `https://www.youtube.com/embed/${getId(block)}`;
|
||||
if (block.includes('{{')) {
|
||||
block = getId(block)
|
||||
}
|
||||
return `https://www.youtube.com/embed/${block}`
|
||||
}
|
||||
|
||||
const getPDFSource = (block) => {
|
||||
return `${getId(block)}#toolbar=0`;
|
||||
return `${getId(block)}#toolbar=0`
|
||||
}
|
||||
|
||||
const getId = (block) => {
|
||||
return block.match(/\(["']([^"']+?)["']\)/)[1];
|
||||
return block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect_to=/courses/${props.courseName}/learn/${route.params.chapterNumber}-${route.params.lessonNumber}`;
|
||||
window.location.href = `/login?redirect_to=/courses/${props.courseName}/learn/${route.params.chapterNumber}-${route.params.lessonNumber}`
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.lesson-content p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.lesson-content li {
|
||||
line-height: 1.7;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.lesson-content ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: 1rem;
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.lesson-content ul {
|
||||
list-style: auto;
|
||||
padding: 1rem;
|
||||
margin: revert;
|
||||
list-style: auto;
|
||||
padding: 1rem;
|
||||
margin: revert;
|
||||
}
|
||||
|
||||
.lesson-content img {
|
||||
border: 1px solid theme("colors.gray.200");
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid theme('colors.gray.200');
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.lesson-content code {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #011627;
|
||||
color: #d6deeb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #011627;
|
||||
color: #d6deeb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.lesson-content a {
|
||||
color: theme("colors.gray.900");
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
color: theme('colors.gray.900');
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user