feat: course details page
This commit is contained in:
@@ -68,10 +68,8 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/user'
|
||||
|
||||
const { isLoggedIn } = sessionStore()
|
||||
const { getUser } = usersStore()
|
||||
const { isLoggedIn, getUser } = sessionStore()
|
||||
const user = computed(() => isLoggedIn && getUser())
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
47
frontend/src/components/CourseCardOverlay.vue
Normal file
47
frontend/src/components/CourseCardOverlay.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="border border-gray-200" style="width: 300px;">
|
||||
<iframe v-if="course.data.video_link" :src="video_link" />
|
||||
<div>
|
||||
<Button variant="solid" class="w-full">
|
||||
<span>
|
||||
{{ __("Start Learning") }}
|
||||
</span>
|
||||
</Button>
|
||||
<div class="flex items-center">
|
||||
<Users class="h-4 w-4 text-gray-700"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Star class="h-5 w-5 fill-orange-500 text-gray-100"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 text-gray-700"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.lesson_count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const video_link = computed(() => {
|
||||
if (props.course.data.video_link) {
|
||||
return "https://www.youtube.com/embed/" + props.course.data.video_link;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
21
frontend/src/components/CourseOutline.vue
Normal file
21
frontend/src/components/CourseOutline.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
{{ outline }}
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource } from "frappe-ui";
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
console.log(props);
|
||||
const outline = createResource({
|
||||
url: "lms.lms.utils.get_course_outline",
|
||||
cache: ["course_outline", props.courseName],
|
||||
params: {
|
||||
course: props.courseName
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
</script>
|
||||
@@ -33,7 +33,6 @@
|
||||
<script setup>
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
@@ -45,16 +44,17 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const { logout, isLoggedIn } = sessionStore()
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const { getUser, logout } = sessionStore()
|
||||
let { isLoggedIn } = sessionStore();
|
||||
const user = computed(() => isLoggedIn && getUser())
|
||||
const userDropdownOptions = [
|
||||
{
|
||||
icon: 'log-out',
|
||||
label: 'Log out',
|
||||
onClick: () => {
|
||||
logout.submit()
|
||||
logout.submit().then(() => {
|
||||
isLoggedIn = false;
|
||||
});
|
||||
},
|
||||
condition: () => {
|
||||
return isLoggedIn
|
||||
|
||||
@@ -1,7 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
Course Detail
|
||||
<div v-if="course.data" class="h-screen text-base">
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5">
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="breadcrumbs"
|
||||
/>
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div>
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3 w-1/3">
|
||||
<div class="flex items-center">
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
</span>
|
||||
</div>
|
||||
·
|
||||
<div class="flex items-center">
|
||||
<Users class="h-4 w-4 text-gray-700"/>
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count }}
|
||||
</span>
|
||||
</div>
|
||||
·
|
||||
<div class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 text-gray-700 mr-1"/>
|
||||
<span v-if="course.data.instructors.length == 1">
|
||||
{{ course.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 2">
|
||||
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length > 2">
|
||||
{{ course.data.instructors[0].first_name }} and {{ course.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[70%,20%] gap-10">
|
||||
<div>
|
||||
<div v-html="course.data.description"></div>
|
||||
<CourseOutline :courseName="course.data.name"/>
|
||||
</div>
|
||||
<div>
|
||||
<CourseCardOverlay :course="course"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue';
|
||||
import CourseOutline from '@/components/CourseOutline.vue';
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
console.log(props.courseName)
|
||||
const course = createResource({
|
||||
url: "lms.lms.utils.get_course_details",
|
||||
cache: ["course", props.courseName],
|
||||
params: {
|
||||
course: props.courseName
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
console.log(course)
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: "All Courses", route: { name: "Courses" } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: "CourseDetail", params: { course: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ tab }">
|
||||
<div v-if="tab.courses && tab.courses.value.length" class="grid grid-cols-3 gap-8 mt-5" >
|
||||
<router-link v-for="course in tab.courses.value" :to="{ name: 'CourseDetail', params: { course: course.name } }">
|
||||
<div v-if="tab.courses && tab.courses.value.length" class="grid grid-cols-3 gap-8 mt-5">
|
||||
<router-link v-for="course in tab.courses.value" :to="{ name: 'CourseDetail', params: { courseName: course.name } }">
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -62,17 +62,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { createListResource, Breadcrumbs, Tabs, Badge, Select } from 'frappe-ui';
|
||||
import CourseCard from '@/components/CourseCard.vue';
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { ref, computed } from 'vue'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
|
||||
const { isLoggedIn } = sessionStore()
|
||||
const { getUser } = usersStore()
|
||||
const { isLoggedIn, getUser } = sessionStore()
|
||||
const user = computed(() => isLoggedIn && getUser())
|
||||
|
||||
const courses = createListResource({
|
||||
type: 'list',
|
||||
cache: "courses",
|
||||
@@ -81,39 +78,58 @@ const courses = createListResource({
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const is_moderator = computed(() => {
|
||||
if (user && user.value?.roles?.includes('Moderator')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const is_instructor = computed(() => {
|
||||
if (user && user.value?.roles?.includes('Course Creator')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Live',
|
||||
courses: computed(() => courses.data?.live || []),
|
||||
count: computed(() => courses.data?.live?.length),
|
||||
show: true
|
||||
},
|
||||
{
|
||||
label: 'Upcoming',
|
||||
courses: computed(() => courses.data?.upcoming),
|
||||
count: computed(() => courses.data?.upcoming?.length),
|
||||
show: true
|
||||
},
|
||||
{
|
||||
}
|
||||
];
|
||||
|
||||
if (user.value) {
|
||||
tabs.push({
|
||||
label: 'Enrolled',
|
||||
courses: computed(() => courses.data?.enrolled),
|
||||
count: computed(() => courses.data?.enrolled?.length),
|
||||
show: user
|
||||
},
|
||||
{
|
||||
label: 'Created',
|
||||
courses: computed(() => courses.data?.created),
|
||||
count: computed(() => courses.data?.created?.length),
|
||||
show: computed(() => user && (user.roles.includes('Course Creator') || user.roles.includes('Moderator')))
|
||||
},
|
||||
{
|
||||
label: 'Under Review',
|
||||
courses: computed(() => courses.data?.under_review),
|
||||
count: computed(() => courses.data?.under_review?.length),
|
||||
show: computed(() => user && user.roles.includes('Moderator'))
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (is_moderator.value || is_instructor.value || courses.data?.created?.length) {
|
||||
tabs.push({
|
||||
label: 'Created',
|
||||
courses: computed(() => courses.data?.created),
|
||||
count: computed(() => courses.data?.created?.length),
|
||||
});
|
||||
};
|
||||
|
||||
if (is_moderator.value) {
|
||||
tabs.push({
|
||||
label: 'Under Review',
|
||||
courses: computed(() => courses.data?.under_review),
|
||||
count: computed(() => courses.data?.under_review?.length),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const orderOptions = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -14,7 +12,7 @@ const routes = [
|
||||
component: () => import('@/pages/Courses.vue'),
|
||||
},
|
||||
{
|
||||
path: '/courses/:course',
|
||||
path: '/courses/:courseName',
|
||||
name: 'CourseDetail',
|
||||
component: () => import('@/pages/CourseDetail.vue'),
|
||||
props: true,
|
||||
@@ -26,9 +24,4 @@ let router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
const { users } = usersStore()
|
||||
await users.promise
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,9 +5,9 @@ import router from '@/router'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const sessionStore = defineStore('lms-session', () => {
|
||||
const { users } = usersStore()
|
||||
const { user, usersByName } = usersStore()
|
||||
|
||||
function sessionUser() {
|
||||
function currentUser() {
|
||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||
let _sessionUser = cookies.get('user_id')
|
||||
if (_sessionUser === 'Guest') {
|
||||
@@ -16,9 +16,18 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
return _sessionUser
|
||||
}
|
||||
|
||||
let user = ref(sessionUser())
|
||||
let sessionUser = ref(currentUser())
|
||||
const isLoggedIn = ref(!!sessionUser.value)
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
function getUser() {
|
||||
if (!sessionUser.value) {
|
||||
return null
|
||||
}
|
||||
if (usersByName[sessionUser.value]) {
|
||||
return usersByName[sessionUser.value]
|
||||
}
|
||||
return user.value
|
||||
}
|
||||
|
||||
const login = createResource({
|
||||
url: 'login',
|
||||
@@ -26,8 +35,8 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
throw new Error('Invalid email or password')
|
||||
},
|
||||
onSuccess() {
|
||||
users.reload()
|
||||
user.value = sessionUser()
|
||||
user.reload()
|
||||
sessionUser.value = currentUser()
|
||||
login.reset()
|
||||
router.replace({ path: '/' })
|
||||
},
|
||||
@@ -36,15 +45,16 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
const logout = createResource({
|
||||
url: 'logout',
|
||||
onSuccess() {
|
||||
users.reset()
|
||||
user.value = null
|
||||
user.reset()
|
||||
sessionUser.value = null
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
sessionUser,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
getUser,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { sessionStore } from './session'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const usersStore = defineStore('lms-users', () => {
|
||||
const session = sessionStore()
|
||||
|
||||
let usersByName = reactive({})
|
||||
|
||||
const users = createResource({
|
||||
const user = createResource({
|
||||
url: 'lms.lms.api.get_user_info',
|
||||
cache: 'Users',
|
||||
initialData: [],
|
||||
transform(users) {
|
||||
for (let user of users) {
|
||||
usersByName[user.name] = user
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
if (data?.name && !usersByName[data.name]) {
|
||||
usersByName[data.name] = data
|
||||
}
|
||||
return users
|
||||
},
|
||||
onError(error) {
|
||||
if (error && error.exc_type === 'AuthenticationError') {
|
||||
@@ -25,27 +22,8 @@ export const usersStore = defineStore('lms-users', () => {
|
||||
},
|
||||
})
|
||||
|
||||
function getUser(email) {
|
||||
if (!email || email === 'sessionUser') {
|
||||
email = session.user
|
||||
}
|
||||
if (!email) {
|
||||
return null
|
||||
}
|
||||
if (!usersByName[email]) {
|
||||
usersByName[email] = {
|
||||
name: email,
|
||||
email: email,
|
||||
full_name: email.split('@')[0],
|
||||
user_image: null,
|
||||
roles: ['LMS Student'],
|
||||
}
|
||||
}
|
||||
return usersByName[email]
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
getUser,
|
||||
user,
|
||||
usersByName,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -143,22 +143,18 @@ def add_mentor_to_subgroup(subgroup, email):
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_user_info(user=None):
|
||||
def get_user_info():
|
||||
if frappe.session.user == "Guest":
|
||||
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
|
||||
filters = {}
|
||||
if user:
|
||||
filters["name"] = user
|
||||
return None
|
||||
|
||||
users = frappe.qb.get_query(
|
||||
user = frappe.db.get_value(
|
||||
"User",
|
||||
filters=filters,
|
||||
fields=["name", "email", "enabled", "user_image", "full_name", "user_type"],
|
||||
order_by="full_name asc",
|
||||
distinct=True,
|
||||
).run(as_dict=1)
|
||||
frappe.session.user,
|
||||
["name", "email", "enabled", "user_image", "full_name", "user_type"],
|
||||
as_dict=1,
|
||||
)
|
||||
user["roles"] = frappe.get_roles(user.name)
|
||||
return users
|
||||
return user
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
|
||||
117
lms/lms/utils.py
117
lms/lms/utils.py
@@ -1152,59 +1152,65 @@ def change_currency(amount, currency, country=None):
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_courses():
|
||||
"""Returns the list of courses."""
|
||||
courses = frappe.get_all(
|
||||
"LMS Course",
|
||||
fields=[
|
||||
"name",
|
||||
"title",
|
||||
"short_introduction",
|
||||
"image",
|
||||
"published",
|
||||
"upcoming",
|
||||
"status",
|
||||
"paid_course",
|
||||
"course_price",
|
||||
"currency",
|
||||
],
|
||||
)
|
||||
courses = []
|
||||
course_list = frappe.get_all("LMS Course", pluck="name")
|
||||
for course in course_list:
|
||||
courses.append(get_course_details(course))
|
||||
|
||||
courses = get_course_details(courses)
|
||||
courses = get_categorized_courses(courses)
|
||||
return courses
|
||||
|
||||
|
||||
def get_course_details(courses):
|
||||
for course in courses:
|
||||
course.tags = get_tags(course.name)
|
||||
course.lesson_count = get_lesson_count(course.name)
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_course_details(course):
|
||||
print(course)
|
||||
course = frappe.db.get_value(
|
||||
"LMS Course",
|
||||
course,
|
||||
[
|
||||
"name",
|
||||
"title",
|
||||
"description",
|
||||
"image",
|
||||
"video_link",
|
||||
"short_introduction",
|
||||
"published",
|
||||
"upcoming",
|
||||
"status",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
print(course)
|
||||
course.tags = get_tags(course.name)
|
||||
course.lesson_count = get_lesson_count(course.name)
|
||||
|
||||
course.enrollment_count = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
|
||||
course.enrollment_count = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course.name) or 0
|
||||
course.avg_rating = frappe.utils.flt(
|
||||
avg_rating, frappe.get_system_settings("float_precision") or 3
|
||||
)
|
||||
|
||||
course.instructors = get_instructors(course.name)
|
||||
if course.paid_course:
|
||||
course.price = frappe.utils.fmt_money(course.course_price, 0, course.currency)
|
||||
else:
|
||||
course.price = _("Free")
|
||||
|
||||
if frappe.session.user == "Guest":
|
||||
course.membership = None
|
||||
course.is_instructor = False
|
||||
else:
|
||||
course.membership = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
{"member": frappe.session.user, "course": course.name},
|
||||
["name", "course", "current_lesson", "progress"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course.name) or 0
|
||||
course.avg_rating = frappe.utils.flt(
|
||||
avg_rating, frappe.get_system_settings("float_precision") or 3
|
||||
)
|
||||
|
||||
course.instructors = get_instructors(course.name)
|
||||
if course.paid_course:
|
||||
course.price = frappe.utils.fmt_money(course.course_price, 0, course.currency)
|
||||
else:
|
||||
course.price = _("Free")
|
||||
|
||||
if frappe.session.user == "Guest":
|
||||
course.membership = None
|
||||
course.is_instructor = False
|
||||
else:
|
||||
course.membership = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
{"member": frappe.session.user, "course": course.name},
|
||||
["name", "course", "current_lesson", "progress"],
|
||||
as_dict=1,
|
||||
)
|
||||
course.is_instructor = is_instructor(course.name)
|
||||
return courses
|
||||
course.is_instructor = is_instructor(course.name)
|
||||
return course
|
||||
|
||||
|
||||
def get_categorized_courses(courses):
|
||||
@@ -1234,3 +1240,22 @@ def get_categorized_courses(courses):
|
||||
"created": created,
|
||||
"under_review": under_review,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_course_outline(course):
|
||||
"""Returns the course outline."""
|
||||
outline = []
|
||||
chapters = frappe.get_all(
|
||||
"Chapter Reference", {"parent": course}, ["chapter"], order_by="idx"
|
||||
)
|
||||
for chapter in chapters:
|
||||
chapter_details = frappe.db.get_value(
|
||||
"Course Chapter",
|
||||
chapter.chapter,
|
||||
["name", "title", "description", "idx"],
|
||||
as_dict=True,
|
||||
)
|
||||
chapter_details.lessons = get_lessons(chapter.chapter)
|
||||
outline.append(chapter_details)
|
||||
return outline
|
||||
|
||||
Reference in New Issue
Block a user