feat: batch details
This commit is contained in:
@@ -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)^(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
81
frontend/src/components/BatchOverlay.vue
Normal file
81
frontend/src/components/BatchOverlay.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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>
|
<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">·</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>
|
</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>
|
||||||
|
|||||||
@@ -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">·</span>
|
||||||
<span v-if="course.data.avg_rating">·</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">·</span>
|
||||||
<span v-if="course.data.enrollment_count">·</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user