feat: batch details

This commit is contained in:
Jannat Patel
2024-01-05 18:22:03 +05:30
parent 10cdd712d2
commit 3a33f047f5
15 changed files with 1215 additions and 662 deletions

View File

@@ -32,7 +32,7 @@ repos:
rev: v2.7.1 rev: v2.7.1
hooks: hooks:
- id: prettier - id: prettier
types_or: [javascript] types_or: [javascript, vue]
# Ignore any files that might contain jinja / bundles # Ignore any files that might contain jinja / bundles
exclude: | exclude: |
(?x)^( (?x)^(

View File

@@ -1,23 +1,37 @@
<template> <template>
<div class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50" <div
:class="isSidebarCollapsed ? 'w-12' : 'w-56'"> class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
<div class="flex flex-col overflow-hidden"> :class="isSidebarCollapsed ? 'w-12' : 'w-56'"
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" /> >
<div class="flex flex-col overflow-y-auto"> <div class="flex flex-col overflow-hidden">
<SidebarLink v-for="link in links" :icon="link.icon" :label="link.label" :to="link.to" <UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
:isCollapsed="isSidebarCollapsed" class="mx-2 my-0.5" /> <div class="flex flex-col overflow-y-auto">
</div> <SidebarLink
</div> v-for="link in links"
<SidebarLink :label="isSidebarCollapsed ? 'Expand' : 'Collapse'" :isCollapsed="isSidebarCollapsed" :icon="link.icon"
@click="isSidebarCollapsed = !isSidebarCollapsed" class="m-2"> :label="link.label"
<template #icon> :to="link.to"
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> :isCollapsed="isSidebarCollapsed"
<CollapseSidebar class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out" class="mx-2 my-0.5"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }" /> />
</span> </div>
</template> </div>
</SidebarLink> <SidebarLink
</div> :label="isSidebarCollapsed ? 'Expand' : 'Collapse'"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
class="m-2"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
/>
</span>
</template>
</SidebarLink>
</div>
</template> </template>
<script setup> <script setup>
@@ -26,30 +40,33 @@ import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next' import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next'
import { ref } from 'vue' import { ref, watch } from 'vue'
const links = [ const links = [
{ {
label: 'Courses', label: 'Courses',
icon: BookOpen, icon: BookOpen,
to: 'Courses', to: 'Courses',
}, },
{ {
label: "Batches", label: 'Batches',
icon: Users, icon: Users,
to: 'Batches', to: 'Batches',
}, },
{ {
label: "Statistics", label: 'Statistics',
icon: TrendingUp, icon: TrendingUp,
to: 'Statistics', to: 'Statistics',
}, },
{ {
label: "Jobs", label: 'Jobs',
icon: Briefcase, icon: Briefcase,
to: 'Jobs', to: 'Jobs',
}, },
] ]
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
let isSidebarCollapsed = ref(useStorage("sidebar_is_collapsed", false)) let isSidebarCollapsed = ref(getSidebarFromStorage())
</script> </script>

View File

@@ -1,71 +1,65 @@
<template> <template>
<div class="flex flex-col border border-gray-200 rounded-md p-4 h-full" style="min-height: 150px;"> <div
<Badge v-if="batch.seat_count && batch.seats_left > 0" theme="green" class="self-start mb-2"> class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
{{ batch.seats_left }} {{ __("Seat Left") }} style="min-height: 150px"
</Badge> >
<Badge v-else-if="batch.seat_count && batch.seats_left <= 0" theme="red" class="self-start mb-2"> <Badge
{{ __("Sold Out") }} v-if="batch.seat_count && batch.seats_left > 0"
</Badge> theme="green"
<div class="text-xl font-semibold mb-1"> class="self-start mb-2"
{{ batch.title }} >
</div> {{ batch.seats_left }} {{ __('Seat Left') }}
<div class="short-introduction"> </Badge>
{{ batch.description }} <Badge
</div> v-else-if="batch.seat_count && batch.seats_left <= 0"
<div class="mt-auto"> theme="red"
<div v-if="batch.amount" class="font-semibold text-lg mb-4"> class="self-start mb-2"
{{ batch.price }} >
</div> {{ __('Sold Out') }}
<div class="flex items-center mb-3"> </Badge>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700"/> <div class="text-xl font-semibold mb-1">
<span> {{ batch.title }}
{{ batch.courses }} {{ __("Courses") }} </div>
</span> <div class="short-introduction">
</div> {{ batch.description }}
<div class="flex items-center mb-3"> </div>
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" /> <div class="mt-auto">
<span> <div v-if="batch.amount" class="font-semibold text-lg mb-4">
{{ dayjs(batch.start_date).format("DD MMM YYYY") }} - {{ dayjs(batch.end_date).format("DD MMM YYYY") }} {{ batch.price }}
</span> </div>
</div> <div class="flex items-center mb-3">
<div class="flex items-center"> <BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" /> <span> {{ batch.courses }} {{ __('Courses') }} </span>
<span> </div>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }} <div class="flex items-center mb-3">
</span> <Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
</div> <span>
</div> {{ dayjs(batch.start_date).format('DD MMM YYYY') }} -
</div> {{ dayjs(batch.end_date).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { Calendar, Clock, BookOpen } from "lucide-vue-next" import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
import { inject } from "vue" import { inject } from 'vue'
import { Badge } from "frappe-ui" import { Badge } from 'frappe-ui'
import { formatTime } from '../utils'
const dayjs = inject("$dayjs") const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: Object, type: Object,
default: null, default: null,
}, },
}); })
function formatTime(timeString) {
if (!timeString) return "";
const [hour, minute] = timeString.split(":").map(Number);
// Create a Date object with dummy values for day, month, and year
const dummyDate = new Date(0, 0, 0, hour, minute);
// Use Intl.DateTimeFormat to format the time in 12-hour format
const formattedTime = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
}).format(dummyDate);
return formattedTime;
}
</script> </script>
<style> <style>
.short-introduction { .short-introduction {
@@ -76,6 +70,6 @@ function formatTime(timeString) {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
margin: 0.25rem 0 1.25rem; margin: 0.25rem 0 1.25rem;
line-height: 1.5; line-height: 1.5;
} }
</style> </style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="shadow rounded-md p-5" style="width: 300px">
<Badge
v-if="batch.doc.seat_count && seats_left > 0"
theme="green"
class="self-start mb-2 float-right"
>
{{ seats_left }} {{ __('Seat Left') }}
</Badge>
<Badge
v-else-if="batch.doc.seat_count && seats_left <= 0"
theme="red"
class="self-start mb-2 float-right"
>
{{ __('Sold Out') }}
</Badge>
<div v-if="batch.doc.amount" class="text-lg font-semibold mb-3">
{{ formatNumberIntoCurrency(batch.doc.amount, batch.doc.currency) }}
</div>
<div class="flex items-center mb-3">
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.doc.courses.length }} {{ __('Courses') }} </span>
</div>
<div class="flex items-center mb-3">
<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">
<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>
<Button v-if="user?.data?.is_moderator" class="w-full mt-4">
<span>
{{ __('Manage Batch') }}
</span>
</Button>
<Button
v-else-if="batch.doc.paid_batch"
class="w-full mt-4"
variant="solid"
>
<span>
{{ __('Register Now') }}
</span>
</Button>
<Button v-if="user?.data?.is_moderator" class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</div>
</template>
<script setup>
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
const dayjs = inject('$dayjs')
const user = inject('$user')
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const seats_left = computed(() => {
if (props.batch.doc.seat_count) {
return props.batch.doc.seat_count - props.batch.doc.students.length
}
return null
})
</script>

View File

@@ -1,97 +1,156 @@
<template> <template>
<div class="flex flex-col border border-gray-200 h-full rounded-md shadow-sm text-base overflow-auto" style="min-height: 320px;"> <div
<div class="course-image" :class="{ 'default-image': !course.image }" :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"> v-if="course.title"
<div class="flex relative top-4 left-4 w-fit"> class="flex flex-col border border-gray-200 h-full rounded-md shadow-sm text-base overflow-auto"
<div class="course-card-pills rounded-md border border-gray-200" v-for="tag in course.tags"> style="min-height: 320px"
{{ tag }} >
</div> <div
</div> class="course-image"
<div v-if="!course.image" class="image-placeholder">{{ course.title[0] }}</div> :class="{ 'default-image': !course.image }"
</div> :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
<div class="flex flex-col flex-auto p-4"> >
<div class="flex items-center justify-between mb-2"> <div class="flex relative top-4 left-4 w-fit">
<div v-if="course.lesson_count" class="flex items-center space-x-1 py-1"> <div
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700" /> class="course-card-pills rounded-md border border-gray-200"
<span> {{ course.lesson_count }} </span> v-for="tag in course.tags"
</div> >
{{ tag }}
</div>
</div>
<div v-if="!course.image" class="image-placeholder">
{{ course.title[0] }}
</div>
</div>
<div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2">
<div
v-if="course.lesson_count"
class="flex items-center space-x-1 py-1"
>
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700" />
<span> {{ course.lesson_count }} </span>
</div>
<div v-if="course.enrollment_count" class="flex items-center space-x-1 py-1"> <div
<Users class="h-4 w-4 stroke-1.5 text-gray-700" /> v-if="course.enrollment_count"
<span> {{ course.enrollment_count }} </span> class="flex items-center space-x-1 py-1"
</div> >
<Users class="h-4 w-4 stroke-1.5 text-gray-700" />
<span> {{ course.enrollment_count }} </span>
</div>
<div v-if="course.avg_rating" class="flex items-center space-x-1 py-1"> <div v-if="course.avg_rating" class="flex items-center space-x-1 py-1">
<Star class="h-4 w-4 stroke-1.5 text-gray-700" /> <Star class="h-4 w-4 stroke-1.5 text-gray-700" />
<span> {{ course.avg_rating }} </span> <span> {{ course.avg_rating }} </span>
</div> </div>
<div v-if="course.status != 'Approved'"> <div v-if="course.status != 'Approved'">
<Badge variant="solid" :theme="course.status === 'Under Review' ? 'orange' : 'blue'" size="sm"> <Badge
{{ course.status }} variant="solid"
</Badge> :theme="course.status === 'Under Review' ? 'orange' : 'blue'"
</div> size="sm"
</div> >
{{ course.status }}
<div class="text-xl font-semibold"> </Badge>
{{ course.title }} </div>
</div> </div>
<div class="short-introduction"> <div class="text-xl font-semibold">
{{ course.short_introduction }} {{ course.title }}
</div> </div>
<div v-if="user && course.membership" class="w-full bg-gray-200 rounded-full h-1 mb-2">
<div class="bg-gray-900 h-1 rounded-full" :style="{ width: Math.ceil(course.membership.progress) + '%' }"></div>
</div>
<div v-if="user && course.membership" class="text-sm mb-4">
{{ Math.ceil(course.membership.progress) }}% completed
</div>
<div class="flex items-center justify-between mt-auto"> <div class="short-introduction">
<div class="flex avatar-group overlap"> {{ course.short_introduction }}
<div class="mr-1" :class="{ 'avatar-group overlap': course.instructors.length > 1 }"> </div>
<UserAvatar v-for="instructor in course.instructors" :user="instructor"/> <div
</div> v-if="user && course.membership"
<span v-if="course.instructors.length == 1"> class="w-full bg-gray-200 rounded-full h-1 mb-2"
{{ course.instructors[0].full_name }} >
</span> <div
<span v-if="course.instructors.length == 2"> class="bg-gray-900 h-1 rounded-full"
{{ course.instructors[0].first_name }} and {{ course.instructors[1].first_name }} :style="{ width: Math.ceil(course.membership.progress) + '%' }"
</span> ></div>
<span v-if="course.instructors.length > 2"> </div>
{{ course.instructors[0].first_name }} and {{ course.instructors.length - 1 }} others <div v-if="user && course.membership" class="text-sm mb-4">
</span> {{ Math.ceil(course.membership.progress) }}% completed
</div> </div>
<div class="font-semibold"> <div class="flex items-center justify-between mt-auto">
{{ course.price }} <div class="flex avatar-group overlap">
</div> <div
</div> class="mr-1"
</div> :class="{ 'avatar-group overlap': course.instructors.length > 1 }"
</div> >
<UserAvatar
v-for="instructor in course.instructors"
:user="instructor"
/>
</div>
<span v-if="course.instructors.length == 1">
{{ course.instructors[0].full_name }}
</span>
<span v-if="course.instructors.length == 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors[1].first_name }}
</span>
<span v-if="course.instructors.length > 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors.length - 1 }} others
</span>
</div>
<div class="font-semibold">
{{ course.price }}
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Badge } from "frappe-ui" import { Badge, createResource } from 'frappe-ui'
import { ref, watchEffect } from 'vue'
const { isLoggedIn, user } = sessionStore() const { user } = sessionStore()
let course = ref({})
const props = defineProps({ const props = defineProps({
course: { course: {
type: Object, type: [Object, String],
default: null, default: null,
}, },
}); })
const courseDetails = createResource({
url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName],
makeParams() {
return {
course: props.course,
}
},
transform(data) {
course.value = data
},
})
/* watchEffect(() => {
if (props.course && typeof props.course === "object") {
course.value = props.course;
} else {
courseDetails.reload();
}
}); */
</script> </script>
<style> <style>
.course-image { .course-image {
height: 168px; height: 168px;
width: 100%; width: 100%;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.course-card-pills { .course-card-pills {
@@ -108,31 +167,31 @@ const props = defineProps({
} }
.default-image { .default-image {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: theme('colors.gray.200'); background-color: theme('colors.gray.200');
color: theme('colors.gray.700'); color: theme('colors.gray.700');
} }
.avatar-group { .avatar-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.avatar-group .avatar { .avatar-group .avatar {
transition: margin 0.1s ease-in-out; transition: margin 0.1s ease-in-out;
} }
.image-placeholder { .image-placeholder {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; flex: 1;
font-size: 5rem; font-size: 5rem;
color: theme('colors.gray.700'); color: theme('colors.gray.700');
font-weight: 600; font-weight: 600;
} }
.avatar-group.overlap .avatar + .avatar { .avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px); margin-left: calc(-8px);
} }
.short-introduction { .short-introduction {
@@ -143,6 +202,6 @@ const props = defineProps({
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
margin: 0.25rem 0 1.25rem; margin: 0.25rem 0 1.25rem;
line-height: 1.5; line-height: 1.5;
} }
</style> </style>

View File

@@ -1,101 +1,132 @@
<template> <template>
<div class="shadow rounded-md" style="width: 300px;"> <div class="shadow rounded-md" style="width: 300px">
<iframe v-if="course.data.video_link" :src="video_link" class="rounded-t-md" /> <iframe
<div class="p-5"> v-if="course.data.video_link"
<router-link v-if="course.data.membership && course.data.current_lesson" :src="video_link"
:to="{name: 'Lesson', params: { class="rounded-t-md"
courseName: course.name, />
chapterNumber: course.data.current_lesson.split('.')[0], <div class="p-5">
lessonNumber: course.data.current_lesson.split('.')[1] <router-link
}}"> v-if="course.data.membership"
<Button variant="solid" class="w-full mb-3"> :to="{
<span> name: 'Lesson',
{{ __("Continue Learning") }} params: {
</span> courseName: course.name,
</Button> chapterNumber: course.data.current_lesson
</router-link> ? course.data.current_lesson.split('.')[0]
<Button v-else @click="enrollStudent()" variant="solid" class="w-full mb-3"> : 1,
<span> lessonNumber: course.data.current_lesson
{{ __("Start Learning") }} ? course.data.current_lesson.split('.')[1]
</span> : 1,
</Button> },
<Button v-if="user?.data?.is_moderator" variant="subtle" class="w-full mb-3"> }"
<span> >
{{ __("Edit") }} <Button variant="solid" class="w-full mb-3">
</span> <span>
</Button> {{ __('Continue Learning') }}
<div class="flex items-center mb-3"> </span>
<Users class="h-4 w-4 text-gray-700"/> </Button>
<span class="ml-1"> </router-link>
{{ course.data.enrollment_count_formatted }} {{ __("Enrolled") }} <Button
</span> v-else
</div> @click="enrollStudent()"
<div class="flex items-center mb-3"> variant="solid"
<BookOpen class="h-4 w-4 text-gray-700"/> class="w-full mb-3"
<span class="ml-1"> >
{{ course.data.lesson_count }} {{ __("Lessons") }} <span>
</span> {{ __('Start Learning') }}
</div> </span>
<div class="flex items-center"> </Button>
<Star class="h-4 w-4 fill-orange-500 text-gray-100"/> <Button
<span class="ml-1"> v-if="user?.data?.is_moderator"
{{ course.data.avg_rating }} {{ __("Rating") }} variant="subtle"
</span> class="w-full mb-3"
</div> >
</div> <span>
</div> {{ __('Edit') }}
</span>
</Button>
<div class="flex items-center mb-3">
<Users class="h-4 w-4 text-gray-700" />
<span class="ml-1">
{{ course.data.enrollment_count_formatted }} {{ __('Enrolled') }}
</span>
</div>
<div class="flex items-center mb-3">
<BookOpen class="h-4 w-4 text-gray-700" />
<span class="ml-1">
{{ course.data.lesson_count }} {{ __('Lessons') }}
</span>
</div>
<div class="flex items-center">
<Star class="h-4 w-4 fill-orange-500 text-gray-100" />
<span class="ml-1">
{{ course.data.avg_rating }} {{ __('Rating') }}
</span>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from "frappe-ui" import { Button, createResource } from 'frappe-ui'
import { createToast } from "@/utils/" import { createToast } from '@/utils/'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const user = inject("$user"); const user = inject('$user')
const props = defineProps({ const props = defineProps({
course: { course: {
type: Object, type: Object,
default: null, default: null,
}, },
}); })
const video_link = computed(() => { const video_link = computed(() => {
if (props.course.data.video_link) { if (props.course.data.video_link) {
return "https://www.youtube.com/embed/" + props.course.data.video_link; return 'https://www.youtube.com/embed/' + props.course.data.video_link
} }
return null; return null
}); })
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
createToast({ createToast({
title: "Please Login", title: 'Please Login',
icon: 'alert-circle', icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100', iconClasses: 'text-yellow-600 bg-yellow-100',
}) })
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 3000) }, 3000)
} else { } else {
const enrollStudentResource = createResource({ const enrollStudentResource = createResource({
url: "lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership" url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
}) })
console.log(props.course) console.log(props.course)
enrollStudentResource.submit({ enrollStudentResource
course: props.course.data.name .submit({
}).then(() => { course: props.course.data.name,
createToast({ })
title: "Enrolled Successfully", .then(() => {
icon: 'check', createToast({
iconClasses: 'text-green-600 bg-green-100', title: 'Enrolled Successfully',
}) icon: 'check',
setTimeout(() => { iconClasses: 'text-green-600 bg-green-100',
router.push({ name: 'Lesson', params: { courseName: props.course.data.name, chapterNumber: 1, lessonNumber: 1 } }) })
}, 3000) setTimeout(() => {
}) router.push({
} name: 'Lesson',
params: {
courseName: props.course.data.name,
chapterNumber: 1,
lessonNumber: 1,
},
})
}, 3000)
})
}
} }
</script> </script>

View File

@@ -1,67 +1,96 @@
<template> <template>
<div class="course-outline text-base"> <div class="course-outline text-base">
<div class="mt-4"> <div class="mt-4">
<Disclosure v-slot="{ open }" v-for="(chapter, index) in outline.data" :key="chapter.name" :defaultOpen="chapter.idx == route.params.chapterNumber"> <Disclosure
<DisclosureButton class="flex w-full px-2 pt-2 pb-3"> v-slot="{ open }"
<ChevronRight v-for="(chapter, index) in outline.data"
:class="{'rotate-90 transform duration-200' : open, 'duration-200' : !open, 'open': index == 1}" :key="chapter.name"
class="h-5 w-5 text-gray-900 stroke-1 mr-2" :defaultOpen="openChapter(chapter.idx)"
/> >
<div class="text-base font-medium"> <DisclosureButton class="flex w-full px-2 pt-2 pb-3">
{{ chapter.title }} <ChevronRight
</div> :class="{
</DisclosureButton> 'rotate-90 transform duration-200': open,
<DisclosurePanel class="pb-2"> 'duration-200': !open,
<div v-for="lesson in chapter.lessons" :key="lesson.name"> open: index == 1,
<div class="outline-lesson mb-2 pl-9"> }"
<router-link :to='{ class="h-5 w-5 text-gray-900 stroke-1 mr-2"
name: "Lesson", />
params: { <div class="text-base">
courseName: courseName, {{ chapter.title }}
chapterNumber: lesson.number.split(".")[0], </div>
lessonNumber: lesson.number.split(".")[1], </DisclosureButton>
} <DisclosurePanel class="pb-2">
}'> <div v-for="lesson in chapter.lessons" :key="lesson.name">
<div class="flex items-center text-sm"> <div class="outline-lesson mb-2 pl-9">
<MonitorPlay v-if="lesson.icon === 'icon-youtube'" class="h-4 w-4 text-gray-900 stroke-1 mr-2"/> <router-link
<HelpCircle v-else-if="lesson.icon === 'icon-quiz'" class="h-4 w-4 text-gray-900 stroke-1 mr-2"/> :to="{
<FileText v-else-if="lesson.icon === 'icon-list'" class="h-4 w-4 text-gray-900 stroke-1 mr-2"/> name: 'Lesson',
{{ lesson.title }} params: {
</div> courseName: courseName,
</router-link> chapterNumber: lesson.number.split('.')[0],
</div> lessonNumber: lesson.number.split('.')[1],
</div> },
</DisclosurePanel> }"
</Disclosure> >
</div> <div class="flex items-center text-sm">
</div> <MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
{{ lesson.title }}
</div>
</router-link>
</div>
</div>
</DisclosurePanel>
</Disclosure>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { createResource } from "frappe-ui"; import { createResource } from 'frappe-ui'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'; import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { ChevronRight, MonitorPlay, HelpCircle, FileText } from 'lucide-vue-next'; import {
import { useRoute } from "vue-router"; ChevronRight,
MonitorPlay,
HelpCircle,
FileText,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
const route = useRoute(); const route = useRoute()
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
type: String, type: String,
required: true, required: true,
}, },
}); })
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: { params: {
course: props.courseName course: props.courseName,
}, },
auto: true, auto: true,
}); })
const openChapter = (index) => {
return index == route.params.chapterNumber || index == 1
}
</script> </script>
<style> <style>
.outline-lesson:has(.router-link-active) { .outline-lesson:has(.router-link-active) {
background-color: theme('colors.gray.100'); background-color: theme('colors.gray.100');
padding: 0.5rem 0 0.5rem 2rem; padding: 0.5rem 0 0.5rem 2rem;
} }
</style> </style>

View 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>

View File

@@ -1,13 +1,123 @@
<template> <template>
<div class="h-screen"> <div v-if="batch.doc" class="h-screen text-base">
this is a batch <header class="sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5">
</div> <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">&middot;</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">&middot;</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> </template>
<script setup> <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({ const props = defineProps({
batchName: { batchName: {
type: String, type: String,
required: true, 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>

View File

@@ -1,123 +1,140 @@
<template> <template>
<div v-if="course.data" class="h-screen text-base"> <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"> <header
<Breadcrumbs class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
class="h-7" >
:items="breadcrumbs" <Breadcrumbs class="h-7" :items="breadcrumbs" />
/> </header>
</header> <div class="m-5">
<div class="m-5"> <div>
<div> <div class="text-3xl font-semibold">
<div class="text-3xl font-semibold"> {{ course.data.title }}
{{ course.data.title }} </div>
</div> <div class="my-3">
<div class="mt-1"> {{ course.data.short_introduction }}
{{ course.data.short_introduction }} </div>
</div> <div class="flex items-center justify-between w-1/3">
<div class="flex items-center justify-between mt-3 w-1/3"> <div v-if="course.data.avg_rating" class="flex items-center">
<div v-if="course.data.avg_rating" class="flex items-center"> <Star class="h-5 w-5 text-gray-100 fill-orange-500" />
<Star class="h-5 w-5 text-gray-100 fill-orange-500"/> <span class="ml-1">
<span class="ml-1"> {{ course.data.avg_rating }}
{{ course.data.avg_rating }} </span>
</span> </div>
</div> <span v-if="course.data.avg_rating">&middot;</span>
<span v-if="course.data.avg_rating">&middot;</span> <div v-if="course.data.enrollment_count" class="flex items-center">
<div v-if="course.data.enrollment_count" class="flex items-center"> <Users class="h-4 w-4 text-gray-700" />
<Users class="h-4 w-4 text-gray-700"/> <span class="ml-1">
<span class="ml-1"> {{ course.data.enrollment_count_formatted }}
{{ course.data.enrollment_count_formatted }} </span>
</span> </div>
</div> <span v-if="course.data.enrollment_count">&middot;</span>
<span v-if="course.data.enrollment_count">&middot;</span> <div class="flex items-center">
<div class="flex items-center"> <span
<span class="mr-1" :class="{ 'avatar-group overlap': course.data.instructors.length > 1 }"> class="mr-1"
<UserAvatar v-for="instructor in course.data.instructors" :user="instructor"/> :class="{
</span> 'avatar-group overlap': course.data.instructors.length > 1,
<span v-if="course.data.instructors.length == 1"> }"
{{ course.data.instructors[0].full_name }} >
</span> <UserAvatar
<span v-if="course.data.instructors.length == 2"> v-for="instructor in course.data.instructors"
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors[1].first_name }} :user="instructor"
</span> />
<span v-if="course.data.instructors.length > 2"> </span>
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors.length - 1 }} others <span v-if="course.data.instructors.length == 1">
</span> {{ course.data.instructors[0].full_name }}
</div> </span>
</div> <span v-if="course.data.instructors.length == 2">
</div> {{ course.data.instructors[0].first_name }} and
<div class="grid grid-cols-[60%,20%] gap-20 mt-10"> {{ course.data.instructors[1].first_name }}
<div class=""> </span>
<div v-html="course.data.description" class="course-description"></div> <span v-if="course.data.instructors.length > 2">
<div class="mt-10"> {{ course.data.instructors[0].first_name }} and
<div class="text-2xl font-semibold"> {{ course.data.instructors.length - 1 }} others
{{ __("Course Content") }} </span>
</div> </div>
<CourseOutline :courseName="course.data.name"/> </div>
</div> </div>
<CourseReviews v-if="course.data.avg_rating" :courseName="course.data.name" :avg_rating="course.data.avg_rating" :membership="course.data.membership"/> <div class="grid grid-cols-[60%,20%] gap-20 mt-10">
</div> <div class="">
<div> <div
<CourseCardOverlay :course="course"/> v-html="course.data.description"
</div> class="course-description"
</div> ></div>
</div> <div class="mt-10">
</div> <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> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs } from "frappe-ui"; import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed } from "vue"; import { computed } from 'vue'
import { Users, Star } from 'lucide-vue-next' import { Users, Star } from 'lucide-vue-next'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'; import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
import CourseOutline from '@/components/CourseOutline.vue'; import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'; import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
type: String, type: String,
required: true, required: true,
}, },
}) })
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: { params: {
course: props.courseName course: props.courseName,
}, },
auto: true, auto: true,
}); })
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let items = [{ label: "All Courses", route: { name: "Courses" } }] let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({ items.push({
label: course?.data?.title, label: course?.data?.title,
route: { name: "CourseDetail", params: { course: course?.data?.name } }, route: { name: 'CourseDetail', params: { course: course?.data?.name } },
}) })
return items return items
}) })
</script> </script>
<style> <style>
.course-description p { .course-description p {
margin-bottom: 1rem; margin-bottom: 1rem;
line-height: 1.7; line-height: 1.7;
} }
.course-description li { .course-description li {
line-height: 1.7; line-height: 1.7;
} }
.course-description ol { .course-description ol {
list-style: auto; list-style: auto;
margin: revert; margin: revert;
padding: revert; padding: revert;
} }
.avatar-group { .avatar-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.avatar-group .avatar { .avatar-group .avatar {
transition: margin 0.1s ease-in-out; transition: margin 0.1s ease-in-out;
} }
</style> </style>

View File

@@ -1,276 +1,388 @@
<template> <template>
<div v-if="lesson.data && course.data" class="h-screen text-base"> <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"> <header
<Breadcrumbs class="h-7" :items="breadcrumbs" /> class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
</header> >
<div class="grid grid-cols-[70%,30%] h-full"> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<div v-if="lesson.data.no_preview" class="border-r-2 text-center pt-10"> </header>
<p class="mb-4"> <div class="grid grid-cols-[70%,30%] h-full">
{{ __("This lesson is not available for preview. Please enroll in the course to access it.") }} <div v-if="lesson.data.no_preview" class="border-r-2 text-center pt-10">
</p> <p class="mb-4">
<router-link :to='{ name: "CourseDetail", params: { courseName: courseName } }'> {{
<Button variant="solid"> __(
{{ __("Start Learning") }} 'This lesson is not available for preview. Please enroll in the course to access it.'
</Button> )
</router-link> }}
</div> </p>
<div v-else class="border-r-2 container pt-5 pb-10"> <router-link
<div class="flex items-center justify-between"> :to="{ name: 'CourseDetail', params: { courseName: courseName } }"
<div class="text-3xl font-semibold"> >
{{ lesson.data.title }} <Button variant="solid">
</div> {{ __('Start Learning') }}
<div> </Button>
<router-link v-if="lesson.data.prev" :to='{name: "Lesson", params: { </router-link>
courseName: courseName, </div>
chapterNumber: lesson.data.prev.split(".")[0], <div v-else class="border-r-2 container pt-5 pb-10">
lessonNumber: lesson.data.prev.split(".")[1] <div class="flex items-center justify-between">
}}'> <div class="text-3xl font-semibold">
<Button class="mr-2"> {{ lesson.data.title }}
<ChevronLeft class="w-4 h-4 stroke-1"/> </div>
</Button> <div>
</router-link> <router-link
<router-link v-if="lesson.data.next" :to='{name: "Lesson", params: { v-if="lesson.data.prev"
courseName: courseName, :to="{
chapterNumber: lesson.data.next.split(".")[0], name: 'Lesson',
lessonNumber: lesson.data.next.split(".")[1] params: {
}}'> courseName: courseName,
<Button> chapterNumber: lesson.data.prev.split('.')[0],
<ChevronRight class="w-4 h-4 stroke-1"/> lessonNumber: lesson.data.prev.split('.')[1],
</Button> },
</router-link> }"
</div> >
</div> <Button class="mr-2">
<ChevronLeft class="w-4 h-4 stroke-1" />
<div class="flex items-center mt-2"> </Button>
<span class="mr-1" :class="{ 'avatar-group overlap': course.data.instructors.length > 1 }"> </router-link>
<UserAvatar v-for="instructor in course.data.instructors" :user="instructor" /> <router-link
</span> v-if="lesson.data.next"
<span v-if="course.data.instructors.length == 1"> :to="{
{{ course.data.instructors[0].full_name }} name: 'Lesson',
</span> params: {
<span v-if="course.data.instructors.length == 2"> courseName: courseName,
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors[1].first_name }} chapterNumber: lesson.data.next.split('.')[0],
</span> lessonNumber: lesson.data.next.split('.')[1],
<span v-if="course.data.instructors.length > 2"> },
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors.length - 1 }} others }"
</span> >
</div> <Button>
<!-- <div v-html="lesson.data.rendered_content" class="lesson-content mt-6"></div> --> <ChevronRight class="w-4 h-4 stroke-1" />
<div class="lesson-content mt-6"> </Button>
<div v-for="block in lesson.data.body.split('\n\n')"> </router-link>
<div v-if='block.includes("{{ YouTubeVideo")'> </div>
<iframe class="youtube-video" :src="getYouTubeVideoSource(block)" width="100%" height="400" frameborder="0" allowfullscreen></iframe> </div>
</div>
<div v-else-if='block.includes("{{ Quiz")'> <div class="flex items-center mt-2">
<Quiz v-if="user.data" :quizName="getId(block)"></Quiz> <span
<div v-else class="border rounded-md text-center py-20"> class="mr-1"
<div> :class="{
{{ __("Please login to access the quiz.") }} 'avatar-group overlap': course.data.instructors.length > 1,
</div> }"
<Button @click="redirectToLogin()" class="mt-2"> >
<span> <UserAvatar
{{ __("Login") }} v-for="instructor in course.data.instructors"
</span> :user="instructor"
</Button> />
</div> </span>
</div> <span v-if="course.data.instructors.length == 1">
<div v-else-if='block.includes("{{ Video")'> {{ course.data.instructors[0].full_name }}
<video controls width='100%' controlsList='nodownload'> </span>
<source :src="getId(block)" type='video/mp4'> <span v-if="course.data.instructors.length == 2">
</video> {{ course.data.instructors[0].first_name }} and
</div> {{ course.data.instructors[1].first_name }}
<div v-else-if='block.includes("{{ PDF")'> </span>
<iframe :src="getPDFSource(block)" width="100%" height="400" frameborder="0" allowfullscreen></iframe> <span v-if="course.data.instructors.length > 2">
</div> {{ course.data.instructors[0].first_name }} and
<div v-else-if='block.includes("{{ Audio")'> {{ course.data.instructors.length - 1 }} others
<audio width='100%' controls controlsList='nodownload'> </span>
<source :src="getId(block)" type='audio/mp3'> </div>
</audio> <div class="lesson-content mt-6">
</div> <div v-if="lesson.data.youtube">
<div v-else-if='block.includes("{{ Embed")'> <iframe
<iframe width="100%" height="400" :src="getId(block)" frameborder="0" allowfullscreen> class="youtube-video"
</iframe> :src="getYouTubeVideoSource(lesson.data.youtube)"
</div> width="100%"
<div v-else v-html="markdown.render(block)"> height="400"
</div> frameborder="0"
</div> allowfullscreen
</div> ></iframe>
</div> </div>
<div class="sticky top-10"> <div v-for="block in lesson.data.body.split('\n\n')">
<div class="bg-gray-50 p-5 border-b-2"> <div v-if="block.includes('{{ YouTubeVideo')">
<div class="text-lg font-semibold"> <iframe
{{ course.data.title }} class="youtube-video"
</div> :src="getYouTubeVideoSource(block)"
<div v-if="user && course.data.membership" class="text-sm mt-3"> width="100%"
{{ Math.ceil(course.data.membership.progress) }}% completed height="400"
</div> frameborder="0"
<div v-if="user && course.data.membership" class="w-full bg-gray-200 rounded-full h-1 my-2"> allowfullscreen
<div class="bg-gray-900 h-1 rounded-full" ></iframe>
:style="{ width: Math.ceil(course.data.membership.progress) + '%' }"></div> </div>
</div> <div v-else-if="block.includes('{{ Quiz')">
</div> <Quiz v-if="user.data" :quizName="getId(block)"></Quiz>
<CourseOutline :courseName="courseName" :key="chapterNumber" /> <div v-else class="border rounded-md text-center py-20">
</div> <div>
</div> {{ __('Please login to access the quiz.') }}
</div> </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> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs, Button } from "frappe-ui"; import { createResource, Breadcrumbs, Button } from 'frappe-ui'
import { computed, watch, onBeforeMount, onUnmounted, inject } from "vue"; import { computed, watch, onBeforeMount, onUnmounted, inject } from 'vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import CourseOutline from '@/components/CourseOutline.vue'; import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'; import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router'
import MarkdownIt from "markdown-it"; import MarkdownIt from 'markdown-it'
import { ChevronLeft, ChevronRight } from "lucide-vue-next"; import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import Quiz from '@/components/Quiz.vue'; import Quiz from '@/components/Quiz.vue'
const user = inject("$user"); const user = inject('$user')
const route = useRoute(); const route = useRoute()
const markdown = new MarkdownIt({ const markdown = new MarkdownIt({
html: true, html: true,
linkify: true, linkify: true,
});
onBeforeMount(() => {
localStorage.setItem("sidebar_is_collapsed", true);
}) })
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
type: String, type: String,
required: true, required: true,
}, },
chapterNumber: { chapterNumber: {
type: String, type: String,
required: true, required: true,
}, },
lessonNumber: { lessonNumber: {
type: String, type: String,
required: true, required: true,
}, },
}); })
const lesson = createResource({ const lesson = createResource({
url: "lms.lms.utils.get_lesson", url: 'lms.lms.utils.get_lesson',
cache: ["lesson", props.courseName, props.chapterNumber, props.lessonNumber], cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
makeParams(values) { makeParams(values) {
return { return {
course: props.courseName, course: props.courseName,
chapter: values ? values.chapter : props.chapterNumber, chapter: values ? values.chapter : props.chapterNumber,
lesson: values ? values.lesson : props.lessonNumber, lesson: values ? values.lesson : props.lessonNumber,
} }
}, },
auto: true, 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({ 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: { params: {
course: props.courseName course: props.courseName,
}, },
auto: true, auto: true,
}); })
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let items = [{ label: "All Courses", route: { name: "Courses" } }] let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({ items.push({
label: course?.data?.title, label: course?.data?.title,
route: { name: "CourseDetail", params: { course: props.courseName } }, route: { name: 'CourseDetail', params: { course: props.courseName } },
}) })
items.push({ items.push({
label: lesson?.data?.title, label: lesson?.data?.title,
route: { name: "Lesson", params: { course: props.courseName, chapterNumber: props.chapterNumber, lessonNumber: props.lessonNumber } }, route: {
}) name: 'Lesson',
return items params: {
}); course: props.courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
},
})
return items
})
onBeforeMount(() => {
localStorage.setItem('sidebar_is_collapsed', true)
})
onUnmounted(() => { onUnmounted(() => {
useStorage("sidebar_is_collapsed", false); localStorage.setItem('sidebar_is_collapsed', false)
}); })
watch( watch(
[() => route.params.chapterNumber, () => route.params.lessonNumber], [() => route.params.chapterNumber, () => route.params.lessonNumber],
([newChapterNumber, newLessonNumber], [oldChapterNumber, oldLessonNumber]) => { (
lesson.submit({ [newChapterNumber, newLessonNumber],
chapter: newChapterNumber, [oldChapterNumber, oldLessonNumber]
lesson: newLessonNumber, ) => {
}) if (newChapterNumber && newLessonNumber) {
} lesson.submit({
); chapter: newChapterNumber,
lesson: newLessonNumber,
})
}
}
)
const getYouTubeVideoSource = (block) => { 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) => { const getPDFSource = (block) => {
return `${getId(block)}#toolbar=0`; return `${getId(block)}#toolbar=0`
} }
const getId = (block) => { const getId = (block) => {
return block.match(/\(["']([^"']+?)["']\)/)[1]; return block.match(/\(["']([^"']+?)["']\)/)[1]
} }
const redirectToLogin = () => { 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> </script>
<style> <style>
.avatar-group { .avatar-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.avatar-group .avatar { .avatar-group .avatar {
transition: margin 0.1s ease-in-out; transition: margin 0.1s ease-in-out;
} }
iframe { iframe {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 0.5rem; border-radius: 0.5rem;
margin-bottom: 1rem;
} }
.lesson-content p { .lesson-content p {
margin-bottom: 1rem; margin-bottom: 1rem;
line-height: 1.7; line-height: 1.7;
} }
.lesson-content li { .lesson-content li {
line-height: 1.7; line-height: 1.7;
} }
.lesson-content ol { .lesson-content ol {
list-style: auto; list-style: auto;
margin: revert; margin: revert;
padding: 1rem; padding: 1rem;
} }
.lesson-content ul { .lesson-content ul {
list-style: auto; list-style: auto;
padding: 1rem; padding: 1rem;
margin: revert; margin: revert;
} }
.lesson-content img { .lesson-content img {
border: 1px solid theme("colors.gray.200"); border: 1px solid theme('colors.gray.200');
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.lesson-content code { .lesson-content code {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
background: #011627; background: #011627;
color: #d6deeb; color: #d6deeb;
border-radius: 0.5rem; border-radius: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.lesson-content a { .lesson-content a {
color: theme("colors.gray.900"); color: theme('colors.gray.900');
text-decoration: underline; text-decoration: underline;
font-weight: 500; font-weight: 500;
} }
</style>
</style>

View File

@@ -32,9 +32,16 @@ const routes = [
component: () => import('@/pages/Batches.vue'), component: () => import('@/pages/Batches.vue'),
}, },
{ {
path: '/batches/:batchName', path: '/batches/details/:batchName',
name: 'BatchDetail', name: 'BatchDetail',
component: () => import('@/pages/BatchDetail.vue'), component: () => import('@/pages/BatchDetail.vue'),
props: true,
},
{
path: '/batches/:batchName',
name: 'Batch',
component: () => import('@/pages/Batch.vue'),
props: true,
}, },
] ]

View File

@@ -11,3 +11,31 @@ export function createToast(options) {
export function timeAgo(date) { export function timeAgo(date) {
return useTimeAgo(date).value return useTimeAgo(date).value
} }
export function formatTime(timeString) {
if (!timeString) return ''
const [hour, minute] = timeString.split(':').map(Number)
// Create a Date object with dummy values for day, month, and year
const dummyDate = new Date(0, 0, 0, hour, minute)
// Use Intl.DateTimeFormat to format the time in 12-hour format
const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(dummyDate)
return formattedTime
}
export function formatNumberIntoCurrency(number, currency) {
if (number) {
return number.toLocaleString('en-IN', {
maximumFractionDigits: 0,
style: 'currency',
currency: currency,
})
}
return ''
}

View File

@@ -39,10 +39,7 @@ def save_current_lesson(course_name, lesson_name):
) )
if not name: if not name:
return return
doc = frappe.get_doc("LMS Enrollment", name) frappe.db.set_value("LMS Enrollment", name, "current_lesson", lesson_name)
doc.current_lesson = lesson_name
doc.save()
return {"current_lesson": doc.current_lesson}
@frappe.whitelist() @frappe.whitelist()

View File

@@ -1322,6 +1322,7 @@ def get_lesson(course, chapter, lesson):
neighbours = get_neighbour_lesson(course, chapter, lesson) neighbours = get_neighbour_lesson(course, chapter, lesson)
lesson_details.next = neighbours["next"] lesson_details.next = neighbours["next"]
lesson_details.prev = neighbours["prev"] lesson_details.prev = neighbours["prev"]
lesson_details.membership = membership
return lesson_details return lesson_details