chore: fixed merge conflicts
This commit is contained in:
719
frontend/src/components/Sidebar/AppSidebar.vue
Normal file
719
frontend/src/components/Sidebar/AppSidebar.vue
Normal file
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-r bg-surface-menu-bar"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||
class="mt-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
@click="toggleWebPages"
|
||||
>
|
||||
<div
|
||||
v-if="!sidebarStore.isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-ink-gray-5 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<ChevronRight
|
||||
class="h-4 w-4 stroke-1.5 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
{{ __('More') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isModerator && !readOnlyMode"
|
||||
variant="ghost"
|
||||
@click="openPageModal()"
|
||||
>
|
||||
<template #icon>
|
||||
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length"
|
||||
class="flex flex-col transition-all duration-300 ease-in-out"
|
||||
:class="!sidebarStore.isWebpagesCollapsed ? 'block' : 'hidden'"
|
||||
>
|
||||
<div
|
||||
v-for="link in sidebarSettings.data.web_pages"
|
||||
class="mx-2 my-0.5"
|
||||
>
|
||||
<SidebarLink
|
||||
:link="link"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
:showControls="isModerator ? true : false"
|
||||
@openModal="openPageModal"
|
||||
@deletePage="deletePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-2 flex flex-col gap-1">
|
||||
<div
|
||||
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed"
|
||||
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<TrialBanner
|
||||
v-if="
|
||||
userResource.data?.is_system_manager && userResource.data?.is_fc_site
|
||||
"
|
||||
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
/>
|
||||
<GettingStartedBanner
|
||||
v-if="showOnboarding && !isOnboardingStepsCompleted"
|
||||
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
appName="learning"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex items-center mt-4"
|
||||
:class="
|
||||
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-1"
|
||||
:class="
|
||||
sidebarStore.isSidebarCollapsed
|
||||
? 'flex-col space-y-3'
|
||||
: 'flex-row space-x-3'
|
||||
"
|
||||
>
|
||||
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
|
||||
<CircleAlert
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
/>
|
||||
<template #body>
|
||||
<div
|
||||
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Learning')">
|
||||
<Zap
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="showOnboarding" :text="__('Help')">
|
||||
<CircleHelp
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="
|
||||
() => {
|
||||
showHelpModal = minimize ? true : !showHelpModal
|
||||
minimize = !showHelpModal
|
||||
}
|
||||
"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="
|
||||
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
|
||||
"
|
||||
>
|
||||
<CollapseSidebar
|
||||
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
|
||||
:class="{
|
||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||
}"
|
||||
@click="toggleSidebar()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<HelpModal
|
||||
v-if="showOnboarding && showHelpModal"
|
||||
v-model="showHelpModal"
|
||||
v-model:articles="articles"
|
||||
appName="learning"
|
||||
title="Frappe Learning"
|
||||
:logo="LMSLogo"
|
||||
:afterSkip="(step) => capture('onboarding_step_skipped_' + step)"
|
||||
:afterSkipAll="() => capture('onboarding_steps_skipped')"
|
||||
:afterReset="(step) => capture('onboarding_step_reset_' + step)"
|
||||
:afterResetAll="() => capture('onboarding_steps_reset')"
|
||||
docsLink="https://docs.frappe.io/learning"
|
||||
/>
|
||||
<IntermediateStepModal
|
||||
v-model="showIntermediateModal"
|
||||
:currentStep="currentStep"
|
||||
/>
|
||||
</div>
|
||||
<CommandPalette v-model="settingsStore.isCommandPaletteOpen" />
|
||||
<PageModal
|
||||
v-model="showPageModal"
|
||||
v-model:reloadSidebar="sidebarSettings"
|
||||
:page="pageToEdit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { Button, call, createResource, Tooltip, toast } from 'frappe-ui'
|
||||
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,
|
||||
inject,
|
||||
watch,
|
||||
reactive,
|
||||
markRaw,
|
||||
h,
|
||||
onUnmounted,
|
||||
} from 'vue'
|
||||
import {
|
||||
BookOpen,
|
||||
CircleAlert,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
CircleHelp,
|
||||
FolderTree,
|
||||
FileText,
|
||||
UserPlus,
|
||||
Users,
|
||||
BookText,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TrialBanner,
|
||||
HelpModal,
|
||||
GettingStartedBanner,
|
||||
useOnboarding,
|
||||
showHelpModal,
|
||||
minimize,
|
||||
IntermediateStepModal,
|
||||
} from 'frappe-ui/frappe'
|
||||
import InviteIcon from './Icons/InviteIcon.vue'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/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 showPageModal = ref(false)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
const pageToEdit = ref(null)
|
||||
const settingsStore = useSettings()
|
||||
const { sidebarSettings } = settingsStore
|
||||
const showOnboarding = ref(false)
|
||||
const showIntermediateModal = ref(false)
|
||||
const currentStep = ref({})
|
||||
const router = useRouter()
|
||||
let onboardingDetails
|
||||
let isOnboardingStepsCompleted = false
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const iconProps = {
|
||||
strokeWidth: 1.5,
|
||||
width: 16,
|
||||
height: 16,
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addNotifications()
|
||||
setSidebarLinks()
|
||||
setUpOnboarding()
|
||||
addKeyboardShortcut()
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unreadNotifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
const setSidebarLinks = () => {
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!parseInt(data[key])) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Notification Log',
|
||||
filters: {
|
||||
for_user: user,
|
||||
read: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
unreadCount.value = data
|
||||
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
||||
if (link.label === 'Notifications') {
|
||||
link.count = data
|
||||
}
|
||||
return link
|
||||
})
|
||||
},
|
||||
auto: user ? true : false,
|
||||
})
|
||||
|
||||
const addNotifications = () => {
|
||||
if (user) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
activeFor: ['Notifications'],
|
||||
count: unreadCount.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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'],
|
||||
})
|
||||
}
|
||||
|
||||
const openPageModal = (link) => {
|
||||
showPageModal.value = true
|
||||
pageToEdit.value = link
|
||||
}
|
||||
|
||||
const deletePage = (link) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Sidebar Item',
|
||||
documents: [link.name],
|
||||
}).then(() => {
|
||||
sidebarSettings.reload()
|
||||
toast.success(__('Page deleted successfully'))
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
||||
localStorage.setItem(
|
||||
'isSidebarCollapsed',
|
||||
JSON.stringify(sidebarStore.isSidebarCollapsed)
|
||||
)
|
||||
}
|
||||
|
||||
const toggleWebPages = () => {
|
||||
sidebarStore.isWebpagesCollapsed = !sidebarStore.isWebpagesCollapsed
|
||||
localStorage.setItem(
|
||||
'isWebpagesCollapsed',
|
||||
JSON.stringify(sidebarStore.isWebpagesCollapsed)
|
||||
)
|
||||
}
|
||||
|
||||
const getFirstCourse = async () => {
|
||||
let firstCourse = localStorage.getItem('firstCourse')
|
||||
if (firstCourse) return firstCourse
|
||||
return await call('lms.lms.onboarding.get_first_course')
|
||||
}
|
||||
|
||||
const getFirstBatch = async () => {
|
||||
let firstBatch = localStorage.getItem('firstBatch')
|
||||
if (firstBatch) return firstBatch
|
||||
return await call('lms.lms.onboarding.get_first_batch')
|
||||
}
|
||||
|
||||
const steps = reactive([
|
||||
{
|
||||
name: 'create_first_course',
|
||||
title: __('Create your first course'),
|
||||
icon: markRaw(h(BookOpen, iconProps)),
|
||||
completed: false,
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_first_chapter',
|
||||
title: __('Add your first chapter'),
|
||||
icon: markRaw(h(FolderTree, iconProps)),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_course',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
let course = await getFirstCourse()
|
||||
if (course) {
|
||||
router.push({ name: 'CourseForm', params: { courseName: course } })
|
||||
} else {
|
||||
router.push({ name: 'CourseForm' })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_first_lesson',
|
||||
title: __('Add your first lesson'),
|
||||
icon: markRaw(h(FileText, iconProps)),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_chapter',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
let course = await getFirstCourse()
|
||||
if (course) {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: course },
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_first_quiz',
|
||||
title: __('Create your first quiz'),
|
||||
icon: markRaw(h(CircleHelp, iconProps)),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_course',
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
router.push({ name: 'Quizzes' })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invite_students',
|
||||
title: __('Invite your team and students'),
|
||||
icon: markRaw(h(InviteIcon, iconProps)),
|
||||
completed: false,
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
settingsStore.activeTab = 'Members'
|
||||
settingsStore.isSettingsOpen = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_first_batch',
|
||||
title: __('Create your first batch'),
|
||||
icon: markRaw(h(Users, iconProps)),
|
||||
completed: false,
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
router.push({ name: 'Batches' })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_batch_student',
|
||||
title: __('Add students to your batch'),
|
||||
icon: markRaw(h(UserPlus, iconProps)),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_batch',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
let batch = await getFirstBatch()
|
||||
if (batch) {
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'Batch' })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_batch_course',
|
||||
title: __('Add courses to your batch'),
|
||||
icon: markRaw(h(BookText, iconProps)),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_batch',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
let batch = await getFirstBatch()
|
||||
if (batch) {
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch,
|
||||
},
|
||||
hash: '#courses',
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'Batch' })
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const articles = ref([
|
||||
{
|
||||
title: __('Introduction'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{ name: 'introduction', title: __('Introduction') },
|
||||
{ name: 'setting-up', title: __('Setting up') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: __('Creating a course'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{ name: 'create-a-course', title: __('Create a course') },
|
||||
{ name: 'add-a-chapter', title: __('Add a chapter') },
|
||||
{ name: 'add-a-lesson', title: __('Add a lesson') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: __('Creating a batch'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{ name: 'create-a-batch', title: __('Create a batch') },
|
||||
{ name: 'create-a-live-class', title: __('Create a live class') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: __('Learning Paths'),
|
||||
opened: false,
|
||||
subArticles: [{ name: 'add-a-program', title: __('Add a program') }],
|
||||
},
|
||||
{
|
||||
title: __('Assessments'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{ name: 'quizzes', title: __('Quizzes') },
|
||||
{ name: 'assignments', title: __('Assignments') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: __('Certification'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{ name: 'issue-a-certificate', title: __('Issue a Certificate') },
|
||||
{
|
||||
name: 'custom-certificate-templates',
|
||||
title: __('Custom Certificate Templates'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: __('Monetization'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{
|
||||
name: 'setting-up-payment-gateway',
|
||||
title: __('Setting up payment gateway'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: __('Settings'),
|
||||
opened: false,
|
||||
subArticles: [{ name: 'roles', title: __('Roles') }],
|
||||
},
|
||||
])
|
||||
|
||||
const setUpOnboarding = () => {
|
||||
if (userResource.data?.is_system_manager) {
|
||||
onboardingDetails = useOnboarding('learning')
|
||||
onboardingDetails.setUp(steps)
|
||||
isOnboardingStepsCompleted = onboardingDetails.isOnboardingStepsCompleted
|
||||
showOnboarding.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
addContactUsDetails()
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addHome()
|
||||
addPrograms()
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
setUpOnboarding()
|
||||
}
|
||||
})
|
||||
|
||||
const redirectToWebsite = () => {
|
||||
window.open('https://frappe.io/learning', '_blank')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('publish_lms_notifications')
|
||||
})
|
||||
</script>
|
||||
66
frontend/src/components/Sidebar/Apps.vue
Normal file
66
frontend/src/components/Sidebar/Apps.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<Popover placement="right-start" trigger="hover" class="flex w-full">
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
|
||||
]"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<LayoutGrid class="size-4 stroke-1.5" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ __('Apps') }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-surface-white shadow-xl"
|
||||
>
|
||||
<div v-for="app in apps.data" key="name">
|
||||
<a
|
||||
:href="app.route"
|
||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-surface-gray-2"
|
||||
>
|
||||
<img class="size-8" :src="app.logo" />
|
||||
<div class="text-sm text-ink-gray-7" @click="app.onClick">
|
||||
{{ app.title }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Popover, createResource } from 'frappe-ui'
|
||||
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
const apps = createResource({
|
||||
url: 'frappe.apps.get_apps',
|
||||
cache: 'apps',
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
let _apps = [
|
||||
{
|
||||
name: 'frappe',
|
||||
logo: '/assets/lms/images/desk.png',
|
||||
title: __('Desk'),
|
||||
route: '/app',
|
||||
},
|
||||
]
|
||||
data.map((app) => {
|
||||
if (app.name === 'lms') return
|
||||
_apps.push({
|
||||
name: app.name,
|
||||
logo: app.logo,
|
||||
title: __(app.title),
|
||||
route: app.route,
|
||||
})
|
||||
})
|
||||
return _apps
|
||||
},
|
||||
})
|
||||
</script>
|
||||
26
frontend/src/components/Sidebar/Configuration.vue
Normal file
26
frontend/src/components/Sidebar/Configuration.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 justify-between bg-surface-white">
|
||||
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'DataImportList',
|
||||
query: {
|
||||
step: 'list',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<ArrowDownToLine
|
||||
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
|
||||
/>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __('Import') }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownToLine } from 'lucide-vue-next'
|
||||
</script>
|
||||
122
frontend/src/components/Sidebar/SidebarLink.vue
Normal file
122
frontend/src/components/Sidebar/SidebarLink.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="link && !link.onlyMobile"
|
||||
class="flex w-full h-7 cursor-pointer items-center rounded text-ink-gray-8 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||
:class="
|
||||
isActive ? 'bg-surface-selected shadow-sm' : 'hover:bg-surface-gray-2'
|
||||
"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
class="flex items-center w-full duration-300 ease-in-out group"
|
||||
:class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'"
|
||||
>
|
||||
<Tooltip :text="__(link.label)" placement="right">
|
||||
<slot name="icon">
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<component
|
||||
:is="icons[link.icon]"
|
||||
class="h-4 w-4 stroke-1.5 text-ink-gray-8"
|
||||
/>
|
||||
</span>
|
||||
</slot>
|
||||
</Tooltip>
|
||||
<span
|
||||
class="flex-shrink-0 text-sm duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||
: 'ml-2 w-auto opacity-100'
|
||||
"
|
||||
>
|
||||
{{ __(link.label) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="link.count && !isCollapsed"
|
||||
class="!ml-auto block text-xs text-ink-gray-5"
|
||||
:class="
|
||||
isCollapsed && link.count > 9
|
||||
? 'absolute top-[2px] right-0 bg-surface-white'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ link.count }}
|
||||
</span>
|
||||
<div
|
||||
v-if="showControls && !isCollapsed"
|
||||
class="flex items-center space-x-2 !ml-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
|
||||
>
|
||||
<component
|
||||
:is="icons['Edit']"
|
||||
class="h-3 w-3 stroke-1.5 text-ink-gray-7"
|
||||
@click.stop="openModal(link)"
|
||||
/>
|
||||
<component
|
||||
:is="icons['X']"
|
||||
class="h-3 w-3 stroke-1.5 text-ink-gray-7"
|
||||
@click.stop="deletePage(link)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<ContactUsEmail v-model="showContactForm" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContactUsEmail from '@/components/ContactUsEmail.vue'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const emit = defineEmits(['openModal', 'deletePage'])
|
||||
const showContactForm = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showControls: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (router.hasRoute(props.link.to)) {
|
||||
router.push({ name: props.link.to })
|
||||
} else if (props.link.to?.includes('@')) {
|
||||
showContactForm.value = true
|
||||
} else if (props.link.to) {
|
||||
if (props.link.to.startsWith('http')) {
|
||||
window.open(props.link.to, '_blank')
|
||||
return
|
||||
}
|
||||
window.location.href = `/${props.link.to}`
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = computed(() => {
|
||||
return (
|
||||
props.link?.activeFor?.includes(router.currentRoute.value.name) ||
|
||||
(props.activeTab && props.link?.label?.includes(props.activeTab))
|
||||
)
|
||||
})
|
||||
|
||||
const openModal = (link) => {
|
||||
emit('openModal', link)
|
||||
}
|
||||
|
||||
const deletePage = (link) => {
|
||||
emit('deletePage', link)
|
||||
}
|
||||
</script>
|
||||
244
frontend/src/components/Sidebar/UserDropdown.vue
Normal file
244
frontend/src/components/Sidebar/UserDropdown.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<Dropdown :options="userDropdownOptions">
|
||||
<template v-slot="{ open, close }">
|
||||
<button
|
||||
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'px-0 w-auto'
|
||||
: open
|
||||
? 'bg-surface-white shadow-sm px-2 w-52'
|
||||
: 'hover:bg-surface-gray-3 px-2 w-52'
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="branding.data?.banner_image"
|
||||
:src="branding.data?.banner_image.file_url"
|
||||
class="w-8 h-8 rounded flex-shrink-0"
|
||||
/>
|
||||
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||
<div
|
||||
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-9 leading-none">
|
||||
<span
|
||||
v-if="
|
||||
branding.data?.app_name && branding.data?.app_name != 'Frappe'
|
||||
"
|
||||
>
|
||||
{{ branding.data?.app_name }}
|
||||
</span>
|
||||
<span v-else> Learning </span>
|
||||
</div>
|
||||
<div
|
||||
v-if="userResource.data"
|
||||
class="mt-1 text-sm text-ink-gray-7 leading-none"
|
||||
>
|
||||
{{ convertToTitleCase(userResource.data?.full_name) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 text-ink-gray-7" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<SettingsModal
|
||||
v-if="userResource.data?.is_moderator"
|
||||
v-model="showSettingsModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
||||
import { createDialog } from '@/utils/dialogs'
|
||||
import Apps from '@/components/Sidebar/Apps.vue'
|
||||
import Configuration from '@/components/Sidebar/Configuration.vue'
|
||||
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import SettingsModal from '@/components/Settings/Settings.vue'
|
||||
import {
|
||||
ChevronDown,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Moon,
|
||||
User,
|
||||
Settings,
|
||||
Sun,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const { logout, branding } = sessionStore()
|
||||
let { userResource } = usersStore()
|
||||
const settingsStore = useSettings()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const showSettingsModal = ref(false)
|
||||
const theme = ref('light')
|
||||
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
|
||||
const $dialog = createDialog
|
||||
|
||||
const props = defineProps({
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
theme.value = localStorage.getItem('theme') || 'light'
|
||||
if (['light', 'dark'].includes(theme.value)) {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => settingsStore.isSettingsOpen,
|
||||
(value) => {
|
||||
showSettingsModal.value = value
|
||||
}
|
||||
)
|
||||
|
||||
const toggleTheme = () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme')
|
||||
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
|
||||
const userDropdownOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
group: '',
|
||||
items: [
|
||||
{
|
||||
icon: User,
|
||||
label: 'My Profile',
|
||||
onClick: () => {
|
||||
router.push(`/user/${userResource.data?.username}`)
|
||||
},
|
||||
condition: () => {
|
||||
return isLoggedIn
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: theme.value === 'light' ? Moon : Sun,
|
||||
label: 'Toggle Theme',
|
||||
onClick: () => {
|
||||
toggleTheme()
|
||||
},
|
||||
},
|
||||
{
|
||||
component: markRaw(Apps),
|
||||
condition: () => {
|
||||
let cookies = new URLSearchParams(
|
||||
document.cookie.split('; ').join('&')
|
||||
)
|
||||
let system_user = cookies.get('system_user')
|
||||
if (system_user === 'yes') return true
|
||||
else return false
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: 'Settings',
|
||||
onClick: () => {
|
||||
settingsStore.isSettingsOpen = true
|
||||
},
|
||||
condition: () => {
|
||||
return userResource.data?.is_moderator
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
icon: Wrench,
|
||||
submenu: [
|
||||
{
|
||||
component: markRaw(Configuration),
|
||||
},
|
||||
],
|
||||
condition: () => {
|
||||
return userResource.data?.is_moderator
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: FrappeCloudIcon,
|
||||
label: 'Login to Frappe Cloud',
|
||||
onClick: () => {
|
||||
$dialog({
|
||||
title: __('Login to Frappe Cloud?'),
|
||||
message: __(
|
||||
'Are you sure you want to login to your Frappe Cloud dashboard?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Confirm'),
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
loginToFrappeCloud()
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
condition: () => {
|
||||
return (
|
||||
userResource.data?.is_system_manager &&
|
||||
userResource.data?.is_fc_site
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: LogOut,
|
||||
label: 'Log out',
|
||||
onClick: () => {
|
||||
logout.submit().then(() => {
|
||||
isLoggedIn = false
|
||||
})
|
||||
},
|
||||
condition: () => {
|
||||
return isLoggedIn
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: LogIn,
|
||||
label: 'Log in',
|
||||
onClick: () => {
|
||||
window.location.href = '/login'
|
||||
},
|
||||
condition: () => {
|
||||
return !isLoggedIn
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const loginToFrappeCloud = () => {
|
||||
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
|
||||
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user