Merge branch 'develop' of https://github.com/frappe/lms into tests-1
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<Layout class="isolate text-base">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<InstallPrompt v-if="isMobile" />
|
||||
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||
<Dialogs />
|
||||
</FrappeUIProvider>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { posthogSettings } from '@/telemetry'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
@@ -24,6 +25,7 @@ const { isMobile } = useScreenSize()
|
||||
const router = useRouter()
|
||||
const noSidebar = ref(false)
|
||||
const { userResource } = usersStore()
|
||||
const { settings } = useSettings()
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.query.fromLesson || to.path === '/persona') {
|
||||
|
||||
@@ -71,7 +71,7 @@ const addAssessment = () => {
|
||||
}
|
||||
|
||||
const redirectToForm = () => {
|
||||
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
|
||||
else window.open('/lms/assignments/new', '_blank')
|
||||
if (props.type == 'quiz') window.open('/lms/quizzes?new=true', '_blank')
|
||||
else window.open('/lms/assignments?new=true', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
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>
|
||||
@@ -48,7 +48,7 @@ const settingsStore = useSettings()
|
||||
|
||||
const sendMail = (close: Function) => {
|
||||
call('frappe.core.doctype.communication.email.make', {
|
||||
recipients: settingsStore.contactUsEmail?.data,
|
||||
recipients: settingsStore.settings?.data?.contact_us_email,
|
||||
subject: subject.value,
|
||||
content: message.value,
|
||||
send_email: true,
|
||||
|
||||
@@ -156,7 +156,7 @@ const getGradientColor = () => {
|
||||
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = colors[theme][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
return `linear-gradient(to top right, black, ${colorMap[200]})`
|
||||
/* 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%)` */
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML } from '@/utils'
|
||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -122,12 +122,13 @@ watch(show, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
const validateFields = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
assignment.question = sanitizeHTML(assignment.question)
|
||||
}
|
||||
|
||||
const saveAssignment = () => {
|
||||
validateTitle()
|
||||
validateFields()
|
||||
if (props.assignmentID == 'new') {
|
||||
createAssignment()
|
||||
} else {
|
||||
|
||||
@@ -104,9 +104,8 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { getFileSize, decodeEntities } from '@/utils'
|
||||
import { getFileSize, sanitizeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
const hasLanguageChanged = ref(false)
|
||||
@@ -157,22 +156,7 @@ const updateProfile = createResource({
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), {
|
||||
ALLOWED_TAGS: [
|
||||
'b',
|
||||
'i',
|
||||
'em',
|
||||
'strong',
|
||||
'a',
|
||||
'p',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'src'],
|
||||
})
|
||||
profile.bio = sanitizeHTML(profile.bio)
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
|
||||
@@ -120,6 +120,13 @@ const tabsStructure = computed(() => {
|
||||
description:
|
||||
'If enabled, users will no able to move forward in a video',
|
||||
},
|
||||
{
|
||||
label: 'Disable PWA',
|
||||
name: 'disable_pwa',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If checked, users will not be able to install the application as a Progressive Web App.',
|
||||
},
|
||||
{
|
||||
label: 'Send calendar invite for evaluations',
|
||||
name: 'send_calendar_invite_for_evaluations',
|
||||
|
||||
@@ -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,19 +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 { sidebarSettings, activeTab, isSettingsOpen, programs } = useSettings()
|
||||
const settingsStore = useSettings()
|
||||
const { sidebarSettings } = settingsStore
|
||||
const showOnboarding = ref(false)
|
||||
const showIntermediateModal = ref(false)
|
||||
const currentStep = ref({})
|
||||
@@ -255,9 +266,8 @@ const iconProps = {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addNotifications()
|
||||
setSidebarLinks()
|
||||
setUpOnboarding()
|
||||
addKeyboardShortcut()
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unreadNotifications.reload()
|
||||
})
|
||||
@@ -270,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
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -280,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',
|
||||
@@ -294,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 (!settingsStore.contactUsEmail?.data && !settingsStore.contactUsURL?.data)
|
||||
return
|
||||
|
||||
const contactUsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Contact Us'
|
||||
)
|
||||
if (contactUsLinkExists) return
|
||||
|
||||
sidebarLinks.value.push({
|
||||
label: 'Contact Us',
|
||||
icon: settingsStore.contactUsURL?.data ? 'Headset' : 'Mail',
|
||||
to: settingsStore.contactUsURL?.data
|
||||
? settingsStore.contactUsURL.data
|
||||
: settingsStore.contactUsEmail?.data,
|
||||
})
|
||||
}
|
||||
|
||||
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'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -540,8 +447,8 @@ const steps = reactive([
|
||||
completed: false,
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
settingsStore.activeTab = 'Members'
|
||||
settingsStore.isSettingsOpen = true
|
||||
activeTab.value = 'Members'
|
||||
isSettingsOpen.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -675,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) => {
|
||||
|
||||
@@ -171,7 +171,7 @@ const showQuizLoader = ref(false)
|
||||
const quizLoadTimer = ref(0)
|
||||
const currentQuiz = ref(null)
|
||||
const nextQuiz = ref({})
|
||||
const { preventSkippingVideos } = useSettings()
|
||||
const { settings } = useSettings()
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
@@ -299,7 +299,7 @@ const toggleMute = () => {
|
||||
|
||||
const changeCurrentTime = () => {
|
||||
if (
|
||||
preventSkippingVideos.data &&
|
||||
settings.data?.prevent_skipping_videos &&
|
||||
currentTime.value > videoRef.value.currentTime
|
||||
)
|
||||
return
|
||||
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
@@ -99,12 +99,17 @@ const assignmentID = ref('new')
|
||||
const assignmentCount = ref(0)
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
if (route.query.new === 'true') {
|
||||
assignmentID.value = 'new'
|
||||
showAssignmentForm.value = true
|
||||
}
|
||||
getAssignmentCount()
|
||||
titleFilter.value = router.currentRoute.value.query.title
|
||||
typeFilter.value = router.currentRoute.value.query.type
|
||||
|
||||
@@ -340,11 +340,12 @@ import { sessionStore } from '../stores/session'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import {
|
||||
openSettings,
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
escapeHTML,
|
||||
} from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -502,6 +503,9 @@ const imageResource = createResource({
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
batch.description = sanitizeHTML(batch.description)
|
||||
batch.batch_details = sanitizeHTML(batch.batch_details)
|
||||
|
||||
Object.keys(batch).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
|
||||
@@ -353,11 +353,12 @@ import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import {
|
||||
openSettings,
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
escapeHTML,
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
@@ -539,6 +540,8 @@ const imageResource = createResource({
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
course.description = sanitizeHTML(course.description)
|
||||
|
||||
Object.keys(course).forEach((key) => {
|
||||
if (key != 'description' && typeof course[key] === 'string') {
|
||||
course[key] = escapeHTML(course[key])
|
||||
|
||||
@@ -158,7 +158,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { escapeHTML, getFileSize, validateFile } from '@/utils'
|
||||
import { escapeHTML, getFileSize, sanitizeHTML, validateFile } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
@@ -254,8 +254,18 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (props.jobName != 'new') jobDetail.reload()
|
||||
addKeyboardShortcuts()
|
||||
})
|
||||
|
||||
const addKeyboardShortcuts = () => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
saveJob()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveJob = () => {
|
||||
validateJobFields()
|
||||
if (jobDetail.data) {
|
||||
@@ -304,6 +314,7 @@ const editJobDetails = () => {
|
||||
}
|
||||
|
||||
const validateJobFields = () => {
|
||||
job.description = sanitizeHTML(job.description)
|
||||
Object.keys(job).forEach((key) => {
|
||||
if (key != 'description' && typeof job[key] === 'string') {
|
||||
job[key] = escapeHTML(job[key])
|
||||
@@ -359,7 +370,7 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.title,
|
||||
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.job_title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -168,7 +168,7 @@ const testCaseSection = ref<HTMLElement | null>(null)
|
||||
const testCases = ref<TestCase[]>([])
|
||||
const boilerplate = ref<string>('')
|
||||
const { brand } = sessionStore()
|
||||
const { livecodeURL } = useSettings()
|
||||
const { settings } = useSettings()
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||
@@ -291,8 +291,8 @@ watch(
|
||||
)
|
||||
|
||||
const loadFalcon = () => {
|
||||
if (livecodeURL.data) {
|
||||
falconURL.value = livecodeURL.data
|
||||
if (settings.data) {
|
||||
falconURL.value = settings.data.livecode_url
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
|
||||
@@ -134,7 +134,7 @@ import {
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
@@ -145,6 +145,7 @@ const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const search = ref('')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const quizFilters = ref({})
|
||||
@@ -157,6 +158,9 @@ onMounted(() => {
|
||||
} else if (!user.data?.is_moderator) {
|
||||
quizFilters.value['owner'] = user.data?.name
|
||||
}
|
||||
if (route.query.new === 'true') {
|
||||
showForm.value = true
|
||||
}
|
||||
})
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
232
frontend/src/pages/Search/Search.vue
Normal file
232
frontend/src/pages/Search/Search.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<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" 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
|
||||
"
|
||||
class="ml-auto text-sm text-ink-gray-5"
|
||||
>
|
||||
{{
|
||||
dayjs(
|
||||
result.published_on ||
|
||||
result.start_date ||
|
||||
result.creation
|
||||
).format('DD MMM YYYY')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="leading-5" 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)
|
||||
})
|
||||
})
|
||||
searchResults.value.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -270,7 +275,7 @@ let router = createRouter({
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { userResource } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const { allowGuestAccess } = useSettings()
|
||||
const { settings } = useSettings()
|
||||
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
@@ -283,8 +288,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (!isLoggedIn) {
|
||||
if (to.name == 'Home') router.push({ name: 'Courses' })
|
||||
|
||||
await allowGuestAccess.promise
|
||||
if (!allowGuestAccess.data) {
|
||||
await settings.promise
|
||||
if (!settings.data.allow_guest_access) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
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 allowGuestAccess = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'allow_guest_access' },
|
||||
const settings = createResource({
|
||||
url: 'lms.lms.api.get_lms_settings',
|
||||
auto: true,
|
||||
cache: ['allowGuestAccess'],
|
||||
})
|
||||
|
||||
const preventSkippingVideos = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'prevent_skipping_videos' },
|
||||
auto: true,
|
||||
cache: ['preventSkippingVideos'],
|
||||
})
|
||||
|
||||
const contactUsEmail = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'contact_us_email' },
|
||||
auto: true,
|
||||
cache: ['contactUsEmail'],
|
||||
})
|
||||
|
||||
const contactUsURL = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'contact_us_url' },
|
||||
auto: true,
|
||||
cache: ['contactUsURL'],
|
||||
cache: 'LMS Settings',
|
||||
})
|
||||
|
||||
const sidebarSettings = createResource({
|
||||
@@ -41,21 +19,17 @@ export const useSettings = defineStore('settings', () => {
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const livecodeURL = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'livecode_url' },
|
||||
auto: true,
|
||||
cache: ['livecodeURL'],
|
||||
const programs = createResource({
|
||||
url: 'lms.lms.utils.get_programs',
|
||||
auto: false,
|
||||
})
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
allowGuestAccess,
|
||||
preventSkippingVideos,
|
||||
contactUsEmail,
|
||||
contactUsURL,
|
||||
isSettingsOpen,
|
||||
isCommandPaletteOpen,
|
||||
programs,
|
||||
settings,
|
||||
sidebarSettings,
|
||||
livecodeURL,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import SimpleImage from '@editorjs/simple-image'
|
||||
import Table from '@editorjs/table'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
@@ -402,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,
|
||||
@@ -540,6 +671,26 @@ export const escapeHTML = (text) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const sanitizeHTML = (text) => {
|
||||
text = DOMPurify.sanitize(decodeEntities(text), {
|
||||
ALLOWED_TAGS: [
|
||||
'b',
|
||||
'i',
|
||||
'em',
|
||||
'strong',
|
||||
'a',
|
||||
'p',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'src'],
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
export const canCreateCourse = () => {
|
||||
const { userResource } = usersStore()
|
||||
return (
|
||||
@@ -591,7 +742,7 @@ const setupPlyrForVideo = (video, players) => {
|
||||
const current_time = player.currentTime
|
||||
const newTime = getTargetTime(player, e)
|
||||
if (
|
||||
useSettings().preventSkippingVideos.data &&
|
||||
useSettings().settings.data?.prevent_skipping_videos &&
|
||||
parseFloat(newTime) > current_time
|
||||
) {
|
||||
e.preventDefault()
|
||||
|
||||
Reference in New Issue
Block a user