feat: course list
This commit is contained in:
55
frontend/src/components/AppSidebar.vue
Normal file
55
frontend/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
|
||||
:class="isSidebarCollapsed ? 'w-12' : 'w-56'">
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<SidebarLink v-for="link in links" :icon="link.icon" :label="link.label" :to="link.to"
|
||||
:isCollapsed="isSidebarCollapsed" class="mx-2 my-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<SidebarLink :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>
|
||||
|
||||
<script setup>
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { BookOpen, Users, TrendingUp, Search, Bell, Briefcase, Settings } from 'lucide-vue-next'
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
to: 'Courses',
|
||||
},
|
||||
{
|
||||
label: "Batches",
|
||||
icon: Users,
|
||||
to: 'Batches',
|
||||
},
|
||||
{
|
||||
label: "Statistics",
|
||||
icon: TrendingUp,
|
||||
to: 'Statistics',
|
||||
},
|
||||
{
|
||||
label: "Jobs",
|
||||
icon: Briefcase,
|
||||
to: 'Jobs',
|
||||
},
|
||||
]
|
||||
|
||||
const isSidebarCollapsed = useStorage('sidebar_is_collapsed', false)
|
||||
</script>
|
||||
@@ -1,45 +1,94 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full border border-gray-200 rounded-md shadow-sm mt-5">
|
||||
<div class="course-image" :class="{'default-image': !course.image}" :style="{ backgroundImage: 'url(' + course.image + ')' }">
|
||||
<div class="flex flex-col border border-gray-200 h-full rounded-md shadow-sm" style="min-height: 320px;">
|
||||
<div class="course-image" :class="{'default-image': !course.image}" :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }">
|
||||
<div class="flex relative top-4 left-4">
|
||||
<div class="course-card-pills rounded-md border border-gray-200" v-for="tag in tags.data">
|
||||
<div class="course-card-pills rounded-md border border-gray-200" v-for="tag in course.tags">
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!course.image" class="flex flex-1 text-8xl font-bold">{{ course.title[0] }}</div>
|
||||
<div v-if="!course.image" class="image-placeholder">{{ course.title[0] }}</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="text-2xl font-semibold">
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex text-base 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 text-gray-700" />
|
||||
<span> {{ course.lesson_count }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count" class="flex items-center space-x-1 py-1">
|
||||
<Users class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.enrollment_count }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating" class="flex items-center space-x-1 py-1">
|
||||
<Star class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.avg_rating }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div class="short-introduction text-base">
|
||||
{{ course.short_introduction }}
|
||||
</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 text-base mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div class="mr-1" :class="{'avatar-group overlap': course.instructors.length > 1}">
|
||||
<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="text-base font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource } from 'frappe-ui';
|
||||
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 user = computed(() => isLoggedIn && getUser())
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const tags = createResource({
|
||||
url: "lms.lms.utils.get_tags",
|
||||
params: { course: props.course.name },
|
||||
auto: true,
|
||||
})
|
||||
});
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.course-image {
|
||||
height: 168px;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
height: 168px;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.course-card-pills {
|
||||
@@ -56,10 +105,40 @@ const tags = createResource({
|
||||
}
|
||||
|
||||
.default-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: theme('colors.gray.200');
|
||||
color: theme('colors.gray.700');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: theme('colors.gray.200');
|
||||
color: theme('colors.gray.700');
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 5rem;
|
||||
color: theme('colors.gray.700');
|
||||
font-weight: 600;
|
||||
}
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
}
|
||||
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
18
frontend/src/components/DesktopLayout.vue
Normal file
18
frontend/src/components/DesktopLayout.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="relative flex h-full flex-col">
|
||||
<div class="h-full flex-1">
|
||||
<div class="flex h-full">
|
||||
<div class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto">
|
||||
<slot name="sidebar" />
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="w-full overflow-auto" id="scrollContainer">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
</script>
|
||||
12
frontend/src/components/Icons/BatchIcon.vue
Normal file
12
frontend/src/components/Icons/BatchIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1584_1676)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.17474 0.625C2.34632 0.625 1.67474 1.29657 1.67474 2.125V7.475C1.67474 8.30343 2.34632 8.975 3.17474 8.975H14.8247C15.6532 8.975 16.3247 8.30343 16.3247 7.475V2.125C16.3247 1.29657 15.6532 0.625 14.8247 0.625H3.17474ZM2.67474 2.125C2.67474 1.84886 2.8986 1.625 3.17474 1.625H14.8247C15.1009 1.625 15.3247 1.84886 15.3247 2.125V7.475C15.3247 7.75114 15.1009 7.975 14.8247 7.975H3.17474C2.8986 7.975 2.67474 7.75114 2.67474 7.475V2.125ZM4.27478 10.0749C3.99864 10.0749 3.77478 10.2987 3.77478 10.5749V12.6749C3.77478 12.951 3.99864 13.1749 4.27478 13.1749C4.55092 13.1749 4.77478 12.951 4.77478 12.6749V11.0749H6.92478V12.6749C6.92478 12.951 7.14864 13.1749 7.42478 13.1749C7.70092 13.1749 7.92478 12.951 7.92478 12.6749V10.5749C7.92478 10.2987 7.70092 10.0749 7.42478 10.0749H4.27478ZM10.0749 10.5749C10.0749 10.2987 10.2987 10.0749 10.5749 10.0749H13.7249C14.001 10.0749 14.2249 10.2987 14.2249 10.5749V12.6749C14.2249 12.951 14.001 13.1749 13.7249 13.1749C13.4487 13.1749 13.2249 12.951 13.2249 12.6749V11.0749H11.0749V12.6749C11.0749 12.951 10.851 13.1749 10.5749 13.1749C10.2987 13.1749 10.0749 12.951 10.0749 12.6749V10.5749ZM1.125 14.275C0.848858 14.275 0.625 14.4988 0.625 14.775V16.875C0.625 17.1511 0.848858 17.375 1.125 17.375C1.40114 17.375 1.625 17.1511 1.625 16.875V15.275H3.775V16.875C3.775 17.1511 3.99886 17.375 4.275 17.375C4.55114 17.375 4.775 17.1511 4.775 16.875V14.775C4.775 14.4988 4.55114 14.275 4.275 14.275H1.125ZM13.2252 14.775C13.2252 14.4988 13.4491 14.275 13.7252 14.275H16.8752C17.1514 14.275 17.3752 14.4988 17.3752 14.775V16.875C17.3752 17.1511 17.1514 17.375 16.8752 17.375C16.5991 17.375 16.3752 17.1511 16.3752 16.875V15.275H14.2252V16.875C14.2252 17.1511 14.0014 17.375 13.7252 17.375C13.4491 17.375 13.2252 17.1511 13.2252 16.875V14.775ZM7.42511 14.275C7.14897 14.275 6.92511 14.4988 6.92511 14.775V16.875C6.92511 17.1511 7.14897 17.375 7.42511 17.375C7.70125 17.375 7.92511 17.1511 7.92511 16.875V15.275H10.0751V16.875C10.0751 17.1511 10.299 17.375 10.5751 17.375C10.8513 17.375 11.0751 17.1511 11.0751 16.875V14.775C11.0751 14.4988 10.8513 14.275 10.5751 14.275H7.42511Z" fill="#525252"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1584_1676">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
8
frontend/src/components/Icons/CollapseSidebar.vue
Normal file
8
frontend/src/components/Icons/CollapseSidebar.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.875 9.06223L3 9.06232" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M6.74537 5.31699L3 9.06236L6.74527 12.8076" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M14.1423 4L14.1423 14.125" stroke="currentColor" stroke-linecap="round" />
|
||||
</svg>
|
||||
</template>
|
||||
13
frontend/src/components/Icons/LMSLogo.vue
Normal file
13
frontend/src/components/Icons/LMSLogo.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg width="118" height="118" viewBox="0 0 118 118" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z" fill="url(#paint0_radial_174_336)"/>
|
||||
<path d="M93.9278 0H23.1013C10.3428 0 0 10.3428 0 23.1013V93.9278C0 106.686 10.3428 117.029 23.1013 117.029H93.9278C106.686 117.029 117.029 106.686 117.029 93.9278V23.1013C117.029 10.3428 106.686 0 93.9278 0Z" fill="#0B3D3D" fill-opacity="0.8"/>
|
||||
<path d="M95.1879 33.1294L91.4077 32.0268C80.1721 28.7716 67.9389 30.9242 58.5409 37.7496C52.083 33.0769 43.9975 30.5042 36.1746 30.5042H21.8938V41.0048H36.2796C42.2649 41.0048 48.1978 42.9999 52.923 46.6226L58.5934 50.9279L64.2637 46.6226C70.144 42.1599 77.5469 40.2698 84.7923 41.2673V76.1818C75.5518 75.2367 66.2063 77.7044 58.6459 83.2172C51.0854 77.7044 41.6349 75.2367 32.4994 76.1818V52.8705H21.9988V86.4724H95.3454V33.1294H95.1879Z" fill="#58FF9B"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_174_336" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117.24 -101.5) rotate(105.042) scale(226.282)">
|
||||
<stop offset="0.445162" stop-color="#1F7676"/>
|
||||
<stop offset="1" stop-color="#0A4B4B"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
55
frontend/src/components/SidebarLink.vue
Normal file
55
frontend/src/components/SidebarLink.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex h-7 cursor-pointer items-center rounded text-gray-800 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-gray-400"
|
||||
:class="isActive ? 'bg-white shadow-sm' : 'hover:bg-gray-100'" @click="handleClick">
|
||||
<div class="flex items-center duration-300 ease-in-out" :class="isCollapsed ? 'p-1' : 'px-2 py-1'">
|
||||
<Tooltip :text="label" placement="right">
|
||||
<slot name="icon">
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<component :is="icon" class="h-4.5 w-4.5 text-gray-700" />
|
||||
</span>
|
||||
</slot>
|
||||
</Tooltip>
|
||||
<span class="flex-shrink-0 text-base duration-300 ease-in-out" :class="isCollapsed
|
||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||
: 'ml-2 w-auto opacity-100'
|
||||
">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: Function,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
router.push({ name: props.to })
|
||||
}
|
||||
|
||||
let isActive = computed(() => {
|
||||
return router.currentRoute.value.name === props.to
|
||||
})
|
||||
</script>
|
||||
12
frontend/src/components/UserAvatar.vue
Normal file
12
frontend/src/components/UserAvatar.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<Avatar class="avatar border border-gray-300" v-if="user" :label="user.full_name" :image="user.user_image" size="lg" v-bind="$attrs" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar } from 'frappe-ui'
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
74
frontend/src/components/UserDropdown.vue
Normal file
74
frontend/src/components/UserDropdown.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Dropdown :options="userDropdownOptions">
|
||||
<template v-slot="{ open }">
|
||||
<button class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out" :class="isCollapsed
|
||||
? 'px-0 w-auto'
|
||||
: open
|
||||
? 'bg-white shadow-sm px-2 w-52'
|
||||
: 'hover:bg-gray-200 px-2 w-52'
|
||||
">
|
||||
<LMSLogo class="w-8 h-8 rounded flex-shrink-0" />
|
||||
<div class="flex flex-1 flex-col text-left duration-300 ease-in-out" :class="isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
">
|
||||
<div class="text-base font-medium text-gray-900 leading-none">
|
||||
LMS
|
||||
</div>
|
||||
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="duration-300 ease-in-out" :class="isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
">
|
||||
<ChevronDown class="h-4 w-4 text-gray-700" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
const props = defineProps({
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { logout, isLoggedIn } = sessionStore()
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const user = computed(() => isLoggedIn && getUser())
|
||||
const userDropdownOptions = [
|
||||
{
|
||||
icon: 'log-out',
|
||||
label: 'Log out',
|
||||
onClick: () => {
|
||||
logout.submit()
|
||||
},
|
||||
condition: () => {
|
||||
return isLoggedIn
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'log-in',
|
||||
label: 'Log in',
|
||||
onClick: () => {
|
||||
window.location.href = '/login'
|
||||
},
|
||||
condition: () => {
|
||||
return !isLoggedIn
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
Reference in New Issue
Block a user