Merge branch 'develop' into fix/quiz-enter
This commit is contained in:
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@@ -42,6 +42,8 @@ declare module 'vue' {
|
||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
|
||||
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
|
||||
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
|
||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
||||
|
||||
@@ -55,8 +55,9 @@
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"autoprefixer": "10.4.2",
|
||||
"postcss": "8.4.5",
|
||||
"vite": "5.0.11",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"vite": "5.0.11",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -26,28 +26,51 @@
|
||||
v-model="quiz"
|
||||
doctype="LMS Quiz"
|
||||
:label="__('Select a quiz')"
|
||||
placeholder=" "
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
:label="__('Select an assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<div v-else class="space-y-4">
|
||||
<Link
|
||||
v-if="filterAssignmentsByCourse"
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
:filters="{
|
||||
course: route.params.courseName,
|
||||
}"
|
||||
placeholder=" "
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
placeholder=" "
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:label="__('Filter assignments by course')"
|
||||
v-model="filterAssignmentsByCourse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog } from 'frappe-ui'
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Dialog, FormControl } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
|
||||
const show = ref(false)
|
||||
const quiz = ref(null)
|
||||
const assignment = ref(null)
|
||||
const filterAssignmentsByCourse = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4">
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
|
||||
@@ -68,11 +68,12 @@ const props = defineProps({
|
||||
|
||||
const certification = createResource({
|
||||
url: 'lms.lms.api.get_certification_details',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: user.data ? true : false,
|
||||
cache: ['certificationData', user.data?.name],
|
||||
})
|
||||
|
||||
const downloadCertificate = () => {
|
||||
|
||||
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '2xl' }">
|
||||
<template #body>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center space-x-2 pl-4.5 border-b">
|
||||
<Search class="size-4 text-ink-gray-4" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||
@input="onInput"
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-auto mb-2">
|
||||
<div v-if="query.length" class="mt-5 space-y-5">
|
||||
<CommandPaletteGroup
|
||||
:list="searchResults"
|
||||
@navigateTo="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-5">
|
||||
<CommandPaletteGroup
|
||||
:list="jumpToOptions"
|
||||
@navigateTo="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<MoveUp
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<MoveDown
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<span>
|
||||
{{ __('to navigate') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<CornerDownLeft
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<span>
|
||||
{{ __('to select') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||
<span>
|
||||
{{ __('to close') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, debounce, Dialog } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
CornerDownLeft,
|
||||
FileSearch,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
Search,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import CommandPaletteGroup from './CommandPaletteGroup.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const query = ref<string>('')
|
||||
const searchResults = ref<Array<any>>([])
|
||||
|
||||
const search = createResource({
|
||||
url: 'lms.command_palette.search_sqlite',
|
||||
makeParams: () => ({
|
||||
query: query.value,
|
||||
}),
|
||||
onSuccess() {
|
||||
generateSearchResults()
|
||||
},
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
if (query.value.length > 2) {
|
||||
search.reload()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const onInput = () => {
|
||||
debouncedSearch()
|
||||
}
|
||||
|
||||
const generateSearchResults = () => {
|
||||
search.data?.forEach((type: any) => {
|
||||
let result: { title: string; items: any[] } = { title: '', items: [] }
|
||||
result.title = type.title
|
||||
type.items.forEach((item: any) => {
|
||||
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
|
||||
item.route = {
|
||||
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[paramName]: item.name,
|
||||
},
|
||||
}
|
||||
item.isActive = false
|
||||
})
|
||||
result.items = type.items
|
||||
searchResults.value.push(result)
|
||||
})
|
||||
}
|
||||
|
||||
const appendSearchPage = () => {
|
||||
let searchPage: { title: string; items: Array<any> } = {
|
||||
title: '',
|
||||
items: [],
|
||||
}
|
||||
searchPage.title = __('Jump to')
|
||||
searchPage.items = [
|
||||
{
|
||||
title: __('Search for ') + `"${query.value}"`,
|
||||
route: {
|
||||
name: 'Search',
|
||||
query: {
|
||||
q: query.value,
|
||||
},
|
||||
},
|
||||
icon: FileSearch,
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
searchResults.value = [searchPage]
|
||||
}
|
||||
|
||||
watch(
|
||||
query,
|
||||
() => {
|
||||
appendSearchPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
query.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
addKeyboardShortcuts()
|
||||
})
|
||||
|
||||
const addKeyboardShortcuts = () => {
|
||||
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowUp' && show.value) {
|
||||
e.preventDefault()
|
||||
shortcutForArrowKey(-1)
|
||||
} else if (e.key === 'ArrowDown' && show.value) {
|
||||
shortcutForArrowKey(1)
|
||||
} else if (e.key === 'Enter' && show.value) {
|
||||
shortcutForEnter()
|
||||
} else if (e.key === 'Escape' && show.value) {
|
||||
show.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const shortcutForArrowKey = (direction: number) => {
|
||||
let currentList = query.value.length
|
||||
? searchResults.value
|
||||
: jumpToOptions.value
|
||||
let allItems = currentList.flatMap((result: any) => result.items)
|
||||
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
|
||||
let newIndex = indexOfActive + direction
|
||||
if (newIndex < 0) newIndex = allItems.length - 1
|
||||
if (newIndex >= allItems.length) newIndex = 0
|
||||
allItems[indexOfActive].isActive = false
|
||||
allItems[newIndex].isActive = true
|
||||
nextTick(scrollActiveItemIntoView)
|
||||
}
|
||||
|
||||
const scrollActiveItemIntoView = () => {
|
||||
const activeItem = document.querySelector(
|
||||
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
|
||||
) as HTMLElement
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutForEnter = () => {
|
||||
let currentList = query.value.length
|
||||
? searchResults.value
|
||||
: jumpToOptions.value
|
||||
let allItems = currentList.flatMap((result: any) => result.items)
|
||||
let activeOption = allItems.find((option) => option.isActive)
|
||||
if (activeOption) {
|
||||
navigateTo(activeOption.route)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateTo = (route: {
|
||||
name: string
|
||||
params?: Record<string, any>
|
||||
query?: Record<string, any>
|
||||
}) => {
|
||||
show.value = false
|
||||
query.value = ''
|
||||
router.replace({ name: route.name, params: route.params, query: route.query })
|
||||
}
|
||||
|
||||
const jumpToOptions = ref([
|
||||
{
|
||||
title: __('Jump to'),
|
||||
items: [
|
||||
{
|
||||
title: 'Advanced Search',
|
||||
icon: Search,
|
||||
route: {
|
||||
name: 'Search',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: 'Courses',
|
||||
icon: BookOpen,
|
||||
route: {
|
||||
name: 'Courses',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: 'Batches',
|
||||
icon: Users,
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
icon: Briefcase,
|
||||
route: {
|
||||
name: 'Jobs',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
<style>
|
||||
mark {
|
||||
background-color: theme('colors.amber.100');
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-for="result in list" class="px-2.5 space-y-2">
|
||||
<div class="text-ink-gray-5 px-2">
|
||||
{{ result.title }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="item in result.items"
|
||||
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
|
||||
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||
@click="emit('navigateTo', item.route)"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="size-4 stroke-1.5 text-ink-gray-6"
|
||||
/>
|
||||
<div v-html="item.title"></div>
|
||||
</div>
|
||||
<div v-if="item.modified" class="text-ink-gray-5">
|
||||
{{ dayjs.unix(item.modified).fromNow(true) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const emit = defineEmits(['navigateTo'])
|
||||
|
||||
const props = defineProps<{
|
||||
list: Array<{
|
||||
title: string
|
||||
items: Array<{
|
||||
title: string
|
||||
icon?: any
|
||||
isActive?: boolean
|
||||
modified?: string
|
||||
}>
|
||||
}>
|
||||
}>()
|
||||
</script>
|
||||
@@ -152,21 +152,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let theme =
|
||||
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
|
||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = colors[theme][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@@ -220,8 +220,12 @@ function enrollStudent() {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 500)
|
||||
} else {
|
||||
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
||||
course: props.course.data.name,
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.course.data.name,
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
capture('enrolled_in_course', {
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
chapterNumber: lesson.number.split('-')[0],
|
||||
lessonNumber: lesson.number.split('-')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
@@ -389,8 +389,8 @@ const redirectToChapter = (chapter) => {
|
||||
|
||||
const isActiveLesson = (lessonNumber) => {
|
||||
return (
|
||||
route.params.chapterNumber == lessonNumber.split('.')[0] &&
|
||||
route.params.lessonNumber == lessonNumber.split('.')[1]
|
||||
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||
route.params.lessonNumber == lessonNumber.split('-')[1]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -80,6 +80,7 @@ onMounted(() => {
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
destructureSidebarLinks()
|
||||
filterLinksToShow(data)
|
||||
addOtherLinks()
|
||||
},
|
||||
@@ -103,6 +104,16 @@ watch(showMenu, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
const destructureSidebarLinks = () => {
|
||||
let links = []
|
||||
sidebarLinks.value.forEach((link) => {
|
||||
link.items?.forEach((item) => {
|
||||
links.push(item)
|
||||
})
|
||||
})
|
||||
sidebarLinks.value = links
|
||||
}
|
||||
|
||||
const filterLinksToShow = (data) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!parseInt(data[key])) {
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
:label="__('Submission Type')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="assignment.course"
|
||||
:label="__('Course')"
|
||||
doctype="LMS Course"
|
||||
placeholder=" "
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Question') }}
|
||||
@@ -67,6 +73,7 @@
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -75,6 +82,7 @@ interface Assignment {
|
||||
title: string
|
||||
type: string
|
||||
question: string
|
||||
course?: string
|
||||
}
|
||||
|
||||
interface Assignments {
|
||||
@@ -89,6 +97,7 @@ const assignment = reactive({
|
||||
title: '',
|
||||
type: '',
|
||||
question: '',
|
||||
course: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@@ -107,6 +116,7 @@ watch(
|
||||
assignment.title = row.title
|
||||
assignment.type = row.type
|
||||
assignment.question = row.question
|
||||
assignment.course = row.course || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,104 +1,137 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:options="{
|
||||
title: 'Edit your profile',
|
||||
size: '3xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Save',
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveProfile(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="flex items-center mb-5">
|
||||
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Edit Profile') }}
|
||||
</div>
|
||||
<Badge v-if="isDirty" class="ml-4" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<!-- <Uploader
|
||||
v-model="profile.image.file_url"
|
||||
label="Profile Image"
|
||||
description="Your profile image to help others recognize you."
|
||||
/> -->
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="profile.image.file_url"
|
||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="profile.image?.file_url"
|
||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
|
||||
<div class="text-base flex flex-col ml-2">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
<div class="text-base flex flex-col ml-2">
|
||||
<span>
|
||||
{{ profile.image?.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image?.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
/>
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
|
||||
<FormControl
|
||||
v-model="profile.linkedin"
|
||||
:label="__('LinkedIn ID')"
|
||||
/>
|
||||
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
|
||||
<FormControl
|
||||
v-model="profile.twitter"
|
||||
:label="__('Twitter ID')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl v-model="profile.first_name" :label="__('First Name')" />
|
||||
<FormControl v-model="profile.last_name" :label="__('Last Name')" />
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="profile.open_to"
|
||||
type="select"
|
||||
:options="[' ', 'Opportunities', 'Hiring']"
|
||||
:label="__('Open to')"
|
||||
:placeholder="__('Looking for new work or hiring talent?')"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
:rows="15"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveProfile(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
Button,
|
||||
createResource,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
@@ -109,6 +142,7 @@ import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
const hasLanguageChanged = ref(false)
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -123,6 +157,10 @@ const profile = reactive({
|
||||
headline: '',
|
||||
bio: '',
|
||||
image: '',
|
||||
open_to: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
twitter: '',
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
@@ -145,7 +183,7 @@ const updateProfile = createResource({
|
||||
doctype: 'User',
|
||||
name: props.profile.data.name,
|
||||
fieldname: {
|
||||
user_image: profile.image.file_url,
|
||||
user_image: profile.image?.file_url || null,
|
||||
...profile,
|
||||
},
|
||||
}
|
||||
@@ -190,6 +228,27 @@ const removeImage = () => {
|
||||
profile.image = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => profile,
|
||||
(newVal) => {
|
||||
if (!props.profile.data) return
|
||||
let keys = Object.keys(newVal)
|
||||
keys.splice(keys.indexOf('image'), 1)
|
||||
for (let key of keys) {
|
||||
if (newVal[key] !== props.profile.data[key]) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if (profile.image?.file_url !== props.profile.data.user_image) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
isDirty.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.profile.data,
|
||||
(newVal) => {
|
||||
@@ -199,7 +258,12 @@ watch(
|
||||
profile.headline = newVal.headline
|
||||
profile.language = newVal.language
|
||||
profile.bio = newVal.bio
|
||||
profile.open_to = newVal.open_to
|
||||
profile.linkedin = newVal.linkedin
|
||||
profile.github = newVal.github
|
||||
profile.twitter = newVal.twitter
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
isDirty.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Schedule Evaluation'),
|
||||
title: __('Schedule your evaluation'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
@@ -14,52 +14,49 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course') }}
|
||||
<div class="flex flex-col gap-4 text-base max-h-[60vh]">
|
||||
<FormControl
|
||||
v-model="evaluation.course"
|
||||
type="select"
|
||||
:label="__('Course')"
|
||||
:options="getCourses()"
|
||||
/>
|
||||
<div v-if="slots.data?.length" class="space-y-4 overflow-y-auto mt-4">
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ __('Available Slots') }}
|
||||
</div>
|
||||
<Select v-model="evaluation.course" :options="getCourses()" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="evaluation.date"
|
||||
:min="
|
||||
dayjs()
|
||||
.add(dayjs.duration({ days: 1 }))
|
||||
.format('YYYY-MM-DD')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="slots.data?.length">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Select a slot') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div v-for="slot in slots.data">
|
||||
<div
|
||||
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
|
||||
@click="saveSlot(slot)"
|
||||
:class="{
|
||||
'border-outline-gray-4':
|
||||
evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
<div class="space-y-5">
|
||||
<div v-for="row in slots.data" class="space-y-2">
|
||||
<div class="flex items-center text-ink-gray-7 space-x-2">
|
||||
<Calendar class="size-3" />
|
||||
<div class="text-ink-gray-9">
|
||||
{{ dayjs(row.date).format('DD MMMM YYYY') }}
|
||||
</div>
|
||||
<div>·</div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ row.day }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="slot in row.slots"
|
||||
class="text-base text-center border rounded-md text-ink-gray-8 p-2 cursor-pointer text-ink-gray-7 hover:bg-surface-gray-2 hover:border-outline-gray-3"
|
||||
@click="saveSlot(slot, row)"
|
||||
:class="{
|
||||
'border-outline-gray-4 text-ink-gray-9':
|
||||
evaluation.date == row.date &&
|
||||
evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="evaluation.course && evaluation.date"
|
||||
class="text-sm italic text-ink-red-4"
|
||||
>
|
||||
{{ __('No slots available for this date.') }}
|
||||
<div v-else class="text-ink-red-3">
|
||||
{{ __('No slots available for the selected course.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,14 +64,15 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dialog,
|
||||
createResource,
|
||||
Select,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, inject } from 'vue'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
@@ -96,7 +94,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluation = reactive({
|
||||
const evaluation = ref({
|
||||
course: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
@@ -106,49 +104,28 @@ const evaluation = reactive({
|
||||
member: user.data.name,
|
||||
})
|
||||
|
||||
const createEvaluation = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: values.batch,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function submitEvaluation(close) {
|
||||
createEvaluation.submit(evaluation, {
|
||||
validate() {
|
||||
if (!evaluation.course) {
|
||||
return 'Please select a course.'
|
||||
}
|
||||
if (!evaluation.date) {
|
||||
return 'Please select a date.'
|
||||
}
|
||||
if (!evaluation.start_time) {
|
||||
return 'Please select a slot.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||
return `Please select a date before the end date ${dayjs(
|
||||
props.endDate
|
||||
).format('DD MMMM YYYY')}.`
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
console.log(err.messages?.[0] || err)
|
||||
toast.warning(__(err.messages?.[0] || err), { duration: 10000 })
|
||||
if (!evaluation.value.date || !evaluation.value.start_time) {
|
||||
toast.warning(__('Please select a slot for your evaluation.'), {
|
||||
duration: 10,
|
||||
})
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: evaluation.value.batch,
|
||||
...evaluation.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err.messages?.[0] || err)
|
||||
toast.warning(__(err.messages?.[0] || err), { duration: 20 })
|
||||
})
|
||||
}
|
||||
|
||||
const getCourses = () => {
|
||||
@@ -163,7 +140,7 @@ const getCourses = () => {
|
||||
}
|
||||
|
||||
if (courses.length === 1) {
|
||||
evaluation.course = courses[0].value
|
||||
evaluation.value.course = courses[0].value
|
||||
}
|
||||
|
||||
return courses
|
||||
@@ -174,34 +151,22 @@ const slots = createResource({
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: values.course,
|
||||
date: values.date,
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => evaluation.date,
|
||||
(date) => {
|
||||
evaluation.start_time = ''
|
||||
if (date && evaluation.course) {
|
||||
slots.submit(evaluation)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => evaluation.course,
|
||||
() => evaluation.value.course,
|
||||
(course) => {
|
||||
evaluation.date = ''
|
||||
evaluation.start_time = ''
|
||||
slots.reset()
|
||||
slots.reload(evaluation.value)
|
||||
}
|
||||
)
|
||||
|
||||
const saveSlot = (slot) => {
|
||||
evaluation.start_time = slot.start_time
|
||||
evaluation.end_time = slot.end_time
|
||||
evaluation.day = slot.day
|
||||
const saveSlot = (slot, row) => {
|
||||
evaluation.value.start_time = slot.start_time
|
||||
evaluation.value.end_time = slot.end_time
|
||||
evaluation.value.date = row.date
|
||||
evaluation.value.day = row.day
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 text-base">
|
||||
<p class="text-ink-gray-9">
|
||||
{{
|
||||
__(
|
||||
@@ -39,6 +39,9 @@
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
<template #prefix>
|
||||
<Upload class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
||||
}}
|
||||
@@ -66,7 +69,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
||||
import { FileText } from 'lucide-vue-next'
|
||||
import { FileText, Upload } from 'lucide-vue-next'
|
||||
import { ref, inject } from 'vue'
|
||||
import { getFileSize } from '@/utils/'
|
||||
|
||||
|
||||
@@ -1,42 +1,50 @@
|
||||
<template>
|
||||
<div class="border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
<div class="bg-surface-white w-full h-full">
|
||||
<div class="w-fit mx-auto mt-56 text-center p-4">
|
||||
<div class="text-3xl font-semibold text-ink-gray-5 pb-4 mb-2 border-b">
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="mt-2">
|
||||
{{ __('Checkout Courses') }}
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="mt-2 w-full">
|
||||
{{ __('Checkout Courses') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('You are not permitted to access this page.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-4 w-full" variant="solid">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-4">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Button, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Not Permitted'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,11 +9,21 @@
|
||||
>
|
||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||
<div v-for="link in sidebarLinks" class="mx-2 my-0.5">
|
||||
<SidebarLink
|
||||
:link="link"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
/>
|
||||
<div v-for="link in sidebarLinks" class="mx-2 my-2.5">
|
||||
<div
|
||||
v-if="!link.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(link.label) }}</span>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<div v-for="item in link.items">
|
||||
<SidebarLink
|
||||
:link="item"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -173,6 +183,7 @@
|
||||
:currentStep="currentStep"
|
||||
/>
|
||||
</div>
|
||||
<CommandPalette v-model="settingsStore.isCommandPaletteOpen" />
|
||||
<PageModal
|
||||
v-model="showPageModal"
|
||||
v-model:reloadSidebar="sidebarSettings"
|
||||
@@ -181,9 +192,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/Sidebar/SidebarLink.vue'
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
@@ -194,7 +202,6 @@ import PageModal from '@/components/Modals/PageModal.vue'
|
||||
import { capture } from '@/telemetry'
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
@@ -217,7 +224,6 @@ import {
|
||||
Users,
|
||||
BookText,
|
||||
Zap,
|
||||
Check,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TrialBanner,
|
||||
@@ -228,18 +234,24 @@ import {
|
||||
minimize,
|
||||
IntermediateStepModal,
|
||||
} from 'frappe-ui/frappe'
|
||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/Sidebar/SidebarLink.vue'
|
||||
import CommandPalette from '@/components/CommandPalette/CommandPalette.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
const { userResource } = usersStore()
|
||||
let sidebarStore = useSidebar()
|
||||
const socket = inject('$socket')
|
||||
const unreadCount = ref(0)
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const sidebarLinks = ref(null)
|
||||
const showPageModal = ref(false)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
const pageToEdit = ref(null)
|
||||
const { settings, sidebarSettings, activeTab, isSettingsOpen } = useSettings()
|
||||
const { sidebarSettings, activeTab, isSettingsOpen, programs } = useSettings()
|
||||
const settingsStore = useSettings()
|
||||
const showOnboarding = ref(false)
|
||||
const showIntermediateModal = ref(false)
|
||||
const currentStep = ref({})
|
||||
@@ -254,9 +266,8 @@ const iconProps = {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addNotifications()
|
||||
setSidebarLinks()
|
||||
setUpOnboarding()
|
||||
addKeyboardShortcut()
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unreadNotifications.reload()
|
||||
})
|
||||
@@ -269,9 +280,11 @@ const setSidebarLinks = () => {
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!parseInt(data[key])) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||
)
|
||||
sidebarLinks.value.forEach((link) => {
|
||||
link.items = link.items.filter(
|
||||
(item) => item.label.toLowerCase().split(' ').join('_') !== key
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -279,6 +292,23 @@ const setSidebarLinks = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const addKeyboardShortcut = () => {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (
|
||||
e.key === 'k' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
toggleCommandPalette()
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCommandPalette = () => {
|
||||
settingsStore.isCommandPaletteOpen = !settingsStore.isCommandPaletteOpen
|
||||
}
|
||||
|
||||
const unreadNotifications = createResource({
|
||||
cache: 'Unread Notifications Count',
|
||||
url: 'frappe.client.get_count',
|
||||
@@ -293,140 +323,18 @@ const unreadNotifications = createResource({
|
||||
},
|
||||
onSuccess(data) {
|
||||
unreadCount.value = data
|
||||
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
||||
if (link.label === 'Notifications') {
|
||||
link.count = data
|
||||
}
|
||||
return link
|
||||
})
|
||||
updateUnreadCount()
|
||||
},
|
||||
auto: user ? true : false,
|
||||
})
|
||||
|
||||
const addNotifications = () => {
|
||||
if (user) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
activeFor: ['Notifications'],
|
||||
count: unreadCount.value,
|
||||
const updateUnreadCount = () => {
|
||||
sidebarLinks.value?.forEach((link) => {
|
||||
link.items.forEach((item) => {
|
||||
if (item.label === 'Notifications') {
|
||||
item.count = unreadCount.value || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addQuizzes = () => {
|
||||
if (!isInstructor.value && !isModerator.value) return
|
||||
|
||||
const quizzesLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Quizzes'
|
||||
)
|
||||
if (quizzesLinkExists) return
|
||||
|
||||
sidebarLinks.value.splice(4, 0, {
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: ['Quizzes', 'QuizForm', 'QuizSubmissionList', 'QuizSubmission'],
|
||||
})
|
||||
}
|
||||
|
||||
const addAssignments = () => {
|
||||
if (!isInstructor.value && !isModerator.value) return
|
||||
|
||||
const assignmentsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Assignments'
|
||||
)
|
||||
if (assignmentsLinkExists) return
|
||||
|
||||
sidebarLinks.value.splice(5, 0, {
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
activeFor: [
|
||||
'Assignments',
|
||||
'AssignmentForm',
|
||||
'AssignmentSubmissionList',
|
||||
'AssignmentSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const addProgrammingExercises = () => {
|
||||
if (!isInstructor.value && !isModerator.value) return
|
||||
const programmingExercisesLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Programming Exercises'
|
||||
)
|
||||
if (programmingExercisesLinkExists) return
|
||||
|
||||
sidebarLinks.value.splice(3, 0, {
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
activeFor: [
|
||||
'ProgrammingExercises',
|
||||
'ProgrammingExerciseForm',
|
||||
'ProgrammingExerciseSubmissions',
|
||||
'ProgrammingExerciseSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const addPrograms = async () => {
|
||||
const programsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Programs'
|
||||
)
|
||||
if (programsLinkExists) return
|
||||
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
let index = 2
|
||||
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
|
||||
const addContactUsDetails = () => {
|
||||
if (!settings?.data?.contact_us_email && !settings?.data?.contact_us_url)
|
||||
return
|
||||
|
||||
const contactUsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Contact Us'
|
||||
)
|
||||
if (contactUsLinkExists) return
|
||||
|
||||
sidebarLinks.value.push({
|
||||
label: 'Contact Us',
|
||||
icon: settings.data?.contact_us_url ? 'Headset' : 'Mail',
|
||||
to: settings.data?.contact_us_url
|
||||
? settings.data?.contact_us_url
|
||||
: settings.data?.contact_us_email,
|
||||
})
|
||||
}
|
||||
|
||||
const checkIfCanAddProgram = async () => {
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
return true
|
||||
}
|
||||
const programs = await call('lms.lms.utils.get_programs')
|
||||
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||
}
|
||||
|
||||
const addHome = () => {
|
||||
const homeLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Home'
|
||||
)
|
||||
if (homeLinkExists) return
|
||||
sidebarLinks.value.unshift({
|
||||
label: 'Home',
|
||||
icon: 'Home',
|
||||
to: 'Home',
|
||||
activeFor: ['Home'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -674,18 +582,16 @@ const setUpOnboarding = () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
addContactUsDetails()
|
||||
watch(userResource, async () => {
|
||||
await userResource.promise
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addHome()
|
||||
addPrograms()
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
await programs.reload()
|
||||
setUpOnboarding()
|
||||
}
|
||||
sidebarLinks.value = getSidebarLinks()
|
||||
setSidebarLinks()
|
||||
})
|
||||
|
||||
const redirectToWebsite = () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ const apps = createResource({
|
||||
name: 'frappe',
|
||||
logo: '/assets/lms/images/desk.png',
|
||||
title: __('Desk'),
|
||||
route: '/app',
|
||||
route: '/desk/lms',
|
||||
},
|
||||
]
|
||||
data.map((app) => {
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
<template>
|
||||
<Tooltip :text="user.full_name">
|
||||
<Avatar
|
||||
class="avatar border border-outline-gray-2 cursor-auto"
|
||||
v-if="user"
|
||||
:label="user.full_name"
|
||||
:image="user.user_image"
|
||||
:size="size"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Avatar
|
||||
class="avatar border border-outline-gray-2 cursor-auto"
|
||||
v-if="user"
|
||||
:label="user.full_name"
|
||||
:image="user.user_image"
|
||||
:size="size"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template v-if="user.open_to === 'Opportunities'" #indicator>
|
||||
<Tooltip :text="__('Open to Opportunities')" placement="right">
|
||||
<div class="rounded-full bg-surface-green-3 w-fit">
|
||||
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<template v-else-if="user.open_to === 'Hiring'" #indicator>
|
||||
<Tooltip :text="__('Hiring')" placement="right">
|
||||
<div class="rounded-full bg-purple-500 w-fit">
|
||||
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Avatar>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar, Tooltip } from 'frappe-ui'
|
||||
import { BadgeCheckIcon } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
@@ -21,4 +37,15 @@ const props = defineProps({
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const checkSize = computed(() => {
|
||||
let sizeMap = {
|
||||
sm: 'size-1',
|
||||
md: 'size-2',
|
||||
lg: 'size-3',
|
||||
xl: 'size-3',
|
||||
'2xl': 'size-3',
|
||||
}
|
||||
return sizeMap[props.size] || 'size-3'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -148,7 +148,7 @@ const assignmentFilter = computed(() => {
|
||||
|
||||
const assignments = createListResource({
|
||||
doctype: 'LMS Assignment',
|
||||
fields: ['name', 'title', 'type', 'creation', 'question'],
|
||||
fields: ['name', 'title', 'type', 'creation', 'question', 'course'],
|
||||
orderBy: 'modified desc',
|
||||
cache: ['assignments'],
|
||||
transform(data) {
|
||||
|
||||
@@ -144,6 +144,20 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.evaluation_end_date && isStudent"
|
||||
class="text-sm leading-5 bg-surface-amber-1 text-ink-amber-3 p-2 rounded-md mb-10"
|
||||
>
|
||||
{{ __('The last day to schedule your evaluations is ') }}
|
||||
<span class="font-medium">
|
||||
{{
|
||||
dayjs(batch.data.evaluation_end_date).format('DD MMMM YYYY')
|
||||
}} </span
|
||||
>.
|
||||
{{
|
||||
__('Please make sure to schedule your evaluation before this date.')
|
||||
}}
|
||||
</div>
|
||||
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('Feedback') }}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
v-model="currentCategory"
|
||||
:options="categories"
|
||||
:placeholder="__('Category')"
|
||||
@change="updateBatches()"
|
||||
@update:modelValue="updateBatches()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="bg-surface-amber-2 text-ink-amber-2 text-sm leading-5 p-2 rounded-md"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'Please ensure that the billing name you enter is correct, as it will be used on your invoice.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 lg:mr-10">
|
||||
@@ -104,16 +114,22 @@
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="billingDetails.billing_name"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Address Line 1')"
|
||||
v-model="billingDetails.address_line1"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Address Line 2')"
|
||||
v-model="billingDetails.address_line2"
|
||||
/>
|
||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||
<FormControl
|
||||
:label="__('City')"
|
||||
v-model="billingDetails.city"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('State/Province')"
|
||||
v-model="billingDetails.state"
|
||||
@@ -125,20 +141,24 @@
|
||||
:value="billingDetails.country"
|
||||
@change="(option) => changeCurrency(option)"
|
||||
:label="__('Country')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Postal Code')"
|
||||
v-model="billingDetails.pincode"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Phone Number')"
|
||||
v-model="billingDetails.phone"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Source"
|
||||
:value="billingDetails.source"
|
||||
@change="(option) => (billingDetails.source = option)"
|
||||
:label="__('Where did you hear about us?')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
@@ -152,14 +172,29 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t pt-4 mt-8">
|
||||
<p class="text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
'Make sure to enter the correct billing name as the same will be used in your invoice.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-col lg:flex-row items-start lg:items-center justify-between border-t pt-4 mt-8 space-y-4 lg:space-y-0"
|
||||
>
|
||||
<div>
|
||||
<FormControl
|
||||
:label="
|
||||
__(
|
||||
'I consent to my personal information being stored for invoicing'
|
||||
)
|
||||
"
|
||||
type="checkbox"
|
||||
class="leading-6"
|
||||
v-model="billingDetails.member_consent"
|
||||
/>
|
||||
<div
|
||||
v-if="showConsentWarning"
|
||||
class="mt-1 text-xs text-ink-red-3"
|
||||
>
|
||||
{{
|
||||
__('Please provide your consent to proceed with the payment')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="solid" size="md" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
@@ -194,7 +229,7 @@ import {
|
||||
toast,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, computed, ref } from 'vue'
|
||||
import { reactive, inject, onMounted, computed, ref, watch } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
@@ -202,6 +237,7 @@ import { X } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const showConsentWarning = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const script = document.createElement('script')
|
||||
@@ -296,6 +332,10 @@ const generatePaymentLink = () => {
|
||||
if (!billingDetails.source) {
|
||||
return __('Please let us know where you heard about us from.')
|
||||
}
|
||||
if (!billingDetails.member_consent) {
|
||||
showConsentWarning.value = true
|
||||
return __('Please provide your consent to proceed with the payment.')
|
||||
}
|
||||
return validateAddress()
|
||||
},
|
||||
onSuccess(data) {
|
||||
@@ -406,6 +446,12 @@ const redirectTo = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(billingDetails, () => {
|
||||
if (billingDetails.member_consent) {
|
||||
showConsentWarning.value = false
|
||||
}
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Billing Details'),
|
||||
|
||||
@@ -13,49 +13,68 @@
|
||||
</router-link>
|
||||
</header>
|
||||
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||
<div class="flex flex-col md:flex-row justify-between mb-8 px-3">
|
||||
<div class="text-xl font-semibold text-ink-gray-9 mb-4 md:mb-0">
|
||||
{{ memberCount }} {{ __('certified members') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
v-model="nameFilter"
|
||||
:placeholder="__('Search by Name')"
|
||||
type="text"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateParticipants()"
|
||||
/>
|
||||
<div
|
||||
v-if="categories.data?.length"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
>
|
||||
<Select
|
||||
v-model="currentCategory"
|
||||
:options="categories.data"
|
||||
:placeholder="__('Category')"
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 md:space-x-4"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
v-model="nameFilter"
|
||||
:placeholder="__('Search by Name')"
|
||||
type="text"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateParticipants()"
|
||||
/>
|
||||
<div
|
||||
v-if="categories.data?.length"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
>
|
||||
<Select
|
||||
v-model="currentCategory"
|
||||
:options="categories.data"
|
||||
:placeholder="__('Category')"
|
||||
@update:modelValue="updateParticipants()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
v-model="openToOpportunities"
|
||||
:label="__('Open to Opportunities')"
|
||||
type="checkbox"
|
||||
@change="updateParticipants()"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="hiring"
|
||||
:label="__('Hiring')"
|
||||
type="checkbox"
|
||||
@change="updateParticipants()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="participants.data?.length" class="divide-y">
|
||||
<template v-for="participant in participants.data">
|
||||
<div v-if="participants.data?.length" class="">
|
||||
<template v-for="(participant, index) in participants.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProfileCertificates',
|
||||
name: 'ProfileAbout',
|
||||
params: {
|
||||
username: participant.username,
|
||||
},
|
||||
}"
|
||||
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
|
||||
class="flex rounded-md hover:bg-surface-gray-2 px-3"
|
||||
>
|
||||
<div class="flex items-center w-full space-x-3">
|
||||
<Avatar
|
||||
:image="participant.user_image"
|
||||
class="size-8 rounded-full object-contain"
|
||||
:label="participant.full_name"
|
||||
size="2xl"
|
||||
/>
|
||||
<div
|
||||
class="flex w-full space-x-3 py-2"
|
||||
:class="{
|
||||
'border-b': index < participants.data.length - 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar :user="participant" size="2xl" />
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full">
|
||||
<div class="flex-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
@@ -115,10 +134,13 @@ import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { GraduationCap } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const currentCategory = ref('')
|
||||
const filters = ref({})
|
||||
const currentCategory = ref('')
|
||||
const nameFilter = ref('')
|
||||
const openToOpportunities = ref(false)
|
||||
const hiring = ref(false)
|
||||
const { brand } = sessionStore()
|
||||
const memberCount = ref(0)
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -150,7 +172,7 @@ const categories = createListResource({
|
||||
cache: ['certification_categories'],
|
||||
auto: true,
|
||||
transform(data) {
|
||||
data.unshift({ label: __(''), value: '' })
|
||||
data.unshift({ label: __(' '), value: ' ' })
|
||||
return data
|
||||
},
|
||||
})
|
||||
@@ -167,16 +189,19 @@ const updateParticipants = () => {
|
||||
}
|
||||
|
||||
const updateFilters = () => {
|
||||
if (currentCategory.value) {
|
||||
filters.value.category = currentCategory.value
|
||||
} else {
|
||||
delete filters.value.category
|
||||
}
|
||||
|
||||
if (nameFilter.value) {
|
||||
filters.value.member_name = ['like', `%${nameFilter.value}%`]
|
||||
} else {
|
||||
delete filters.value.member_name
|
||||
filters.value = {
|
||||
...(currentCategory.value && {
|
||||
category: currentCategory.value,
|
||||
}),
|
||||
...(nameFilter.value && {
|
||||
member_name: ['like', `%${nameFilter.value}%`],
|
||||
}),
|
||||
...(openToOpportunities.value && {
|
||||
open_to_opportunities: true,
|
||||
}),
|
||||
...(hiring.value && {
|
||||
hiring: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,10 +210,12 @@ const setQueryParams = () => {
|
||||
let filterKeys = {
|
||||
category: currentCategory.value,
|
||||
name: nameFilter.value,
|
||||
'open-to-opportunities': openToOpportunities.value,
|
||||
hiring: hiring.value,
|
||||
}
|
||||
|
||||
Object.keys(filterKeys).forEach((key) => {
|
||||
if (filterKeys[key]) {
|
||||
if (filterKeys[key] && hasValue(filterKeys[key])) {
|
||||
queries.set(key, filterKeys[key])
|
||||
} else {
|
||||
queries.delete(key)
|
||||
@@ -201,10 +228,19 @@ const setQueryParams = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const hasValue = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const setFiltersFromQuery = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
nameFilter.value = queries.get('name') || ''
|
||||
currentCategory.value = queries.get('category') || ''
|
||||
openToOpportunities.value = queries.get('open-to-opportunities') === 'true'
|
||||
hiring.value = queries.get('hiring') === 'true'
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
v-model="currentCategory"
|
||||
:options="categories"
|
||||
:placeholder="__('Category')"
|
||||
@change="updateCourses()"
|
||||
@update:modelValue="updateCourses()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,12 +222,12 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const createdCourses = createResource({
|
||||
url: 'lms.lms.utils.get_created_courses',
|
||||
url: 'lms.lms.api.get_created_courses',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const createdBatches = createResource({
|
||||
url: 'lms.lms.utils.get_created_batches',
|
||||
url: 'lms.lms.api.get_created_batches',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -74,22 +74,22 @@ const isAdmin = computed(() => {
|
||||
})
|
||||
|
||||
const myLiveClasses = createResource({
|
||||
url: 'lms.lms.utils.get_my_live_classes',
|
||||
url: 'lms.lms.api.get_my_live_classes',
|
||||
auto: !isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const adminLiveClasses = createResource({
|
||||
url: 'lms.lms.utils.get_admin_live_classes',
|
||||
url: 'lms.lms.api.get_admin_live_classes',
|
||||
auto: isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const adminEvals = createResource({
|
||||
url: 'lms.lms.utils.get_admin_evals',
|
||||
url: 'lms.lms.api.get_admin_evals',
|
||||
auto: isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const streakInfo = createResource({
|
||||
url: 'lms.lms.utils.get_streak_info',
|
||||
url: 'lms.lms.api.get_streak_info',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -161,12 +161,12 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const myCourses = createResource({
|
||||
url: 'lms.lms.utils.get_my_courses',
|
||||
url: 'lms.lms.api.get_my_courses',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const myBatches = createResource({
|
||||
url: 'lms.lms.utils.get_my_batches',
|
||||
url: 'lms.lms.api.get_my_batches',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -196,8 +196,8 @@ const job = createResource({
|
||||
onSuccess: (data) => {
|
||||
if (user.data?.name) {
|
||||
jobApplication.submit()
|
||||
applicationCount.submit()
|
||||
}
|
||||
applicationCount.submit()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -26,56 +26,72 @@
|
||||
</header>
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto mb-2 p-5"
|
||||
>
|
||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||
{{ __('{0} Open Jobs').format(jobCount) }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between space-x-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold text-ink-gray-9 md:mb-0">
|
||||
{{ __('{0} {1} Jobs').format(jobCount, activeTab) }}
|
||||
</div>
|
||||
<TabButtons
|
||||
v-if="tabs.length > 1"
|
||||
v-model="activeTab"
|
||||
:buttons="tabs"
|
||||
class="lg:hidden"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
<FormControl
|
||||
type="text"
|
||||
:placeholder="__('Search')"
|
||||
v-model="searchQuery"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateJobs"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
||||
name="search"
|
||||
/>
|
||||
</template>
|
||||
</FormControl>
|
||||
<Link
|
||||
v-if="user.data"
|
||||
doctype="Country"
|
||||
v-model="country"
|
||||
:placeholder="__('Country')"
|
||||
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
|
||||
:placeholder="__('Type')"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="workMode"
|
||||
type="select"
|
||||
:options="workModes"
|
||||
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
|
||||
:placeholder="__('Work Mode')"
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:space-x-4 space-y-4 md:space-y-0"
|
||||
>
|
||||
<TabButtons
|
||||
v-if="tabs.length > 1"
|
||||
v-model="activeTab"
|
||||
:buttons="tabs"
|
||||
class="hidden lg:block"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
type="text"
|
||||
:placeholder="__('Search')"
|
||||
v-model="searchQuery"
|
||||
class="w-full max-w-40"
|
||||
@input="updateJobs"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
||||
name="search"
|
||||
/>
|
||||
</template>
|
||||
</FormControl>
|
||||
<Link
|
||||
v-if="user.data"
|
||||
doctype="Country"
|
||||
v-model="country"
|
||||
:placeholder="__('Country')"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="w-full"
|
||||
:placeholder="__('Type')"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="workMode"
|
||||
type="select"
|
||||
:options="workModes"
|
||||
class="w-full"
|
||||
:placeholder="__('Work Mode')"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
|
||||
@@ -137,6 +153,10 @@ const isModerator = computed(() => {
|
||||
})
|
||||
|
||||
const getClosedJobCount = () => {
|
||||
if (!user.data?.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const filters = {
|
||||
status: 'Closed',
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<InlineLessonMenu
|
||||
v-if="lesson.data"
|
||||
v-if="lesson.data?.name"
|
||||
v-model="showInlineMenu"
|
||||
:lesson="lesson.data?.name"
|
||||
v-model:notes="notes"
|
||||
@@ -342,6 +342,7 @@ import {
|
||||
TabButtons,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
@@ -731,6 +732,7 @@ const updateVideoTime = (video) => {
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
if (!lesson.data?.membership) return
|
||||
let timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
if (timer.value == 30) {
|
||||
@@ -798,6 +800,10 @@ const enrollStudent = () => {
|
||||
onSuccess() {
|
||||
window.location.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,24 +50,68 @@
|
||||
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
|
||||
<div class="flex flex-col md:flex-row items-center">
|
||||
<div>
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
:src="profile.data.user_image"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:user="profile.data"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<div class="relative">
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
:src="profile.data.user_image"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-[100px] w-[100px] rounded-full border-4 border-white bg-surface-gray-2 text-3xl font-semibold text-ink-gray-7"
|
||||
>
|
||||
{{ profile.data.full_name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="profile.data.open_to"
|
||||
:text="
|
||||
profile.data.open_to === 'Opportunities'
|
||||
? __('Open to Opportunities')
|
||||
: __('Hiring')
|
||||
"
|
||||
placement="right"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-3 right-1 p-0.5 bg-surface-white rounded-full"
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-fit"
|
||||
:class="
|
||||
profile.data.open_to === 'Opportunities'
|
||||
? 'bg-surface-green-3'
|
||||
: 'bg-purple-500'
|
||||
"
|
||||
>
|
||||
<BadgeCheckIcon class="text-ink-white size-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-6">
|
||||
<h2 class="mt-2 text-3xl font-semibold text-ink-gray-9">
|
||||
<div class="ml-6 mt-5">
|
||||
<h2 class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ profile.data.full_name }}
|
||||
</h2>
|
||||
<div class="mt-2 text-base text-ink-gray-7">
|
||||
<div class="text-base text-ink-gray-7 mt-1">
|
||||
{{ profile.data.headline }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 mt-2">
|
||||
<Twitter
|
||||
v-if="profile.data.twitter"
|
||||
class="size-4 text-ink-gray-5 cursor-pointer"
|
||||
@click="navigateTo(profile.data.twitter)"
|
||||
/>
|
||||
<Linkedin
|
||||
v-if="profile.data.linkedin"
|
||||
class="size-4 text-ink-gray-5 cursor-pointer"
|
||||
@click="navigateTo(profile.data.linkedin)"
|
||||
/>
|
||||
<Github
|
||||
v-if="profile.data.github"
|
||||
class="size-4 text-ink-gray-5 cursor-pointer"
|
||||
@click="navigateTo(profile.data.github)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isSessionUser() && !readOnlyMode"
|
||||
@@ -81,7 +125,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-6">
|
||||
<div class="mb-4 mt-10">
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="getTabButtons()"
|
||||
@@ -104,11 +148,19 @@ import {
|
||||
call,
|
||||
createResource,
|
||||
TabButtons,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Edit, RefreshCcw } from 'lucide-vue-next'
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
Edit,
|
||||
Github,
|
||||
Linkedin,
|
||||
RefreshCcw,
|
||||
Twitter,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
@@ -229,6 +281,10 @@ const reloadUser = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const navigateTo = (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
|
||||
@@ -56,11 +56,13 @@
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="w-[250px] text-base">
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="bg-surface-gray-2 rounded-t-md h-[200px] mx-auto"
|
||||
/>
|
||||
<div class="bg-surface-gray-2 rounded-t-md py-5">
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="h-[200px] mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
{{ badge.badge }}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div v-if="program.data" class="text-base">
|
||||
<div v-if="program.data" class="text-base text-ink-gray-9">
|
||||
<div class="bg-surface-blue-2 text-ink-blue-3 p-2 rounded-md leading-5">
|
||||
<span>
|
||||
{{
|
||||
@@ -46,9 +46,9 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div
|
||||
v-for="course in program.data.courses"
|
||||
class="flex flex-col border p-2 rounded-md h-full"
|
||||
class="flex flex-col border border-outline-gray-2 p-2 rounded-md h-full"
|
||||
>
|
||||
<div class="font-semibold leading-5 mb-2">
|
||||
<div class="font-semibold text-ink-gray-9 leading-5 mb-2">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
<div class="flex items-center space-x-1 mt-auto">
|
||||
<UserAvatar :user="course.instructors[0]" />
|
||||
<span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ course.instructors[0].full_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
@click="openDetails(program.name, category)"
|
||||
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer"
|
||||
>
|
||||
<div class="text-lg font-semibold mb-2">
|
||||
<div class="text-lg font-semibold text-ink-gray-9 mb-2">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<div v-if="Object.keys(program).includes('progress')" class="mt-5">
|
||||
<ProgressBar :progress="program.progress" />
|
||||
<div class="text-sm mt-1">
|
||||
<div class="text-sm text-ink-gray-7 mt-1">
|
||||
{{ Math.ceil(program.progress) }}% {{ __('completed') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
254
frontend/src/pages/Search/Search.vue
Normal file
254
frontend/src/pages/Search/Search.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="[{ label: __('Search') }]" />
|
||||
</header>
|
||||
<div class="w-4/6 mx-auto py-5">
|
||||
<div class="px-2.5">
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
class="flex-1"
|
||||
placeholder="Search for a keyword or phrase and press enter"
|
||||
autocomplete="off"
|
||||
:model-value="query"
|
||||
@update:model-value="updateQuery"
|
||||
@keydown.enter="() => submit()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="w-4 text-ink-gray-5" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
v-if="query"
|
||||
@click="clearSearch"
|
||||
class="p-1 size-6 grid place-content-center focus:outline-none focus:ring focus:ring-outline-gray-3 rounded"
|
||||
>
|
||||
<X class="w-4 text-ink-gray-7" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</TextInput>
|
||||
<div
|
||||
v-if="query && searchResults.length"
|
||||
class="text-sm text-ink-gray-5 mt-2"
|
||||
>
|
||||
{{ searchResults.length }}
|
||||
{{ searchResults.length === 1 ? __('match') : __('matches') }}
|
||||
</div>
|
||||
<div v-else-if="queryChanged" class="text-sm text-ink-gray-5 mt-2">
|
||||
{{ __('Press enter to search') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="query && !searchResults.length"
|
||||
class="text-sm text-ink-gray-5 mt-2"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div v-if="searchResults.length" class="">
|
||||
<div
|
||||
v-for="(result, index) in searchResults"
|
||||
@click="navigate(result)"
|
||||
class="rounded-md cursor-pointer hover:bg-surface-gray-2 px-2"
|
||||
>
|
||||
<div
|
||||
class="flex space-x-2 py-3"
|
||||
:class="{
|
||||
'border-b': index !== searchResults.length - 1,
|
||||
}"
|
||||
>
|
||||
<Tooltip :text="result.author_info.full_name">
|
||||
<Avatar
|
||||
:label="result.author_info.full_name"
|
||||
:image="result.author_info.user_image"
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="space-y-1 w-full">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
v-html="result.title"
|
||||
></div>
|
||||
<div class="text-sm text-ink-gray-5 ml-2">
|
||||
{{ getDocTypeTitle(result.doctype) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
result.published_on ||
|
||||
result.start_date ||
|
||||
result.creation ||
|
||||
result.modified
|
||||
"
|
||||
class="ml-auto text-sm text-ink-gray-5"
|
||||
>
|
||||
{{
|
||||
dayjs(
|
||||
result.published_on ||
|
||||
result.start_date ||
|
||||
result.creation ||
|
||||
result.modified
|
||||
).format('DD MMM YYYY')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-ink-gray-7"
|
||||
v-html="result.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
debounce,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { inject, onMounted, ref, watch } from 'vue'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const query = ref('')
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const searchResults = ref<Array<any>>([])
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const queryChanged = ref(false)
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
|
||||
onMounted(() => {
|
||||
if (router.currentRoute.value.query.q) {
|
||||
query.value = router.currentRoute.value.query.q as string
|
||||
submit()
|
||||
}
|
||||
})
|
||||
|
||||
const updateQuery = (value: string) => {
|
||||
query.value = value
|
||||
router.replace({ query: value ? { q: value } : {} })
|
||||
}
|
||||
|
||||
const submit = debounce(() => {
|
||||
if (query.value.length > 2) {
|
||||
search.reload()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const search = createResource({
|
||||
url: 'lms.command_palette.search_sqlite',
|
||||
makeParams: () => ({
|
||||
query: query.value,
|
||||
}),
|
||||
onSuccess() {
|
||||
generateSearchResults()
|
||||
},
|
||||
})
|
||||
|
||||
const generateSearchResults = () => {
|
||||
searchResults.value = []
|
||||
if (search.data) {
|
||||
queryChanged.value = false
|
||||
search.data.forEach((group: any) => {
|
||||
group.items.forEach((item: any) => {
|
||||
searchResults.value.push(item)
|
||||
})
|
||||
})
|
||||
sortResults()
|
||||
}
|
||||
}
|
||||
|
||||
const sortResults = () => {
|
||||
searchResults.value.sort((a, b) => {
|
||||
const dateA = new Date(
|
||||
a.published_on || a.start_date || a.creation || a.modified
|
||||
).getTime()
|
||||
const dateB = new Date(
|
||||
b.published_on || b.start_date || b.creation || b.modified
|
||||
).getTime()
|
||||
return dateB - dateA
|
||||
})
|
||||
}
|
||||
|
||||
const navigate = (result: any) => {
|
||||
if (result.doctype == 'LMS Course') {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: result.name,
|
||||
},
|
||||
})
|
||||
} else if (result.doctype == 'LMS Batch') {
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: result.name,
|
||||
},
|
||||
})
|
||||
} else if (result.doctype == 'Job Opportunity') {
|
||||
router.push({
|
||||
name: 'JobDetail',
|
||||
params: {
|
||||
job: result.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, () => {
|
||||
if (query.value && query.value != search.params?.query) {
|
||||
queryChanged.value = true
|
||||
} else if (!query.value) {
|
||||
queryChanged.value = false
|
||||
searchResults.value = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQ) => {
|
||||
if (newQ && newQ !== query.value) {
|
||||
query.value = newQ as string
|
||||
submit()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getDocTypeTitle = (doctype: string) => {
|
||||
if (doctype === 'LMS Course') {
|
||||
return __('Course')
|
||||
} else if (doctype === 'LMS Batch') {
|
||||
return __('Batch')
|
||||
} else if (doctype === 'Job Opportunity') {
|
||||
return __('Job')
|
||||
} else {
|
||||
return doctype
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
query.value = ''
|
||||
updateQuery('')
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Search'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -243,6 +243,11 @@ const routes = [
|
||||
),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'Search',
|
||||
component: () => import('@/pages/Search/Search.vue'),
|
||||
},
|
||||
{
|
||||
path: '/data-import',
|
||||
name: 'DataImportList',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { sessionStore } from './session'
|
||||
|
||||
export const useSettings = defineStore('settings', () => {
|
||||
const isSettingsOpen = ref(false)
|
||||
const isCommandPaletteOpen = ref(false)
|
||||
const activeTab = ref(null)
|
||||
|
||||
const settings = createResource({
|
||||
@@ -19,9 +19,16 @@ export const useSettings = defineStore('settings', () => {
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const programs = createResource({
|
||||
url: 'lms.lms.utils.get_programs',
|
||||
auto: false,
|
||||
})
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
isSettingsOpen,
|
||||
isCommandPaletteOpen,
|
||||
programs,
|
||||
settings,
|
||||
sidebarSettings,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||
import translationPlugin from '../translation'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { call } from 'frappe-ui'
|
||||
import router from '@/router'
|
||||
|
||||
export class Assignment {
|
||||
constructor({ data, api, readOnly }) {
|
||||
@@ -84,6 +85,7 @@ export class Assignment {
|
||||
},
|
||||
})
|
||||
app.use(translationPlugin)
|
||||
app.use(router)
|
||||
app.mount(this.wrapper)
|
||||
}
|
||||
|
||||
|
||||
@@ -403,46 +403,176 @@ export function getUserTimezone() {
|
||||
}
|
||||
|
||||
export function getSidebarLinks() {
|
||||
let links = getSidebarItems()
|
||||
|
||||
links.forEach((link) => {
|
||||
link.items = link.items.filter((item) => {
|
||||
return item.condition ? item.condition() : true
|
||||
})
|
||||
})
|
||||
|
||||
links = links.filter((link) => {
|
||||
return link.items.length > 0
|
||||
})
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
const getSidebarItems = () => {
|
||||
const { userResource } = usersStore()
|
||||
const { settings } = useSettings()
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Courses',
|
||||
icon: 'BookOpen',
|
||||
to: 'Courses',
|
||||
activeFor: [
|
||||
'Courses',
|
||||
'CourseDetail',
|
||||
'Lesson',
|
||||
'CourseForm',
|
||||
'LessonForm',
|
||||
label: 'General',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'Home',
|
||||
to: 'Home',
|
||||
condition: () => {
|
||||
return userResource?.data
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
icon: 'Search',
|
||||
to: 'Search',
|
||||
condition: () => {
|
||||
return userResource?.data
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
condition: () => {
|
||||
return userResource?.data
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Batches',
|
||||
icon: 'Users',
|
||||
to: 'Batches',
|
||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||
label: 'Learning',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Courses',
|
||||
icon: 'BookOpen',
|
||||
to: 'Courses',
|
||||
activeFor: [
|
||||
'Courses',
|
||||
'CourseDetail',
|
||||
'Lesson',
|
||||
'CourseForm',
|
||||
'LessonForm',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: ['Programs', 'ProgramDetail'],
|
||||
await: true,
|
||||
condition: () => {
|
||||
return checkIfCanAddProgram()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Batches',
|
||||
icon: 'Users',
|
||||
to: 'Batches',
|
||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||
},
|
||||
{
|
||||
label: 'Certifications',
|
||||
icon: 'GraduationCap',
|
||||
to: 'CertifiedParticipants',
|
||||
activeFor: ['CertifiedParticipants'],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
icon: 'Briefcase',
|
||||
to: 'Jobs',
|
||||
activeFor: ['Jobs', 'JobDetail'],
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
icon: 'TrendingUp',
|
||||
to: 'Statistics',
|
||||
activeFor: ['Statistics'],
|
||||
},
|
||||
{
|
||||
label: 'Contact Us',
|
||||
icon: settings.data?.contact_us_url ? 'Headset' : 'Mail',
|
||||
to: settings.data?.contact_us_url
|
||||
? settings.data?.contact_us_url
|
||||
: settings.data?.contact_us_email,
|
||||
condition: () => {
|
||||
return (
|
||||
settings?.data?.contact_us_email ||
|
||||
settings?.data?.contact_us_url
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Certifications',
|
||||
icon: 'GraduationCap',
|
||||
to: 'CertifiedParticipants',
|
||||
activeFor: ['CertifiedParticipants'],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
icon: 'Briefcase',
|
||||
to: 'Jobs',
|
||||
activeFor: ['Jobs', 'JobDetail'],
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
icon: 'TrendingUp',
|
||||
to: 'Statistics',
|
||||
activeFor: ['Statistics'],
|
||||
label: 'Assessments',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
condition: () => {
|
||||
return isAdmin()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
condition: () => {
|
||||
return isAdmin()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
condition: () => {
|
||||
return isAdmin()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
const { userResource } = usersStore()
|
||||
return (
|
||||
userResource?.data?.is_instructor ||
|
||||
userResource?.data?.is_moderator ||
|
||||
userResource.data?.is_evaluator
|
||||
)
|
||||
}
|
||||
|
||||
const checkIfCanAddProgram = () => {
|
||||
const { userResource } = usersStore()
|
||||
const { programs } = useSettings()
|
||||
if (!userResource.data) return false
|
||||
if (userResource?.data?.is_moderator || userResource?.data?.is_instructor) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
programs.data?.enrolled.length > 0 ||
|
||||
programs.data?.published.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getFormattedDateRange(
|
||||
startDate,
|
||||
endDate,
|
||||
|
||||
@@ -1,70 +1,86 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import frappeui from 'frappe-ui/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
frappeui({
|
||||
frappeProxy: true,
|
||||
lucideIcons: true,
|
||||
jinjaBootData: true,
|
||||
frappeTypes: {
|
||||
input: {},
|
||||
},
|
||||
buildConfig: {
|
||||
indexHtmlPath: '../lms/www/lms.html',
|
||||
},
|
||||
}),
|
||||
vue({
|
||||
script: {
|
||||
defineModel: true,
|
||||
propsDestructure: true,
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
globDirectory: '/assets/lms/frontend',
|
||||
globPatterns: ['**/*.{js,ts,css,html,png,svg}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ request }) =>
|
||||
request.destination === 'document',
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'html-cache',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: false,
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0', // Accept connections from any network interface
|
||||
allowedHosts: ['ps', 'fs', 'home'], // Explicitly allow this host
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const isDev = mode === 'development'
|
||||
const frappeui = await importFrappeUIPlugin(isDev)
|
||||
|
||||
const config = {
|
||||
define: {
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'feather-icons',
|
||||
'engine.io-client',
|
||||
'interactjs',
|
||||
'highlight.js',
|
||||
'plyr',
|
||||
plugins: [
|
||||
frappeui({
|
||||
frappeProxy: true,
|
||||
lucideIcons: true,
|
||||
jinjaBootData: true,
|
||||
buildConfig: {
|
||||
indexHtmlPath: '../lms/www/lms.html',
|
||||
},
|
||||
}),
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
globDirectory: '/assets/lms/frontend',
|
||||
globPatterns: ['**/*.{js,ts,css,html,png,svg}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ request }) =>
|
||||
request.destination === 'document',
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'html-cache',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: false,
|
||||
}),
|
||||
],
|
||||
exclude: mode === 'production' ? [] : ['frappe-ui'],
|
||||
},
|
||||
}))
|
||||
server: {
|
||||
host: '0.0.0.0', // Accept connections from any network interface
|
||||
allowedHosts: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'feather-icons',
|
||||
'tailwind.config.js',
|
||||
'interactjs',
|
||||
'highlight.js',
|
||||
'plyr',
|
||||
],
|
||||
exclude: mode === 'production' ? [] : ['frappe-ui'],
|
||||
},
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
async function importFrappeUIPlugin(isDev) {
|
||||
if (isDev) {
|
||||
try {
|
||||
const module = await import('../frappe-ui/vite')
|
||||
return module.default
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Local frappe-ui not found, falling back to npm package:',
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
// Fall back to npm package if local import fails
|
||||
const module = await import('frappe-ui/vite')
|
||||
return module.default
|
||||
}
|
||||
|
||||
5561
frontend/yarn.lock
5561
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user