chore: merged upstream

This commit is contained in:
Jannat Patel
2025-05-07 22:03:57 +05:30
98 changed files with 13881 additions and 8571 deletions

View File

@@ -1,8 +1,7 @@
name: Create weekly release name: Create weekly release
on: on:
schedule: schedule:
# 13:00 UTC -> 7pm IST on every Wednesday - cron: '30 4 15 * *'
- cron: '30 4 * * 3'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -100,6 +100,7 @@ jobs:
bench --site lms.test execute frappe.utils.install.complete_setup_wizard bench --site lms.test execute frappe.utils.install.complete_setup_wizard
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
bench --site lms.test set-password frappe@example.com admin bench --site lms.test set-password frappe@example.com admin
bench --site lms.test execute lms.lms.utils.persona_captured
- name: cypress pre-requisites - name: cypress pre-requisites
run: | run: |

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://testui:8000", baseUrl: "http://pertest:8000",
}, },
}); });

View File

@@ -16,6 +16,7 @@ declare module 'vue' {
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default'] AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default'] Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default'] Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default'] AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default'] Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default'] BatchCard: typeof import('./src/components/BatchCard.vue')['default']
@@ -70,6 +71,7 @@ declare module 'vue' {
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default'] NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default'] PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default'] ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default'] Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default'] Quiz: typeof import('./src/components/Quiz.vue')['default']

View File

@@ -26,12 +26,8 @@
<a href="{{ meta.link }}">Know More</a> <a href="{{ meta.link }}">Know More</a>
</div> </div>
</div> </div>
<div id="modals"></div>
<div id="popovers"></div>
<script> <script>
document.getElementById('seo-content').style.display = 'none'; document.getElementById('seo-content').style.display = 'none';
window.csrf_token = '{{ csrf_token }}'
</script> </script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>

View File

@@ -26,11 +26,12 @@
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.122", "frappe-ui": "^0.1.134",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"typescript": "^5.7.2", "typescript": "^5.7.2",

View File

@@ -24,7 +24,7 @@ const router = useRouter()
const noSidebar = ref(false) const noSidebar = ref(false)
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.query.fromLesson) { if (to.query.fromLesson || to.path === '/persona') {
noSidebar.value = true noSidebar.value = true
} else { } else {
noSidebar.value = false noSidebar.value = false

View File

@@ -39,7 +39,11 @@
{{ __('More') }} {{ __('More') }}
</span> </span>
</div> </div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()"> <Button
v-if="isModerator && !readOnlyMode"
variant="ghost"
@click="openPageModal()"
>
<template #icon> <template #icon>
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" /> <Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template> </template>
@@ -63,6 +67,16 @@
</div> </div>
</div> </div>
<div class="m-2 flex flex-col gap-1"> <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 <TrialBanner
v-if=" v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -74,43 +88,69 @@
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed" :isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
appName="learning" appName="learning"
/> />
<SidebarLink
v-if="isOnboardingStepsCompleted" <div
:link="{ class="flex items-center mt-4"
label: __('Help'), :class="
}" sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
" "
> >
<template #icon> <div
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> class="flex items-center flex-1"
<CircleHelp class="h-4 w-4 stroke-1.5" /> :class="
</span> sidebarStore.isSidebarCollapsed
</template> ? 'flex-col space-y-3'
</SidebarLink> : 'flex-row space-x-3'
<SidebarLink "
:link="{ >
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse', <Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
}" <CircleAlert
:isCollapsed="sidebarStore.isSidebarCollapsed" class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="toggleSidebar()"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4 w-4 text-ink-gray-7 duration-300 ease-in-out"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/> />
</span> <template #body>
</template> <div
</SidebarLink> 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 :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> </div>
<HelpModal <HelpModal
v-if="showOnboarding && showHelpModal" v-if="showOnboarding && showHelpModal"
@@ -148,7 +188,7 @@ import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { Button, createResource } from 'frappe-ui' import { Button, createResource, Tooltip } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue' import LMSLogo from '@/components/Icons/LMSLogo.vue'
@@ -156,6 +196,7 @@ import { useRouter } from 'vue-router'
import InviteIcon from './Icons/InviteIcon.vue' import InviteIcon from './Icons/InviteIcon.vue'
import { import {
BookOpen, BookOpen,
CircleAlert,
ChevronRight, ChevronRight,
Plus, Plus,
CircleHelp, CircleHelp,
@@ -164,6 +205,7 @@ import {
UserPlus, UserPlus,
Users, Users,
BookText, BookText,
Zap,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
TrialBanner, TrialBanner,
@@ -192,6 +234,7 @@ const currentStep = ref({})
const router = useRouter() const router = useRouter()
let onboardingDetails let onboardingDetails
let isOnboardingStepsCompleted = false let isOnboardingStepsCompleted = false
const readOnlyMode = window.read_only_mode
const iconProps = { const iconProps = {
strokeWidth: 1.5, strokeWidth: 1.5,
width: 16, width: 16,
@@ -578,4 +621,8 @@ watch(userResource, () => {
setUpOnboarding() setUpOnboarding()
} }
}) })
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
</script> </script>

View File

@@ -3,7 +3,7 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<button <button
:class="[ :class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2', 'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
]" ]"
@click.prevent="togglePopover()" @click.prevent="togglePopover()"
> >

View File

@@ -4,7 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="showModal = true"> <Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const showModal = ref(false) const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -181,7 +182,8 @@ const getRowRoute = (row) => {
} }
} }
const canSeeAddButton = () => { const canAddAssessments = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border-2 hover:bg-surface-gray-2 rounded-md p-4 h-full" class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

@@ -89,6 +89,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils' import { showToast } from '@/utils'
const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -159,6 +160,9 @@ const removeCourses = (selections, unselectAll) => {
} }
const canSeeAddButton = () => { const canSeeAddButton = () => {
if (readOnlyMode) {
return false
}
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -111,7 +111,6 @@ import {
FormControl, FormControl,
ListView, ListView,
ListHeader, ListHeader,
ListHeaderItem,
ListRows, ListRows,
ListRow, ListRow,
ListRowItem, ListRowItem,

View File

@@ -24,7 +24,10 @@
> >
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }} {{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div> </div>
<div class="flex items-center mb-3 text-ink-gray-7"> <div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" /> <BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span> <span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div> </div>
@@ -46,68 +49,70 @@
{{ batch.data.timezone }} {{ batch.data.timezone }}
</span> </span>
</div> </div>
<router-link <div v-if="!readOnlyMode">
v-if="isModerator || isStudent" <router-link
:to="{ v-if="isModerator || isStudent"
name: 'Batch', :to="{
params: { name: 'Batch',
batchName: batch.data.name, params: {
}, batchName: batch.data.name,
}" },
> }"
<Button variant="solid" class="w-full mt-4"> >
<span> <Button variant="solid" class="w-full mt-4">
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }} <span>
</span> {{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button> </Button>
</router-link> <router-link
<router-link v-if="isModerator"
:to="{ :to="{
name: 'Billing', name: 'BatchForm',
params: { params: {
type: 'batch', batchName: batch.data.name,
name: batch.data.name, },
}, }"
}" >
v-else-if=" <Button class="w-full mt-2">
batch.data.paid_batch && <span>
batch.data.seats_left > 0 && {{ __('Edit') }}
batch.data.accept_enrollments </span>
" </Button>
> </router-link>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid"> </div>
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -120,7 +125,7 @@ import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {

View File

@@ -110,7 +110,7 @@
<div class="text-ink-gray-7 font-medium"> <div class="text-ink-gray-7 font-medium">
{{ __('Students') }} {{ __('Students') }}
</div> </div>
<Button @click="openStudentModal()"> <Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -247,6 +247,7 @@ const chartData = ref(null)
const chartOptions = ref(null) const chartOptions = ref(null)
const showProgressChart = ref(false) const showProgressChart = ref(false)
const assessmentCount = ref(0) const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {

View File

@@ -28,9 +28,7 @@
</template> </template>
<template #body="{ isOpen }"> <template #body="{ isOpen }">
<div v-show="isOpen"> <div v-show="isOpen">
<div <div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
>
<div class="relative px-1.5 pt-0.5"> <div class="relative px-1.5 pt-0.5">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
@@ -49,7 +47,7 @@
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center" class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null" @click="selectedValue = null"
> >
<X class="h-4 w-4 stroke-1.5" /> <X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button> </button>
</div> </div>
<ComboboxOptions <ComboboxOptions
@@ -89,7 +87,7 @@
name="item-label" name="item-label"
v-bind="{ active, selected, option }" v-bind="{ active, selected, option }"
> >
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1 text-ink-gray-8">
<div> <div>
{{ option.label }} {{ option.label }}
</div> </div>

View File

@@ -4,7 +4,7 @@
{{ label }} {{ label }}
<span class="text-ink-red-3" v-if="required">*</span> <span class="text-ink-red-3" v-if="required">*</span>
</label> </label>
<div class="grid grid-cols-3 gap-1"> <div class="grid grid-cols-3 gap-2">
<Button <Button
ref="emails" ref="emails"
v-for="value in values" v-for="value in values"
@@ -12,7 +12,7 @@
:label="value" :label="value"
theme="gray" theme="gray"
variant="subtle" variant="subtle"
class="rounded-md" class="rounded-md word-break-all"
@keydown.delete.capture.stop="removeLastValue" @keydown.delete.capture.stop="removeLastValue"
> >
<template #suffix> <template #suffix>
@@ -42,7 +42,7 @@
<template #body="{ isOpen }"> <template #body="{ isOpen }">
<div v-show="isOpen"> <div v-show="isOpen">
<div <div
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl" class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
> >
<ComboboxOptions <ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5" class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
@@ -61,7 +61,7 @@
]" ]"
> >
<div class="flex flex-col gap-1 p-1"> <div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium"> <div class="text-base font-medium text-ink-gray-8">
{{ option.description }} {{ option.description }}
</div> </div>
<div class="text-sm text-ink-gray-5"> <div class="text-sm text-ink-gray-5">

View File

@@ -9,88 +9,94 @@
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3"> <div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }} {{ course.data.price }}
</div> </div>
<div v-if="course.data.membership" class="space-y-2"> <div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<router-link
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link <router-link
v-else-if="course.data.paid_course"
:to="{ :to="{
name: 'Lesson', name: 'Billing',
params: { params: {
courseName: course.name, type: 'course',
chapterNumber: course.data.current_lesson name: course.data.name,
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
}, },
}" }"
> >
<Button variant="solid" size="md" class="w-full"> <Button variant="solid" size="md" class="w-full">
<span> <span>
{{ __('Continue Learning') }} {{ __('Buy this course') }}
</span> </span>
</Button> </Button>
</router-link> </router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" /> <Badge
</div> v-else-if="course.data.disable_self_learning"
<router-link theme="blue"
v-else-if="course.data.paid_course" size="lg"
:to="{ >
name: 'Billing', {{ __('Contact the Administrator to enroll for this course.') }}
params: { </Badge>
type: 'course', <Button
name: course.data.name, v-else
}, @click="enrollStudent()"
}" variant="solid"
> class="w-full"
<Button variant="solid" size="md" class="w-full"> size="md"
>
<span> <span>
{{ __('Buy this course') }} {{ __('Start Learning') }}
</span> </span>
</Button> </Button>
</router-link> <Button
<div v-if="canGetCertificate"
v-else-if="course.data.disable_self_learning" @click="fetchCertificate()"
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3" variant="subtle"
> class="w-full mt-2"
{{ __('Contact the Administrator to enroll for this course.') }} size="md"
</div> >
<Button {{ __('Get Certificate') }}
v-else
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<span>
{{ __('Start Learning') }}
</span>
</Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button> </Button>
</router-link> <router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
<div class="space-y-4"> <div class="space-y-4">
<div class="mt-8 font-medium text-ink-gray-9"> <div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
{{ __('This course has:') }} {{ __('This course has:') }}
</div> </div>
<div class="flex items-center text-ink-gray-9"> <div class="flex items-center text-ink-gray-9">
@@ -140,7 +146,7 @@
<script setup> <script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next' import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource, Tooltip } from 'frappe-ui' import { Badge, Button, createResource } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/' import { showToast, formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -148,6 +154,7 @@ import CertificationLinks from '@/components/CertificationLinks.vue'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -172,7 +179,7 @@ function enrollStudent() {
) )
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000) }, 1000)
} else { } else {
const enrollStudentResource = createResource({ const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="h-full"> <div class="">
<div <div
v-if="title && (outline.data?.length || allowEdit)" v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between space-x-2 mb-4 px-2" class="flex items-center justify-between space-x-2 mb-4 px-2"
@@ -17,9 +17,6 @@
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }} {{ __('Add Chapter') }}
</Button> </Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div> </div>
<div <div
:class="{ :class="{

View File

@@ -27,7 +27,9 @@
</span> </span>
</div> </div>
<Dropdown <Dropdown
v-if="user.data.name == reply.owner && !reply.editable" v-if="
user.data.name == reply.owner && !reply.editable && !readOnlyMode
"
:options="[ :options="[
{ {
label: 'Edit', label: 'Edit',
@@ -71,7 +73,7 @@
</div> </div>
<TextEditor <TextEditor
v-if="renderEditor" v-if="renderEditor && !readOnlyMode"
class="mt-5" class="mt-5"
:content="newReply" :content="newReply"
:mentions="mentionUsers" :mentions="mentionUsers"
@@ -80,7 +82,7 @@
:fixedMenu="true" :fixedMenu="true"
editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2" editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2"
/> />
<div class="flex justify-between mt-2"> <div v-if="!readOnlyMode" class="flex justify-between mt-2">
<span> </span> <span> </span>
<Button @click="postReply()"> <Button @click="postReply()">
<span> <span>
@@ -105,6 +107,7 @@ const user = inject('$user')
const allUsers = inject('$allUsers') const allUsers = inject('$allUsers')
const mentionUsers = ref([]) const mentionUsers = ref([])
const renderEditor = ref(false) const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
topic: { topic: {

View File

@@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()"> <Button
v-if="!singleThread && !readOnlyMode"
class="float-right"
@click="openTopicModal()"
>
{{ __('New {0}').format(singularize(title)) }} {{ __('New {0}').format(singularize(title)) }}
</Button> </Button>
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl font-semibold text-ink-gray-9">
@@ -77,6 +81,7 @@ const currentTopic = ref(null)
const socket = inject('$socket') const socket = inject('$socket')
const user = inject('$user') const user = inject('$user')
const showTopicModal = ref(false) const showTopicModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
title: { title: {

View File

@@ -97,7 +97,7 @@ const evaluators = createResource({
return { return {
doctype: 'Course Evaluator', doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'], fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? [['evaluator', 'like', search.value]] : [], filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
} }
}, },
auto: true, auto: true,

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="20"
height="20"
viewBox="0 0 68 75"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 6.78182C0 1.60212 5.5742 -1.65958 10.09 0.879521L64.09 31.2545C68.6916 33.8443 68.6916 40.4693 64.09 43.0595L10.09 73.4345C5.5744 75.9736 0 72.7119 0 67.5322V6.78182ZM26.2695 38.5201C26.2695 37.3248 25.2265 37.9342 26.2695 38.5201C27.332 39.1178 27.332 37.9225 26.2695 38.5201Z"
fill="white"
/>
</svg>
</template>

View File

@@ -1,25 +1,35 @@
<template> <template>
<div class="border rounded-md p-4"> <div
<div class="flex space-x-4"> class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
<img >
:src="job.company_logo" <div class="flex space-x-4 mb-4">
class="size-10 rounded-full object-contain" <div class="flex flex-col space-y-2 flex-1">
/> <div class="text-lg font-semibold text-ink-gray-9">
<div class="flex flex-col space-y-1 flex-1">
<div class="flex items-center justify-between">
<span class="text-lg font-semibold text-ink-gray-9">
{{ job.job_title }}
</span>
</div>
<div class="text-xs text-ink-gray-5">
{{ job.company_name }} {{ job.company_name }}
</div> </div>
<span class="font-medium text-ink-gray-7 leading-5">
{{ job.job_title }}
</span>
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
<MapPin class="size-3" />
<span>
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
</span>
</div>
<div
v-if="job.applicants"
class="flex items-center space-x-1 text-sm text-ink-gray-7"
>
<User class="size-3" />
<span>
{{ job.applicants }}
{{ job.applicants > 1 ? __('applicants') : __('applicant') }}
</span>
</div>
</div> </div>
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
</div> </div>
<div class="space-x-4 mt-2"> <div class="space-x-2 mt-auto">
<Badge>
{{ job.location }}
</Badge>
<Badge> <Badge>
{{ job.type }} {{ job.type }}
</Badge> </Badge>
@@ -27,11 +37,16 @@
{{ dayjs(job.creation).fromNow() }} {{ dayjs(job.creation).fromNow() }}
</Badge> </Badge>
</div> </div>
<!-- <div
class="description text-ink-gray-9 text-sm"
v-html="job.description"
></div> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue' import { inject } from 'vue'
import { Badge } from 'frappe-ui' import { Badge } from 'frappe-ui'
import { MapPin, User } from 'lucide-vue-next'
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
@@ -41,3 +56,15 @@ const props = defineProps({
}, },
}) })
</script> </script>
<style>
.description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin-top: auto;
line-height: 1.5;
}
</style>

View File

@@ -3,7 +3,7 @@
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }} {{ __('Live Class') }}
</div> </div>
<Button v-if="user.data.is_moderator" @click="openLiveClassModal"> <Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -87,6 +87,7 @@ import { formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
const showLiveClassModal = ref(false) const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -116,6 +117,11 @@ const liveClasses = createListResource({
const openLiveClassModal = () => { const openLiveClassModal = () => {
showLiveClassModal.value = true showLiveClassModal.value = true
} }
const canCreateClass = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
</script> </script>
<style> <style>
.short-introduction { .short-introduction {

View File

@@ -0,0 +1,163 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'lg',
}"
>
<template #body>
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
{{
assignmentID === 'new'
? __('Create an Assignment')
: __('Edit Assignment')
}}
</div>
<div class="space-y-4">
<FormControl
v-model="assignment.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="assignment.type"
type="select"
:options="assignmentOptions"
:label="__('Submission Type')"
:required="true"
/>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="assignment.question"
@change="(val) => (assignment.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
<div class="flex justify-end space-x-2 mt-5">
<router-link
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignmentID,
},
}"
>
<Button v-if="assignmentID !== 'new'" variant="subtle">
{{ __('Check Submissions') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
interface Assignment {
title: string
type: string
question: string
}
interface Assignments {
data: Assignment[]
get: (params: { doctype: string; name: string }) => Promise<Assignment>
insert: {
submit: (params: Assignment, options: { onSuccess: () => void }) => void
}
}
const assignment = reactive({
title: '',
type: '',
question: '',
})
const props = defineProps({
assignmentID: {
type: String,
default: 'new',
},
})
watch(
() => props.assignmentID,
(val) => {
if (val !== 'new') {
assignments.value?.data.forEach((row) => {
if (row.name === val) {
assignment.title = row.title
assignment.type = row.type
assignment.question = row.question
}
})
}
},
{ flush: 'post' }
)
const saveAssignment = () => {
if (props.assignmentID == 'new') {
assignments.value.insert.submit(
{
...assignment,
},
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Assignment created successfully'),
'check'
)
},
}
)
} else {
assignments.value.setValue.submit(
{
...assignment,
name: props.assignmentID,
},
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Assignment updated successfully'),
'check'
)
},
}
)
}
}
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
</script>

View File

@@ -1,38 +1,27 @@
<template> <template>
<Dialog v-model="show" :options="dialogOptions"> <Dialog
<template #body-content> v-model="show"
<div class="space-y-4"> :options="{
size: '3xl',
}"
>
<template #body>
<div class="p-5 space-y-5">
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{ __(props.title) }}
</div>
<div <div
v-if="!editMode" v-if="!editMode"
class="flex items-center text-xs text-ink-gray-7 space-x-5" class="flex items-center text-xs text-ink-gray-7 space-x-5"
> >
<div class="flex items-center space-x-2"> <Switch
<input size="sm"
type="radio" :label="__('Choose an existing question')"
id="existing" v-model="chooseFromExisting"
value="existing" class="!p-0"
v-model="questionType" />
class="w-3 h-3 cursor-pointer"
/>
<label for="existing" class="cursor-pointer">
{{ __('Add an existing question') }}
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
id="new"
value="new"
v-model="questionType"
class="w-3 h-3 cursor-pointer"
/>
<label for="new" class="cursor-pointer">
{{ __('Create a new question') }}
</label>
</div>
</div> </div>
<div v-if="questionType == 'new' || editMode" class="space-y-2"> <div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div> <div>
<label class="block text-xs text-ink-gray-5 mb-1"> <label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }} {{ __('Question') }}
@@ -45,20 +34,34 @@
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
<FormControl <div class="grid grid-cols-2 gap-4">
v-model="question.marks" <FormControl
:label="__('Marks')" v-model="question.marks"
type="number" :label="__('Marks')"
/> type="number"
<FormControl />
:label="__('Type')" <FormControl
v-model="question.type" :label="__('Type')"
type="select" v-model="question.type"
:options="['Choices', 'User Input', 'Open Ended']" type="select"
class="pb-2" :options="['Choices', 'User Input', 'Open Ended']"
:required="true" class="pb-2"
/> :required="true"
<div v-if="question.type == 'Choices'" class="divide-y border-t"> />
</div>
<div
v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
>
{{ __('Options') }}
</div>
<div
v-else-if="question.type == 'User Input'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
>
{{ __('Possibilities') }}
</div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
<div v-for="n in 4" class="space-y-4 py-2"> <div v-for="n in 4" class="space-y-4 py-2">
<FormControl <FormControl
:label="__('Option') + ' ' + n" :label="__('Option') + ' ' + n"
@@ -78,17 +81,18 @@
</div> </div>
<div <div
v-else-if="question.type == 'User Input'" v-else-if="question.type == 'User Input'"
v-for="n in 4" class="grid grid-cols-2 gap-4 py-2"
class="space-y-2"
> >
<FormControl <div v-for="n in 4">
:label="__('Possibility') + ' ' + n" <FormControl
v-model="question[`possibility_${n}`]" :label="__('Possibility') + ' ' + n"
:required="n == 1 ? true : false" v-model="question[`possibility_${n}`]"
/> :required="n == 1 ? true : false"
/>
</div>
</div> </div>
</div> </div>
<div v-else-if="questionType == 'existing'" class="space-y-2"> <div v-else-if="chooseFromExisting" class="space-y-2">
<Link <Link
v-model="existingQuestion.question" v-model="existingQuestion.question"
:label="__('Select a question')" :label="__('Select a question')"
@@ -100,12 +104,24 @@
type="number" type="number"
/> />
</div> </div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
</Button>
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui' import {
Dialog,
FormControl,
TextEditor,
createResource,
Switch,
Button,
} from 'frappe-ui'
import { computed, watch, reactive, ref, inject } from 'vue' import { computed, watch, reactive, ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils' import { showToast } from '@/utils'
@@ -113,7 +129,7 @@ import { useOnboarding } from 'frappe-ui/frappe'
const show = defineModel() const show = defineModel()
const quiz = defineModel('quiz') const quiz = defineModel('quiz')
const questionType = ref(null) const chooseFromExisting = ref(false)
const editMode = ref(false) const editMode = ref(false)
const user = inject('$user') const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
@@ -182,11 +198,12 @@ watch(show, () => {
editMode.value = false editMode.value = false
if (props.questionDetail.question) questionData.fetch() if (props.questionDetail.question) questionData.fetch()
else { else {
;(question.question = ''), (question.marks = 0) question.question = ''
question.marks = 1
question.type = 'Choices' question.type = 'Choices'
existingQuestion.question = '' existingQuestion.question = ''
existingQuestion.marks = 0 existingQuestion.marks = 1
questionType.value = null chooseFromExisting.value = false
populateFields() populateFields()
} }
@@ -221,32 +238,26 @@ const questionCreation = createResource({
}, },
}) })
const submitQuestion = (close) => { const submitQuestion = () => {
if (props.questionDetail?.question) updateQuestion(close) if (props.questionDetail?.question) updateQuestion()
else addQuestion(close) else addQuestion()
} }
const addQuestion = (close) => { const addQuestion = () => {
if (questionType.value == 'existing') { if (chooseFromExisting.value) {
addQuestionRow( addQuestionRow({
{ question: existingQuestion.question,
question: existingQuestion.question, marks: existingQuestion.marks,
marks: existingQuestion.marks, })
},
close
)
} else { } else {
questionCreation.submit( questionCreation.submit(
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
addQuestionRow( addQuestionRow({
{ question: data.name,
question: data.name, marks: question.marks,
marks: question.marks, })
},
close
)
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') showToast(__('Error'), __(err.messages?.[0] || err), 'x')
@@ -256,7 +267,7 @@ const addQuestion = (close) => {
} }
} }
const addQuestionRow = (question, close) => { const addQuestionRow = (question) => {
questionRow.submit( questionRow.submit(
{ {
...question, ...question,
@@ -269,11 +280,11 @@ const addQuestionRow = (question, close) => {
show.value = false show.value = false
showToast(__('Success'), __('Question added successfully'), 'check') showToast(__('Success'), __('Question added successfully'), 'check')
quiz.value.reload() quiz.value.reload()
close() show.value = false
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') showToast(__('Error'), __(err.messages?.[0] || err), 'x')
close() show.value = false
}, },
} }
) )
@@ -307,7 +318,7 @@ const marksUpdate = createResource({
}, },
}) })
const updateQuestion = (close) => { const updateQuestion = () => {
questionUpdate.submit( questionUpdate.submit(
{}, {},
{ {
@@ -323,7 +334,6 @@ const updateQuestion = (close) => {
'check' 'check'
) )
quiz.value.reload() quiz.value.reload()
close()
}, },
} }
) )
@@ -334,22 +344,6 @@ const updateQuestion = (close) => {
} }
) )
} }
const dialogOptions = computed(() => {
return {
title: __(props.title),
size: 'xl',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => {
submitQuestion(close)
},
},
],
}
})
</script> </script>
<style> <style>
input[type='radio']:checked { input[type='radio']:checked {

View File

@@ -315,12 +315,6 @@ const tabsStructure = computed(() => {
doctype: 'Email Template', doctype: 'Email Template',
type: 'Link', type: 'Link',
}, },
{
label: 'Assignment Submission Template',
name: 'assignment_submission_template',
doctype: 'Email Template',
type: 'Link',
},
], ],
}, },
{ {
@@ -328,11 +322,11 @@ const tabsStructure = computed(() => {
icon: 'LogIn', icon: 'LogIn',
fields: [ fields: [
{ {
label: 'Identify User Persona', label: 'Identify User Category',
name: 'user_category', name: 'user_category',
type: 'checkbox', type: 'checkbox',
description: description:
'Enable this option to identify the user persona during signup.', 'Enable this option to identify the user category during signup.',
}, },
{ {
label: 'Disable signup', label: 'Disable signup',
@@ -358,10 +352,23 @@ const tabsStructure = computed(() => {
label: 'Meta Description', label: 'Meta Description',
name: 'meta_description', name: 'meta_description',
type: 'textarea', type: 'textarea',
rows: 5, rows: 4,
description: description:
"This description will be shown on lists and pages that don't have meta description", "This description will be shown on lists and pages that don't have meta description",
}, },
{
label: 'Meta Keywords',
name: 'meta_keywords',
type: 'textarea',
rows: 4,
description:
'Keywords for search engines to find your website. Separated by commas.',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
},
], ],
}, },
], ],

View File

@@ -653,3 +653,8 @@ const getSubmissionColumns = () => {
] ]
} }
</script> </script>
<style>
p {
line-height: 1.5rem;
}
</style>

View File

@@ -51,7 +51,9 @@ const props = defineProps({
const update = () => { const update = () => {
props.fields.forEach((f) => { props.fields.forEach((f) => {
if (f.type != 'Column Break') { if (f.type == 'Upload') {
props.data.doc[f.name] = f.value ? f.value.file_url : null
} else if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value props.data.doc[f.name] = f.value
} }
}) })

View File

@@ -54,21 +54,30 @@
<div v-else> <div v-else>
<div class="flex items-center text-sm space-x-2"> <div class="flex items-center text-sm space-x-2">
<div <div
class="flex items-center justify-center rounded border border-outline-gray-modals w-[10rem] py-5" class="flex items-center justify-center rounded border border-outline-gray-modals bg-white w-[10rem] py-2"
> >
<img :src="data[field.name]?.file_url" class="h-6 rounded" /> <img
:src="data[field.name]?.file_url || data[field.name]"
class="w-[80%] rounded"
/>
</div> </div>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">
<span class="break-all text-ink-gray-9"> <span class="break-all text-ink-gray-9">
{{ data[field.name]?.file_name }} {{
data[field.name]?.file_name ||
data[field.name].split('/').pop()
}}
</span> </span>
<span class="text-sm text-ink-gray-5 mt-1"> <span
v-if="data[field.name]?.file_size"
class="text-sm text-ink-gray-5 mt-1"
>
{{ getFileSize(data[field.name]?.file_size) }} {{ getFileSize(data[field.name]?.file_size) }}
</span> </span>
</div> </div>
<X <X
@click="data[field.name] = null" @click="data[field.name] = null"
class="bg-surface-gray-5 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4" class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/> />
</div> </div>
</div> </div>
@@ -99,7 +108,7 @@
</template> </template>
<script setup> <script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui' import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed, onMounted } from 'vue' import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils' import { getFileSize, validateFile } from '@/utils'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'

View File

@@ -194,18 +194,6 @@ const userDropdownOptions = computed(() => {
) )
}, },
}, },
],
},
{
group: '',
items: [
{
icon: Zap,
label: 'Powered by Learning',
onClick: () => {
window.open('https://frappe.io/learning', '_blank')
},
},
{ {
icon: LogOut, icon: LogOut,
label: 'Log out', label: 'Log out',

View File

@@ -1,32 +1,53 @@
<template> <template>
<div ref="videoContainer" class="video-block group relative"> <div ref="videoContainer" class="video-block relative group">
<video <video
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
@click="togglePlay" @click="togglePlay"
oncontextmenu="return false" oncontextmenu="return false"
class="rounded-lg border border-gray-100 group cursor-pointer" class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef" ref="videoRef"
> >
<source :src="fileURL" :type="type" /> <source :src="fileURL" :type="type" />
</video> </video>
<div <div
class="flex items-center space-x-2 bg-surface-gray-3 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible" v-if="!playing"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="playVideo"
>
<div
class="rounded-full p-4 pl-4.5"
style="
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.4) 50%
);
"
>
<Play />
</div>
</div>
<div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
:class="{
'invisible group-hover:visible': playing,
}"
> >
<Button variant="ghost"> <Button variant="ghost">
<template #icon> <template #icon>
<Play <Play
v-if="!playing" v-if="!playing"
@click="playVideo" @click="playVideo"
class="w-4 h-4 text-ink-gray-9" class="size-4 text-ink-gray-9"
/> />
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-ink-gray-9" /> <Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<Button variant="ghost" @click="toggleMute"> <Button variant="ghost" @click="toggleMute">
<template #icon> <template #icon>
<Volume2 v-if="!muted" class="w-4 h-4 text-ink-gray-9" /> <Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" /> <VolumeX v-else class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<input <input
@@ -38,12 +59,12 @@
@input="changeCurrentTime" @input="changeCurrentTime"
class="duration-slider w-full h-1" class="duration-slider w-full h-1"
/> />
<span class="text-xs font-medium"> <span class="text-sm font-semibold">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span> </span>
<Button variant="ghost" @click="toggleFullscreen"> <Button variant="ghost" @click="toggleFullscreen">
<template #icon> <template #icon>
<Maximize class="w-4 h-4 text-ink-gray-9" /> <Maximize class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
</div> </div>
@@ -51,8 +72,9 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui' import { Button } from 'frappe-ui'
import Play from '@/components/Icons/Play.vue'
const videoRef = ref(null) const videoRef = ref(null)
const videoContainer = ref(null) const videoContainer = ref(null)
@@ -147,7 +169,6 @@ const toggleFullscreen = () => {
<style scoped> <style scoped>
.video-block { .video-block {
width: 100%; width: 100%;
max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
@@ -165,15 +186,16 @@ iframe {
flex: 1; flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-color: theme('colors.gray.400'); border-radius: 10px;
background-color: theme('colors.gray.100');
cursor: pointer; cursor: pointer;
} }
.duration-slider::-webkit-slider-thumb { .duration-slider::-webkit-slider-thumb {
height: 10px; width: 2px;
width: 10px; border-radius: 50%;
-webkit-appearance: none; -webkit-appearance: none;
background-color: theme('colors.gray.900'); background-color: theme('colors.gray.500');
} }
@media screen and (-webkit-min-device-pixel-ratio: 0) { @media screen and (-webkit-min-device-pixel-ratio: 0) {
@@ -186,7 +208,7 @@ iframe {
input[type='range']::-webkit-slider-thumb { input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
cursor: pointer; cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.900'); box-shadow: -500px 0 0 500px theme('colors.gray.600');
} }
} }
</style> </style>

View File

@@ -26,5 +26,6 @@ app.mount('#app')
const { userResource, allUsers } = usersStore() const { userResource, allUsers } = usersStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.provide('$allUsers', allUsers) app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog app.config.globalProperties.$dialog = createDialog

View File

@@ -1,201 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="assignment.doc?.name"
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignment.doc.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
v-model="model.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="model.type"
type="select"
:options="assignmentOptions"
:label="__('Type')"
:required="true"
/>
</div>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="model.question"
@change="(val) => (model.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
createDocumentResource,
createResource,
FormControl,
TextEditor,
usePageMeta,
} from 'frappe-ui'
import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
watch,
} from 'vue'
import { sessionStore } from '../stores/session'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const model = reactive({
title: '',
type: 'PDF',
question: '',
})
onMounted(() => {
if (
props.assignmentID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.assignmentID !== 'new') {
assignment.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
saveAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createDocumentResource({
doctype: 'LMS Assignment',
name: props.assignmentID,
auto: false,
})
const newAssignment = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assignment',
...values,
},
}
},
onSuccess(data) {
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
})
const saveAssignment = () => {
if (props.assignmentID == 'new') {
newAssignment.submit({
...model,
})
} else {
assignment.setValue.submit(
{
...model,
},
{
onSuccess(data) {
showToast(__('Success'), __('Assignment saved successfully'), 'check')
assignment.reload()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
watch(assignment, () => {
Object.keys(assignment.doc).forEach((key) => {
model[key] = assignment.doc[key]
})
})
const breadcrumbs = computed(() => [
{
label: __('Assignments'),
route: { name: 'Assignments' },
},
{
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
},
])
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
usePageMeta(() => {
return {
title: assignment.doc ? assignment.doc.title : __('New Assignment'),
icon: brand.favicon,
}
})
</script>

View File

@@ -3,21 +3,21 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <Button
:to="{ v-if="!readOnlyMode"
name: 'AssignmentForm', variant="solid"
params: { @click="
assignmentID: 'new', () => {
}, assignmentID = 'new'
}" showAssignmentForm = true
}
"
> >
<Button variant="solid"> <template #prefix>
<template #prefix> <Plus class="w-4 h-4" />
<Plus class="w-4 h-4" /> </template>
</template> {{ __('New') }}
{{ __('New') }} </Button>
</Button>
</router-link>
</header> </header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5"> <div class="md:w-3/4 md:mx-auto py-5 mx-5">
@@ -38,12 +38,11 @@
:options="{ :options="{
showTooltip: false, showTooltip: false,
selectable: false, selectable: false,
getRowRoute: (row) => ({ onRowClick: (row) => {
name: 'AssignmentForm', if (readOnlyMode) return
params: { assignmentID = row.name
assignmentID: row.name, showAssignmentForm = true
}, },
}),
}" }"
> >
</ListView> </ListView>
@@ -72,6 +71,11 @@
</Button> </Button>
</div> </div>
</div> </div>
<AssignmentForm
v-model="showAssignmentForm"
v-model:assignments="assignments"
:assignmentID="assignmentID"
/>
</template> </template>
<script setup> <script setup>
import { import {
@@ -86,13 +90,17 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus, Pencil } from 'lucide-vue-next' import { Plus, Pencil } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const titleFilter = ref('') const titleFilter = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const showAssignmentForm = ref(false)
const assignmentID = ref('new')
const { brand } = sessionStore() const { brand } = sessionStore()
const router = useRouter() const router = useRouter()
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
@@ -136,7 +144,7 @@ const assignmentFilter = computed(() => {
const assignments = createListResource({ const assignments = createListResource({
doctype: 'LMS Assignment', doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation'], fields: ['name', 'title', 'type', 'creation', 'question'],
orderBy: 'modified desc', orderBy: 'modified desc',
cache: ['assignments'], cache: ['assignments'],
transform(data) { transform(data) {
@@ -166,7 +174,7 @@ const assignmentColumns = computed(() => {
label: __('Created'), label: __('Created'),
key: 'creation', key: 'creation',
width: 1, width: 1,
align: 'center', align: 'right',
}, },
] ]
}) })

View File

@@ -11,7 +11,7 @@
> >
{{ __('Generate Certificates') }} {{ __('Generate Certificates') }}
</Button> </Button>
<Button v-if="user.data?.is_moderator" @click="openAnnouncementModal()"> <Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
<span> <span>
{{ __('Make an Announcement') }} {{ __('Make an Announcement') }}
</span> </span>
@@ -242,6 +242,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { brand } = sessionStore() const { brand } = sessionStore()
const tabIndex = ref(0) const tabIndex = ref(0)
const readOnlyMode = window.read_only_mode
const tabs = computed(() => { const tabs = computed(() => {
let batchTabs = [] let batchTabs = []
@@ -354,6 +355,11 @@ watch(tabIndex, () => {
} }
}) })
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
usePageMeta(() => { usePageMeta(() => {
return { return {
title: batch?.data?.title, title: batch?.data?.title,

View File

@@ -14,13 +14,16 @@
{{ batch.data.description }} {{ batch.data.description }}
</div> </div>
<div <div
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2" class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center space-x-0 md:space-x-5 lg:w-1/2"
> >
<div class="flex items-center text-ink-gray-7"> <div
<BookOpen class="h-4 w-4 mr-2" /> v-if="batch.data?.courses?.length"
class="flex items-center text-ink-gray-7"
>
<BookOpen class="h-4 w-4 mr-2 stroke-1.5" />
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span> <span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
</div> </div>
<span class="hidden lg:block" v-if="batch.data.courses" <span v-if="batch.data?.courses?.length" class="hidden lg:block"
>&middot;</span >&middot;</span
> >
<DateRange <DateRange
@@ -31,7 +34,7 @@
>&middot;</span >&middot;</span
> >
<div class="flex items-center text-ink-gray-7"> <div class="flex items-center text-ink-gray-7">
<Clock class="h-4 w-4 mr-2" /> <Clock class="h-4 w-4 mr-2 stroke-1.5" />
<span> <span>
{{ formatTime(batch.data.start_time) }} - {{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }} {{ formatTime(batch.data.end_time) }}

View File

@@ -10,11 +10,11 @@
</header> </header>
<div class="w-3/4 mx-auto py-5"> <div class="w-3/4 mx-auto py-5">
<div class=""> <div class="">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="space-y-10 mb-4"> <div class="space-y-10 mb-4">
<div class="grid grid-cols-2 gap-10"> <div class="space-y-4">
<FormControl <FormControl
v-model="batch.title" v-model="batch.title"
:label="__('Title')" :label="__('Title')"
@@ -107,7 +107,7 @@
</div> </div>
<div class="my-10"> <div class="my-10">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Date and Time') }} {{ __('Date and Time') }}
</div> </div>
<div class="grid grid-cols-3 gap-10"> <div class="grid grid-cols-3 gap-10">
@@ -157,7 +157,7 @@
</div> </div>
<div class="mb-10"> <div class="mb-10">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-3 gap-10"> <div class="grid grid-cols-3 gap-10">
@@ -210,7 +210,7 @@
</div> </div>
<div class=""> <div class="">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Payment') }} {{ __('Payment') }}
</div> </div>
<FormControl <FormControl
@@ -234,7 +234,7 @@
</div> </div>
<div class="my-10"> <div class="my-10">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Description') }} {{ __('Description') }}
</div> </div>
<FormControl <FormControl

View File

@@ -4,7 +4,7 @@
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <router-link
v-if="user.data?.is_moderator" v-if="canCreateBatch()"
:to="{ :to="{
name: 'BatchForm', name: 'BatchForm',
params: { batchName: 'new' }, params: { batchName: 'new' },
@@ -124,6 +124,7 @@ const filters = ref({})
const is_student = computed(() => user.data?.is_student) const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming') const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const orderBy = ref('start_date') const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
setFiltersFromQuery() setFiltersFromQuery()
@@ -299,6 +300,12 @@ const batchTabs = computed(() => {
return tabs return tabs
}) })
const canCreateBatch = () => {
if (readOnlyMode) return false
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ {
label: __('Batches'), label: __('Batches'),

View File

@@ -12,12 +12,13 @@
</Button> </Button>
</router-link> </router-link>
</header> </header>
<div class="p-5 lg:w-3/4 mx-auto"> <div
<div v-if="participants.data?.length"
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5" class="mx-auto w-full max-w-4xl pt-6 pb-10"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="flex flex-col md:flex-row justify-between mb-4 px-3">
{{ __('All Certified Participants') }} <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }}
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<FormControl <FormControl
@@ -40,57 +41,80 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="participants.data?.length"> <div class="divide-y">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <template v-for="participant in participants.data">
<router-link <router-link
v-for="participant in participants.data"
:to="{ :to="{
name: 'ProfileCertificates', name: 'ProfileCertificates',
params: { username: participant.username }, params: {
username: participant.username,
},
}" }"
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
> >
<div <div class="flex items-center w-full space-x-3">
class="flex items-center space-x-2 border rounded-md hover:bg-surface-menu-bar p-2 text-ink-gray-7"
>
<Avatar <Avatar
:image="participant.user_image" :image="participant.user_image"
class="size-8 rounded-full object-contain"
:label="participant.full_name" :label="participant.full_name"
size="2xl" size="2xl"
/> />
<div class="flex flex-col space-y-2"> <div class="flex flex-col md:flex-row w-full">
<div class="font-medium"> <div class="flex-1">
{{ participant.full_name }} <div class="text-base font-medium text-ink-gray-8">
{{ participant.full_name }}
</div>
<div
v-if="participant.headline"
class="mt-1.5 text-base text-ink-gray-5"
>
{{ participant.headline }}
</div>
</div> </div>
<div <div
v-if="participant.headline" class="flex items-center space-x-3 md:space-x-24 text-sm md:text-base mt-1.5"
class="headline text-sm text-ink-gray-7"
> >
{{ participant.headline }} <div class="text-ink-gray-5">
{{ participant.certificate_count }}
{{
participant.certificate_count > 1
? __('certificates')
: __('certificate')
}}
</div>
<span class="text-ink-gray-4 md:hidden">·</span>
<div class="text-ink-gray-5">
{{ dayjs(participant.issue_date).format('DD MMM YYYY') }}
</div>
</div> </div>
</div> </div>
</div> </div>
</router-link> </router-link>
</div> </template>
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="participants.next()">
{{ __('Load More') }}
</Button>
</div>
</div> </div>
<div <div
v-else-if="!participants.list.loading" v-if="!participants.list.loading && participants.hasNextPage"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48" class="flex justify-center mt-5"
> >
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" /> <Button @click="participants.next()">
<div class="text-lg font-medium mb-1"> {{ __('Load More') }}
{{ __('No participants found') }} </Button>
</div> </div>
<div class="leading-5 w-2/5 text-center"> </div>
{{ __('There are no participants matching this criteria.') }} <div
</div> v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No certified members') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'No certified members found. Please check again later or get certified yourself.'
)
}}
</div> </div>
</div> </div>
</template> </template>
@@ -99,13 +123,13 @@ import {
Avatar, Avatar,
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { BookOpen, GraduationCap } from 'lucide-vue-next' import { BookOpen, GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
@@ -113,6 +137,8 @@ const currentCategory = ref('')
const filters = ref({}) const filters = ref({})
const nameFilter = ref('') const nameFilter = ref('')
const { brand } = sessionStore() const { brand } = sessionStore()
const memberCount = ref(0)
const dayjs = inject('$dayjs')
onMounted(() => { onMounted(() => {
updateParticipants() updateParticipants()
@@ -126,6 +152,12 @@ const participants = createListResource({
pageLength: 30, pageLength: 30,
}) })
const count = call('lms.lms.api.get_count_of_certified_members').then(
(data) => {
memberCount.value = data
}
)
const categories = createListResource({ const categories = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories', url: 'lms.lms.api.get_certification_categories',
@@ -161,14 +193,14 @@ const updateFilters = () => {
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ {
label: __('Certified Participants'), label: __('Certified Members'),
route: { name: 'CertifiedParticipants' }, route: { name: 'CertifiedParticipants' },
}, },
]) ])
usePageMeta(() => { usePageMeta(() => {
return { return {
title: __('Certified Participants'), title: __('Certified Members'),
icon: brand.favicon, icon: brand.favicon,
} }
}) })

View File

@@ -69,7 +69,7 @@
<CourseCardOverlay :course="course" class="md:hidden mb-4" /> <CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div <div
v-html="course.data.description" v-html="course.data.description"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-4" class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
></div> ></div>
<div class="mt-10"> <div class="mt-10">
<CourseOutline <CourseOutline

View File

@@ -57,7 +57,7 @@
</div> </div>
<div <div
v-if="courses.data?.length" v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
> >
<router-link <router-link
v-for="course in courses.data" v-for="course in courses.data"
@@ -96,6 +96,7 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
@@ -107,6 +108,7 @@ import { BookOpen, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils' import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import router from '../router'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -119,8 +121,10 @@ const certification = ref(false)
const filters = ref({}) const filters = ref({})
const currentTab = ref('Live') const currentTab = ref('Live')
const { brand } = sessionStore() const { brand } = sessionStore()
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
identifyUserPersona()
setFiltersFromQuery() setFiltersFromQuery()
updateCourses() updateCourses()
categories.value = [ categories.value = [
@@ -145,16 +149,45 @@ const courses = createListResource({
pageLength: pageLength.value, pageLength: pageLength.value,
start: start.value, start: start.value,
onSuccess(data) { onSuccess(data) {
let allCategories = data.map((course) => course.category) setCategories(data)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}, },
}) })
const setCategories = (data) => {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}
const isPersonaCaptured = async () => {
let persona = await call('frappe.client.get_single_value', {
doctype: 'LMS Settings',
field: 'persona_captured',
})
return persona
}
const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return
call('frappe.client.get_count', {
doctype: 'LMS Course',
}).then((data) => {
if (!data) {
router.push({
name: 'PersonaForm',
})
}
})
}
}
const updateCourses = () => { const updateCourses = () => {
updateFilters() updateFilters()
courses.update({ courses.update({

View File

@@ -16,11 +16,14 @@
}, },
]" ]"
/> />
<div v-if="user.data?.name" class="flex space-x-2"> <div
v-if="user.data?.name && !readOnlyMode"
class="flex items-center space-x-2"
>
<router-link <router-link
v-if="user.data.name == job.data?.owner" v-if="user.data.name == job.data?.owner"
:to="{ :to="{
name: 'JobCreation', name: 'JobForm',
params: { jobName: job.data?.name }, params: { jobName: job.data?.name },
}" }"
> >
@@ -47,8 +50,14 @@
</template> </template>
{{ __('Apply') }} {{ __('Apply') }}
</Button> </Button>
<Badge v-else variant="subtle" theme="green" size="lg">
<template #prefix>
<Check class="h-4 w-4" />
</template>
{{ __('You have applied') }}
</Badge>
</div> </div>
<div v-else> <div v-else-if="!readOnlyMode">
<Button @click="redirectToLogin(job.data?.name)"> <Button @click="redirectToLogin(job.data?.name)">
<span> <span>
{{ __('Login to apply') }} {{ __('Login to apply') }}
@@ -56,13 +65,13 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="job.data" class="max-w-3xl mx-auto"> <div v-if="job.data" class="max-w-3xl mx-auto pt-5">
<div class="p-4"> <div class="p-4">
<div class="space-y-5 mb-10"> <div class="space-y-5 mb-10">
<div class="flex items-center"> <div class="flex items-center">
<img <img
:src="job.data.company_logo" :src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain cursor-pointer mr-4" class="size-10 rounded-lg object-contain cursor-pointer mr-4"
:alt="job.data.company_name" :alt="job.data.company_name"
@click="redirectToWebsite(job.data.company_website)" @click="redirectToWebsite(job.data.company_website)"
/> />
@@ -75,7 +84,7 @@
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
> >
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<Building2 class="h-4 w-4 text-ink-green-2" /> <Building2 class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Organisation') }} {{ __('Organisation') }}
@@ -86,20 +95,20 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<MapPin class="size-4 text-ink-red-3" /> <MapPin class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Location') }} {{ __('Location') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
{{ job.data.location }} {{ job.data.location }}, {{ job.data.country }}
</span> </span>
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<ClipboardType class="h-4 w-4 text-yellow-500" /> <ClipboardType class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Category') }} {{ __('Category') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
@@ -108,9 +117,9 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<CalendarDays class="h-4 w-4 text-ink-blue-2" /> <CalendarDays class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Posted on') }} {{ __('Posted on') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
@@ -122,9 +131,9 @@
v-if="applicationCount.data" v-if="applicationCount.data"
class="flex items-center space-x-4" class="flex items-center space-x-4"
> >
<SquareUserRound class="h-4 w-4 text-purple-500" /> <SquareUserRound class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Applications Received') }} {{ __('Applications Received') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
@@ -149,12 +158,19 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Button, Breadcrumbs, createResource, usePageMeta } from 'frappe-ui' import {
Badge,
Button,
Breadcrumbs,
createResource,
usePageMeta,
} from 'frappe-ui'
import { inject, ref } from 'vue' import { inject, ref } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue' import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import { import {
MapPin, MapPin,
Check,
SendHorizonal, SendHorizonal,
Pencil, Pencil,
Building2, Building2,
@@ -168,6 +184,7 @@ const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const { brand } = sessionStore() const { brand } = sessionStore()
const showApplicationModal = ref(false) const showApplicationModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
job: { job: {

View File

@@ -13,17 +13,22 @@
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Job Details') }} {{ __('Job Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-5">
<div> <div class="space-y-4">
<FormControl <FormControl
v-model="job.job_title" v-model="job.job_title"
:label="__('Title')" :label="__('Title')"
class="mb-4"
:required="true" :required="true"
/> />
<FormControl <FormControl
v-model="job.location" v-model="job.location"
:label="__('Location')" :label="__('City')"
:required="true"
/>
<Link
v-model="job.country"
doctype="Country"
:label="__('Country')"
:required="true" :required="true"
/> />
</div> </div>
@@ -45,25 +50,12 @@
/> />
</div> </div>
</div> </div>
<div class="mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div> </div>
<div class="container mb-4 pb-4"> <div class="container border-b mb-4 pb-4">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Company Details') }} {{ __('Company Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-5">
<div> <div>
<FormControl <FormControl
v-model="job.company_name" v-model="job.company_name"
@@ -128,6 +120,19 @@
</div> </div>
</div> </div>
</div> </div>
<div class="container mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -217,6 +222,7 @@ const imageResource = createResource({
const job = reactive({ const job = reactive({
job_title: '', job_title: '',
location: '', location: '',
country: '',
type: 'Full Time', type: 'Full Time',
status: 'Open', status: 'Open',
company_name: '', company_name: '',
@@ -317,7 +323,7 @@ const breadcrumbs = computed(() => {
}, },
{ {
label: props.jobName == 'new' ? 'New Job' : 'Edit Job', label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
route: { name: 'JobCreation' }, route: { name: 'JobForm' },
}, },
] ]
return crumbs return crumbs

View File

@@ -10,13 +10,13 @@
<router-link <router-link
v-if="user.data?.name" v-if="user.data?.name"
:to="{ :to="{
name: 'JobCreation', name: 'JobForm',
params: { params: {
jobName: 'new', jobName: 'new',
}, },
}" }"
> >
<Button variant="solid"> <Button v-if="!readOnlyMode" variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -25,40 +25,48 @@
</router-link> </router-link>
</header> </header>
<div> <div>
<div v-if="jobs.data?.length" class="p-5"> <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"
>
<div <div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" v-if="jobCount"
class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"
> >
<div class="text-xl text-ink-gray-9 font-semibold"> {{ __('{0} Open Jobs').format(jobCount) }}
{{ __('Find the perfect job for you') }}
</div>
<div class="grid grid-cols-2 gap-2">
<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>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5"> <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
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
/>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div>
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<router-link <router-link
v-for="job in jobs.data" v-for="job in jobs.data"
:to="{ :to="{
@@ -73,18 +81,17 @@
</div> </div>
<div <div
v-else v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48" class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-56"
> >
<Laptop class="size-10 mx-auto stroke-1 text-ink-gray-4" /> <Laptop class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1"> <div class="text-lg font-medium mb-1">
{{ __('No jobs found') }} {{ __('No jobs found') }}
</div> </div>
<div class="leading-5 w-2/5 text-center"> <div class="leading-5 w-2/5 text-center">
{{ {{ __('There are no jobs available at the moment.') }}
__( </div>
'There are no jobs available at the moment. Open a job opportunity or check here again later.' <div class="leading-5 w-1/5 text-center">
) {{ __('Post a new job or check again later.') }}
}}
</div> </div>
</div> </div>
</div> </div>
@@ -94,21 +101,26 @@
import { import {
Button, Button,
Breadcrumbs, Breadcrumbs,
call,
createResource, createResource,
FormControl, FormControl,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { Laptop, Plus, Search } from 'lucide-vue-next' import { Laptop, Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { inject, computed, ref, onMounted } from 'vue' import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue' import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue'
const user = inject('$user') const user = inject('$user')
const jobType = ref(null) const jobType = ref(null)
const { brand } = sessionStore() const { brand } = sessionStore()
const searchQuery = ref('') const searchQuery = ref('')
const country = ref(null)
const filters = ref({}) const filters = ref({})
const orFilters = ref({}) const orFilters = ref({})
const jobCount = ref(0)
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
@@ -116,6 +128,7 @@ onMounted(() => {
jobType.value = queries.get('type') jobType.value = queries.get('type')
} }
updateJobs() updateJobs()
getJobCount()
}) })
const jobs = createResource({ const jobs = createResource({
@@ -153,8 +166,30 @@ const updateFilters = () => {
} else { } else {
orFilters.value = {} orFilters.value = {}
} }
if (country.value) {
filters.value.country = country.value
} else {
delete filters.value.country
}
} }
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => {
updateJobs()
})
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
'', '',

View File

@@ -4,166 +4,237 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs class="h-7" :items="breadcrumbs" /> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<CertificationLinks :courseName="courseName" /> <div class="flex items-center space-x-2">
<Tooltip v-if="canGoZen()" :text="__('Zen Mode')">
<Button @click="goFullScreen()">
<template #icon>
<Focus class="w-4 h-4 stroke-2" />
</template>
</Button>
</Tooltip>
<CertificationLinks :courseName="courseName" />
</div>
</header> </header>
<div class="grid md:grid-cols-[70%,30%] h-screen"> <div class="grid md:grid-cols-[70%,30%] h-screen">
<div <div v-if="lesson.data.no_preview" class="border-r">
v-if="lesson.data.no_preview" <div class="shadow rounded-md w-3/4 mt-10 mx-auto text-center p-4">
class="border-r text-center pt-10 px-5 md:px-0 pb-10" <div class="flex items-center justify-center mt-4 space-x-2">
> <LockKeyholeIcon class="size-4 stroke-2 text-ink-gray-5" />
<p class="mb-4"> <div class="text-lg font-semibold text-ink-gray-7">
{{ {{ __('This lesson is locked') }}
__( </div>
'This lesson is not available for preview. Please enroll in the course to access it.'
)
}}
</p>
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
{{ __('Start Learning') }}
</Button>
<Button v-else @click="redirectToLogin()">
{{ __('Login') }}
</Button>
</div>
<div v-else class="border-r container pt-5 pb-10 px-5">
<div class="flex flex-col md:flex-row md:items-center justify-between">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ lesson.data.title }}
</div> </div>
<div class="flex items-center mt-2 md:mt-0"> <div class="mt-1 mb-4 text-ink-gray-7">
<router-link {{
v-if="lesson.data.prev" __(
:to="{ 'This lesson is not available for preview. Please enroll in the course to access it.'
name: 'Lesson', )
params: { }}
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button class="mr-2">
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button class="mr-2">
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div> </div>
</div> <Button
v-if="user.data && !lesson.data.disable_self_learning"
<div class="flex items-center mt-2"> @click="enrollStudent()"
<span variant="solid"
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
> >
<UserAvatar {{ __('Start Learning') }}
v-for="instructor in lesson.data.instructors" </Button>
:user="instructor" <Badge
/> theme="blue"
</span> size="lg"
<CourseInstructors v-else-if="lesson.data.disable_self_learning"
v-if="lesson.data?.instructors" class="mt-2"
:instructors="lesson.data.instructors" >
/> {{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button v-else @click="redirectToLogin()">
<template #prefix>
<LogIn class="w-4 h-4 stroke-1" />
</template>
{{ __('Login') }}
</Button>
</div> </div>
</div>
<div
v-else
ref="lessonContainer"
class="bg-surface-white"
:class="{
'overflow-y-auto': zenModeEnabled,
}"
>
<div <div
v-if=" class="border-r container pt-5 pb-10 px-5 h-full"
lesson.data.instructor_content && :class="{
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 && 'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
allowInstructorContent() }"
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
> >
<div class="text-ink-gray-5 font-medium"> <div
{{ __('Instructor Notes') }} class="flex flex-col md:flex-row md:items-center justify-between"
>
<div class="flex flex-col">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ lesson.data.title }}
</div>
<div
v-if="zenModeEnabled"
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
>
<span>
{{ lesson.data.chapter_title }} -
{{ lesson.data.course_title }}
</span>
<Info class="size-3" />
<div
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
>
{{ Math.ceil(lesson.data.membership.progress) }}%
{{ __('completed') }}
</div>
</div>
</div>
<div class="flex items-center space-x-2 mt-2 md:mt-0">
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
<template #icon>
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<router-link
v-if="lesson.data.prev"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button>
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button>
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div>
</div>
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
>
<UserAvatar
v-for="instructor in lesson.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</div>
<div
v-if="
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
>
<div class="text-ink-gray-5 font-medium">
{{ __('Instructor Notes') }}
</div>
<div
id="instructor-content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
></div>
</div> </div>
<div <div
id="instructor-content" v-else-if="lesson.data.instructor_notes"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal" class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
></div> >
</div> <LessonContent :content="lesson.data.instructor_notes" />
<div </div>
v-else-if="lesson.data.instructor_notes" <div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-6" v-if="lesson.data.content"
> class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
<LessonContent :content="lesson.data.instructor_notes" /> >
</div> <div id="editor"></div>
<div </div>
v-if="lesson.data.content" <div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-5" v-else
> class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
<div id="editor"></div> >
</div> <LessonContent
<div v-if="lesson.data?.body"
v-else :content="lesson.data.body"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-5" :youtube="lesson.data.youtube"
> :quizId="lesson.data.quiz_id"
<LessonContent />
v-if="lesson.data?.body" </div>
:content="lesson.data.body" <div class="mt-20" ref="discussionsContainer">
:youtube="lesson.data.youtube" <Discussions
:quizId="lesson.data.quiz_id" v-if="allowDiscussions"
/> :title="'Questions'"
</div> :doctype="'Course Lesson'"
<div class="mt-20"> :docname="lesson.data.name"
<Discussions :key="lesson.data.name"
v-if="allowDiscussions" />
:title="'Questions'" </div>
:doctype="'Course Lesson'"
:docname="lesson.data.name"
:key="lesson.data.name"
/>
</div> </div>
</div> </div>
<div class="sticky top-10"> <div class="sticky top-10">
@@ -193,14 +264,37 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs, Button, usePageMeta } from 'frappe-ui' import {
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue' createResource,
Badge,
Breadcrumbs,
Button,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import {
computed,
watch,
inject,
ref,
onMounted,
onBeforeUnmount,
nextTick,
} from 'vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next' import {
ChevronLeft,
ChevronRight,
LockKeyholeIcon,
LogIn,
Focus,
Info,
MessageCircleQuestion,
} from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import { getEditorTools } from '../utils' import { getEditorTools, enablePlyr } from '@/utils'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue' import LessonContent from '@/components/LessonContent.vue'
@@ -215,6 +309,10 @@ const allowDiscussions = ref(false)
const editor = ref(null) const editor = ref(null)
const instructorEditor = ref(null) const instructorEditor = ref(null)
const lessonProgress = ref(0) const lessonProgress = ref(0)
const lessonContainer = ref(null)
const zenModeEnabled = ref(false)
const hasQuiz = ref(false)
const discussionsContainer = ref(null)
const timer = ref(0) const timer = ref(0)
const { brand } = sessionStore() const { brand } = sessionStore()
let timerInterval let timerInterval
@@ -236,11 +334,28 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() startTimer()
enablePlyr()
document.addEventListener('fullscreenchange', attachFullscreenEvent)
})
const attachFullscreenEvent = () => {
if (document.fullscreenElement) {
zenModeEnabled.value = true
allowDiscussions.value = false
} else {
zenModeEnabled.value = false
if (!hasQuiz.value) {
allowDiscussions.value = true
}
}
}
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
}) })
const lesson = createResource({ const lesson = createResource({
url: 'lms.lms.utils.get_lesson', url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
makeParams(values) { makeParams(values) {
return { return {
course: props.courseName, course: props.courseName,
@@ -249,36 +364,37 @@ const lesson = createResource({
} }
}, },
auto: true, auto: true,
onSuccess(data) {
if (Object.keys(data).length === 0) {
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
const hasQuiz = quizRegex.test(data.body)
if (!hasQuiz) allowDiscussions.value = true
}
},
}) })
const setupLesson = (data) => {
if (Object.keys(data).length === 0) {
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
hasQuiz.value = quizRegex.test(data.body)
if (!hasQuiz.value && !zenModeEnabled) allowDiscussions.value = true
}
}
const renderEditor = (holder, content) => { const renderEditor = (holder, content) => {
// empty the holder // empty the holder
if (document.getElementById(holder)) if (document.getElementById(holder))
@@ -348,10 +464,18 @@ watch(
clearInterval(timerInterval) clearInterval(timerInterval)
timer.value = 0 timer.value = 0
startTimer() startTimer()
enablePlyr()
} }
} }
) )
watch(
() => lesson.data,
(data) => {
setupLesson(data)
}
)
const startTimer = () => { const startTimer = () => {
timerInterval = setInterval(() => { timerInterval = setInterval(() => {
timer.value++ timer.value++
@@ -367,13 +491,13 @@ onBeforeUnmount(() => {
}) })
const checkIfDiscussionsAllowed = () => { const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => { JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
if (block.type === 'quiz') quizPresent = true if (block.type === 'quiz') hasQuiz.value = true
}) })
if ( if (
!quizPresent && !hasQuiz.value &&
!zenModeEnabled.value &&
(lesson.data?.membership || (lesson.data?.membership ||
user.data?.is_moderator || user.data?.is_moderator ||
user.data?.is_instructor) user.data?.is_instructor)
@@ -382,6 +506,7 @@ const checkIfDiscussionsAllowed = () => {
} }
const allowEdit = () => { const allowEdit = () => {
if (window.read_only_mode) return false
if (user.data?.is_moderator) return true if (user.data?.is_moderator) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false return false
@@ -417,6 +542,48 @@ const enrollStudent = () => {
) )
} }
const canGoZen = () => {
if (
user.data?.is_moderator ||
user.data?.is_instructor ||
user.data?.is_evaluator
)
return false
if (lesson.data?.membership) return true
return false
}
const goFullScreen = () => {
if (lessonContainer.value.requestFullscreen) {
lessonContainer.value.requestFullscreen()
} else if (lessonContainer.value.mozRequestFullScreen) {
lessonContainer.value.mozRequestFullScreen()
} else if (lessonContainer.value.webkitRequestFullscreen) {
lessonContainer.value.webkitRequestFullscreen()
} else if (lessonContainer.value.msRequestFullscreen) {
lessonContainer.value.msRequestFullscreen()
}
}
const showDiscussionsInZenMode = () => {
if (allowDiscussions.value) {
allowDiscussions.value = false
} else {
allowDiscussions.value = true
scrollDiscussionsIntoView()
}
}
const scrollDiscussionsIntoView = () => {
nextTick(() => {
discussionsContainer.value?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
})
}
const redirectToLogin = () => { const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}` window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
} }
@@ -590,4 +757,30 @@ usePageMeta(() => {
.tc-table { .tc-table {
border-left: 1px solid #e8e8eb; border-left: 1px solid #e8e8eb;
} }
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style> </style>

View File

@@ -97,7 +97,7 @@ import { sessionStore } from '../stores/session'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue' import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { createToast, getEditorTools } from '@/utils' import { createToast, getEditorTools, enablePlyr } from '@/utils'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
@@ -133,6 +133,7 @@ onMounted(() => {
editor.value = renderEditor('content') editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes') instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
enablePlyr()
}) })
const renderEditor = (holder) => { const renderEditor = (holder) => {
@@ -141,6 +142,9 @@ const renderEditor = (holder) => {
tools: getEditorTools(true), tools: getEditorTools(true),
autofocus: true, autofocus: true,
defaultBlock: 'markdown', defaultBlock: 'markdown',
onChange: async (api, event) => {
enablePlyr()
},
}) })
} }
@@ -624,8 +628,7 @@ usePageMeta(() => {
} }
iframe { iframe {
border-top: 3px solid theme('colors.gray.700'); border: none !important;
border-bottom: 3px solid theme('colors.gray.700');
} }
.tc-table { .tc-table {
@@ -639,4 +642,30 @@ iframe {
.ce-popover-item[data-item-name='markdown'] { .ce-popover-item[data-item-name='markdown'] {
display: none !important; display: none !important;
} }
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style> </style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="flex h-screen overflow-hidden sm:bg-gray-50">
<div class="relative h-full z-10 mx-auto pt-8 sm:w-max sm:pt-32">
<div class="mx-auto flex items-center justify-center space-x-2">
<LMSLogo class="size-7" />
<span
class="select-none text-xl font-semibold tracking-tight text-gray-900"
>
Learning
</span>
</div>
<div
class="mx-auto w-full h-fit bg-white py-8 sm:mt-6 sm:w-96 sm:rounded-lg sm:px-8 sm:shadow-xl"
>
<div class="font-medium text-center mb-8">
{{ __('Help us understand your needs') }}
</div>
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('What is your main use case for Frappe Learning?') }}
</div>
<FormControl
v-model="persona.useCase"
type="select"
:options="useCaseOptions"
/>
</div>
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('How many students are you planning to teach?') }}
</div>
<FormControl
v-model="persona.noOfStudents"
type="select"
:options="noOfStudentsOptions"
/>
</div>
<div class="flex w-full">
<Button variant="solid" class="mx-auto" @click="submitPersona()">
{{ __('Submit and Continue') }}
</Button>
</div>
</div>
<div
class="text-center absolute bottom-0 right-0 left-0 mx-auto cursor-pointer text-sm pb-4"
@click="skipPersonaForm()"
>
{{ __('Skip') }}
</div>
</div>
</div>
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { Button, call, FormControl, usePageMeta } from 'frappe-ui'
import { computed, inject, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { sessionStore } from '@/stores/session'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const persona = reactive({
noOfStudents: null,
useCase: null,
})
const submitPersona = () => {
let responses = {
site: user.data?.sitename,
no_of_students: persona.noOfStudents,
use_case: persona.useCase,
}
call('lms.lms.api.capture_user_persona', {
responses: JSON.stringify(responses),
}).then(() => {
router.push({
name: 'Courses',
})
})
}
const skipPersonaForm = () => {
call('frappe.client.set_value', {
doctype: 'LMS Settings',
name: null,
fieldname: 'persona_captured',
value: 1,
}).then(() => {
router.push({
name: 'Courses',
})
})
}
const noOfStudentsOptions = computed(() => {
const options = [
'Less than 50',
'50-200',
'200-1000',
'1000+',
'Not sure yet',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
const useCaseOptions = computed(() => {
const options = [
'Teaching students in a school/university',
'Training employees in my company',
'Onboarding and educating my users/community',
'Selling courses and earning income',
'Other',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
usePageMeta(() => {
return {
title: 'Persona',
icon: brand.favicon,
}
})
</script>

View File

@@ -25,7 +25,11 @@
@select="(imageUrl) => coverImage.submit({ url: imageUrl })" @select="(imageUrl) => coverImage.submit({ url: imageUrl })"
> >
<template v-slot="{ togglePopover }"> <template v-slot="{ togglePopover }">
<Button variant="outline" @click="togglePopover()"> <Button
v-if="!readOnlyMode"
variant="outline"
@click="togglePopover()"
>
<template #prefix> <template #prefix>
<Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" /> <Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template> </template>
@@ -58,7 +62,7 @@
</div> </div>
</div> </div>
<Button <Button
v-if="isSessionUser()" v-if="isSessionUser() && !readOnlyMode"
class="mt-3 sm:mt-0 md:ml-auto" class="mt-3 sm:mt-0 md:ml-auto"
@click="editProfile()" @click="editProfile()"
> >
@@ -95,7 +99,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue' import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Edit, icons } from 'lucide-vue-next' import { Edit } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import NoPermission from '@/components/NoPermission.vue' import NoPermission from '@/components/NoPermission.vue'
@@ -109,6 +113,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const activeTab = ref('') const activeTab = ref('')
const showProfileModal = ref(false) const showProfileModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
username: { username: {

View File

@@ -4,134 +4,150 @@
{{ __('My availability') }} {{ __('My availability') }}
</h2> </h2>
<div class=""> <div
<div v-if="readOnlyMode"
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4" class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
> >
<div> <CircleAlert class="size-4 stroke-1.5" />
{{ __('Day') }} <span>
</div> {{
<div> __(
{{ __('Start Time') }} 'You cannot change the availability when the site is being updated.'
</div> )
<div> }}
{{ __('End Time') }} </span>
</div>
</div>
<div
v-if="evaluator.data"
v-for="slot in evaluator.data.slots.schedule"
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
>
<FormControl
type="select"
:options="days"
v-model="slot.day"
@focusout.stop="update(slot.name, 'day', slot.day)"
/>
<FormControl
type="time"
v-model="slot.start_time"
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
{{ __('Add Slot') }}
</Button>
</div> </div>
<div class="my-10"> <div v-else>
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9"> <div>
{{ __('I am unavailable') }} <div
</h2> class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> >
<FormControl <div>
type="date" {{ __('Day') }}
:label="__('From')" </div>
v-model="from" <div>
@blur=" {{ __('Start Time') }}
() => { </div>
updateUnavailability.submit({ <div>
field: 'unavailable_from', {{ __('End Time') }}
value: from, </div>
}) </div>
}
" <div
/> v-if="evaluator.data"
<FormControl v-for="slot in evaluator.data.slots.schedule"
type="date" class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
:label="__('To')" >
v-model="to" <FormControl
@blur=" type="select"
() => { :options="days"
updateUnavailability.submit({ v-model="slot.day"
field: 'unavailable_to', @focusout.stop="update(slot.name, 'day', slot.day)"
value: to, />
}) <FormControl
} type="time"
" v-model="slot.start_time"
/> @focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
{{ __('Add Slot') }}
</Button>
</div> </div>
</div> <div class="my-10">
<div> <h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9"> {{ __('I am unavailable') }}
{{ __('My calendar') }} </h2>
</h2> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div <FormControl
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized" type="date"
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit" :label="__('From')"
> v-model="from"
<Check class="h-4 w-4 stroke-1.5 mr-2" /> @blur="
{{ __('Your calendar is set.') }} () => {
updateUnavailability.submit({
field: 'unavailable_from',
value: from,
})
}
"
/>
<FormControl
type="date"
:label="__('To')"
v-model="to"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_to',
value: to,
})
}
"
/>
</div>
</div>
<div>
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
{{ __('My calendar') }}
</h2>
<div
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
>
<Check class="h-4 w-4 stroke-1.5 mr-2" />
{{ __('Your calendar is set.') }}
</div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div> </div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, FormControl, Button } from 'frappe-ui' import { createResource, FormControl, Button, Badge } from 'frappe-ui'
import { computed, reactive, ref, onMounted, inject } from 'vue' import { computed, reactive, ref, onMounted, inject } from 'vue'
import { showToast, convertToTitleCase } from '@/utils' import { showToast, convertToTitleCase } from '@/utils'
import { Plus, X, Check } from 'lucide-vue-next' import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
profile: { profile: {

View File

@@ -4,6 +4,16 @@
{{ __('Settings') }} {{ __('Settings') }}
</h2> </h2>
<div <div
v-if="readOnlyMode"
class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
>
<CircleAlert class="size-4 stroke-1.5" />
<span>
{{ __('You cannot change the roles in read-only mode.') }}
</span>
</div>
<div
v-else
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5" class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
> >
<FormControl <FormControl
@@ -37,11 +47,13 @@
import { FormControl, createResource } from 'frappe-ui' import { FormControl, createResource } from 'frappe-ui'
import { ref } from 'vue' import { ref } from 'vue'
import { showToast, convertToTitleCase } from '@/utils' import { showToast, convertToTitleCase } from '@/utils'
import { CircleAlert } from 'lucide-vue-next'
const moderator = ref(false) const moderator = ref(false)
const course_creator = ref(false) const course_creator = ref(false)
const batch_evaluator = ref(false) const batch_evaluator = ref(false)
const lms_student = ref(false) const lms_student = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
profile: { profile: {

View File

@@ -13,7 +13,7 @@
<!-- Courses --> <!-- Courses -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="text-lg font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Program Courses') }} {{ __('Program Courses') }}
</div> </div>
<Button <Button
@@ -75,7 +75,7 @@
<!-- Members --> <!-- Members -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="text-lg font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Program Members') }} {{ __('Program Members') }}
</div> </div>
<Button <Button

View File

@@ -4,7 +4,7 @@
> >
<Breadcrumbs :items="breadbrumbs" /> <Breadcrumbs :items="breadbrumbs" />
<Button <Button
v-if="user.data?.is_moderator || user.data?.is_instructor" v-if="canCreateProgram()"
@click="showDialog = true" @click="showDialog = true"
variant="solid" variant="solid"
> >
@@ -46,7 +46,7 @@
params: { programName: program.name }, params: { programName: program.name },
}" }"
> >
<Button> <Button v-if="!readOnlyMode">
<template #prefix> <template #prefix>
<Edit class="h-4 w-4 stroke-1.5" /> <Edit class="h-4 w-4 stroke-1.5" />
</template> </template>
@@ -142,6 +142,7 @@ const showDialog = ref(false)
const router = useRouter() const router = useRouter()
const title = ref('') const title = ref('')
const settings = useSettings() const settings = useSettings()
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
if ( if (
@@ -208,9 +209,15 @@ const lockCourse = (course) => {
return true return true
} }
const canCreateProgram = () => {
if (readOnlyMode) return false
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const breadbrumbs = computed(() => [ const breadbrumbs = computed(() => [
{ {
label: 'Programs', label: __('Programs'),
}, },
]) ])

View File

@@ -3,7 +3,7 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2"> <div v-if="!readOnlyMode" class="space-x-2">
<router-link <router-link
v-if="quizDetails.data?.name" v-if="quizDetails.data?.name"
:to="{ :to="{
@@ -38,7 +38,7 @@
<div class="w-3/4 mx-auto py-5"> <div class="w-3/4 mx-auto py-5">
<!-- Details --> <!-- Details -->
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<FormControl <FormControl
@@ -75,7 +75,7 @@
<!-- Settings --> <!-- Settings -->
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-3 gap-5 my-4"> <div class="grid grid-cols-3 gap-5 my-4">
@@ -93,7 +93,7 @@
</div> </div>
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Shuffle Settings') }} {{ __('Shuffle Settings') }}
</div> </div>
<div class="grid grid-cols-3"> <div class="grid grid-cols-3">
@@ -113,10 +113,10 @@
<!-- Questions --> <!-- Questions -->
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="font-semibold"> <div class="font-semibold text-ink-gray-9">
{{ __('Questions') }} {{ __('Questions') }}
</div> </div>
<Button @click="openQuestionModal()"> <Button v-if="!readOnlyMode" @click="openQuestionModal()">
<template #prefix> <template #prefix>
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
</template> </template>
@@ -223,6 +223,7 @@ const currentQuestion = reactive({
}) })
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
quizID: { quizID: {
@@ -444,11 +445,7 @@ const breadcrumbs = computed(() => {
}, },
}, },
] ]
/* if (quizDetails.data) {
crumbs.push({
label: quiz.title,
})
} */
crumbs.push({ crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title, label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } }, route: { name: 'QuizForm', params: { quizID: props.quizID } },

View File

@@ -4,6 +4,7 @@
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <router-link
v-if="!readOnlyMode"
:to="{ :to="{
name: 'QuizForm', name: 'QuizForm',
params: { params: {
@@ -89,6 +90,7 @@ import { sessionStore } from '@/stores/session'
const { brand } = sessionStore() const { brand } = sessionStore()
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {

View File

@@ -7,109 +7,115 @@
</header> </header>
<div v-if="chartDetails.data" class="p-5"> <div v-if="chartDetails.data" class="p-5">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div <NumberChart
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" class="border rounded-md"
> :config="{ title: 'Courses', value: chartDetails.data.courses }"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> />
<BookOpen class="w-18 h-18 stroke-1.5" /> <NumberChart
</div> class="border rounded-md"
<div> :config="{ title: 'Signups', value: chartDetails.data.users }"
<div class="text-xl font-semibold mb-1"> />
{{ formatNumber(chartDetails.data.courses) }} <NumberChart
</div> class="border rounded-md"
<div> :config="{
{{ __('Courses') }} title: 'Enrollments',
</div> value: chartDetails.data.enrollments,
</div> }"
</div> />
<div <NumberChart
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" class="border rounded-md"
> :config="{
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> title: 'Completions',
<LogIn class="w-18 h-18 stroke-1.5" /> value: chartDetails.data.completions,
</div> }"
<div> />
<div class="text-xl font-semibold mb-1"> <NumberChart
{{ formatNumber(chartDetails.data.users) }} class="border rounded-md"
</div> :config="{
<div> title: 'Certifications',
{{ __('Signups') }} value: chartDetails.data.certifications,
</div> }"
</div> />
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<BookOpenCheck class="w-18 h-18 stroke-1.5" />
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.enrollments) }}
</div>
<div>
{{ __('Enrollments') }}
</div>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<FileCheck class="w-18 h-18 stroke-1.5" />
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.completions) }}
</div>
<div>
{{ __('Completions') }}
</div>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<FileCheck2 class="w-18 h-18 stroke-1.5" />
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.lesson_completions) }}
</div>
<div class="text-ink-gray-7">
{{ __('Milestones') }}
</div>
</div>
</div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div class="border rounded-md p-5 min-h-72"> <div class="border rounded-md min-h-72">
<Line <AxisChart
v-if="signupsChart.data" v-if="signupsChart.data"
:data="signupsChart.data" :config="{
:options="signupChartOptions()" data: signupsChart.data,
title: 'Signups',
subtitle: 'Signups per month',
xAxis: {
key: 'date',
type: 'time',
title: 'Date',
timeGrain: 'day',
},
yAxis: {
title: 'Signups',
},
series: [{ name: 'signups', type: 'line', showDataPoints: true }],
}"
/> />
</div> </div>
<div class="border rounded-md p-5 min-h-72"> <div class="border rounded-md min-h-72">
<Line <AxisChart
v-if="enrollmentChart.data" v-if="enrollmentChart.data"
:data="enrollmentChart.data" :config="{
:options="enrollmentChartOptions()" data: enrollmentChart.data,
title: 'Enrollments',
subtitle: 'Enrollments per month',
xAxis: {
key: 'date',
type: 'time',
title: 'Date',
timeGrain: 'day',
},
yAxis: {
title: 'Enrollments',
},
series: [
{ name: 'enrollments', type: 'line', showDataPoints: true },
],
}"
/> />
</div> </div>
<div class="border rounded-md p-5"> <div class="border rounded-md">
<Line <AxisChart
v-if="lessonCompletion.data" v-if="certification.data"
:data="lessonCompletion.data" :config="{
:options="lessonChartOptions()" data: certification.data,
title: 'Certifications',
subtitle: 'Certifications per month',
xAxis: {
key: 'date',
type: 'time',
title: 'Date',
timeGrain: 'day',
},
yAxis: {
title: 'Certifications',
},
series: [
{
name: 'certifications',
type: 'line',
showDataPoints: true,
},
],
}"
/> />
</div> </div>
<div class="border rounded-md p-5"> <div class="border rounded-md">
<Pie <DonutChart
v-if="courseCompletion.data" v-if="courseCompletion.data"
:data="courseCompletion.data" :config="{
:options="courseChartOptions()" data: courseCompletion.data,
title: 'Completions',
subtitle: 'Course Completion',
categoryColumn: 'label',
valueColumn: 'value',
}"
/> />
</div> </div>
</div> </div>
@@ -117,42 +123,16 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs, usePageMeta } from 'frappe-ui' import {
AxisChart,
Breadcrumbs,
createResource,
DonutChart,
NumberChart,
usePageMeta,
} from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { formatNumber } from '@/utils'
import { Line, Pie } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
ArcElement,
Filler,
} from 'chart.js'
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
ArcElement,
Filler
)
import {
BookOpen,
LogIn,
FileCheck,
FileCheck2,
BookOpenCheck,
} from 'lucide-vue-next'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -175,11 +155,18 @@ const chartDetails = createResource({
const signupsChart = createResource({ const signupsChart = createResource({
url: 'lms.lms.utils.get_chart_data', url: 'lms.lms.utils.get_chart_data',
cache: ['signups'],
params: { params: {
chart_name: 'New Signups', chart_name: 'New Signups',
}, },
auto: true, auto: true,
transform(data) {
return data.map((item) => {
return {
date: new Date(item.date),
signups: item.count,
}
})
},
}) })
const enrollmentChart = createResource({ const enrollmentChart = createResource({
@@ -189,15 +176,31 @@ const enrollmentChart = createResource({
chart_name: 'Course Enrollments', chart_name: 'Course Enrollments',
}, },
auto: true, auto: true,
transform(data) {
return data.map((item) => {
return {
date: new Date(item.date),
enrollments: item.count,
}
})
},
}) })
const lessonCompletion = createResource({ const certification = createResource({
url: 'lms.lms.utils.get_chart_data', url: 'lms.lms.utils.get_chart_data',
cache: ['lessonCompletion'], cache: ['certifications'],
params: { params: {
chart_name: 'Lesson Completion', chart_name: 'Certification',
}, },
auto: true, auto: true,
transform(data) {
return data.map((item) => {
return {
date: new Date(item.date),
certifications: item.count,
}
})
},
}) })
const courseCompletion = createResource({ const courseCompletion = createResource({
@@ -206,117 +209,6 @@ const courseCompletion = createResource({
cache: ['courseCompletion'], cache: ['courseCompletion'],
}) })
const signupChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Signups'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#4563f0')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
return options
}
const enrollmentChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Enrollments'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#4563f0')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
return options
}
const lessonChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Milestones'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#B6DEC5')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
return options
}
const courseChartOptions = () => {
let options = chartOptions(true)
options.plugins.title.text = 'Completions'
options.backgroundColor = ['#4563f0', '#f683ae']
return options
}
const chartOptions = (isPie) => {
return {
responsive: true,
maintainAspectRatio: false,
fill: true,
borderWidth: 2,
pointRadius: 2,
pointStyle: 'cross',
ticks: {
autoSkip: true,
maxTicksLimit: 5,
},
plugins: {
legend: {
display: isPie ? true : false,
},
title: {
display: true,
align: 'start',
font: {
size: 14,
weight: '500',
},
color: '#171717',
padding: {
bottom: 20,
},
},
tooltip: {
backgroundColor: '#000',
},
},
scales: {
x: {
display: isPie ? false : true,
grid: {
display: false,
},
border: {
display: isPie ? false : true,
},
},
y: {
beginAtZero: true,
display: isPie ? false : true,
grid: {
display: false,
},
border: {
display: isPie ? false : true,
},
},
},
}
}
usePageMeta(() => { usePageMeta(() => {
return { return {
title: __('Statistics'), title: __('Statistics'),

View File

@@ -134,8 +134,8 @@ const routes = [
}, },
{ {
path: '/job-opening/:jobName/edit', path: '/job-opening/:jobName/edit',
name: 'JobCreation', name: 'JobForm',
component: () => import('@/pages/JobCreation.vue'), component: () => import('@/pages/JobForm.vue'),
props: true, props: true,
}, },
{ {
@@ -199,12 +199,6 @@ const routes = [
name: 'Assignments', name: 'Assignments',
component: () => import('@/pages/Assignments.vue'), component: () => import('@/pages/Assignments.vue'),
}, },
{
path: '/assignments/:assignmentID',
name: 'AssignmentForm',
component: () => import('@/pages/AssignmentForm.vue'),
props: true,
},
{ {
path: '/assignment-submission/:assignmentID/:submissionName', path: '/assignment-submission/:assignmentID/:submissionName',
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
@@ -216,6 +210,11 @@ const routes = [
name: 'AssignmentSubmissionList', name: 'AssignmentSubmissionList',
component: () => import('@/pages/AssignmentSubmissionList.vue'), component: () => import('@/pages/AssignmentSubmissionList.vue'),
}, },
{
path: '/persona',
name: 'PersonaForm',
component: () => import('@/pages/PersonaForm.vue'),
},
] ]
let router = createRouter({ let router = createRouter({

View File

@@ -15,6 +15,10 @@ import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image' import SimpleImage from '@editorjs/simple-image'
import Table from '@editorjs/table' import Table from '@editorjs/table'
import { usersStore } from '../stores/user' import { usersStore } from '../stores/user'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const readOnlyMode = window.read_only_mode
export function createToast(options) { export function createToast(options) {
toast({ toast({
@@ -199,64 +203,24 @@ export function getEditorTools() {
services: { services: {
youtube: { youtube: {
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/, regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
embedUrl: embedUrl: '<%= remote_id %>',
'https://www.youtube.com/embed/<%= remote_id %>', /* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&amp;iv_load_policy=3&amp;modestbranding=1&amp;playsinline=1&amp;showinfo=0&amp;rel=0&amp;enablejsapi=1' */
html: `<iframe style="width:100%; height: ${ html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
window.innerWidth < 640 ? '15rem' : '30rem' id: ([id]) => id,
};" frameborder="0" allowfullscreen></iframe>`,
id: ([id, params]) => {
if (!params && id) {
return id
}
const paramsMap = {
start: 'start',
end: 'end',
t: 'start',
// eslint-disable-next-line camelcase
time_continue: 'start',
list: 'list',
}
let newParams = params
.slice(1)
.split('&')
.map((param) => {
const [name, value] = param.split('=')
if (!id && name === 'v') {
id = value
return null
}
if (!paramsMap[name]) {
return null
}
if (
value === 'LL' ||
value.startsWith('RDMM') ||
value.startsWith('FL')
) {
return null
}
return `${paramsMap[name]}=${value}`
})
.filter((param) => !!param)
return id + '?' + newParams.join('&')
},
}, },
vimeo: { vimeo: {
regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/, regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
embedUrl: '<%= remote_id %>',
html: `<div class="video-player" data-plyr-provider="vimeo"></div>`,
id: ([id]) => id,
},
cloudflareStream: {
regex: /https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch/,
embedUrl: embedUrl:
'https://player.vimeo.com/video/<%= remote_id %>', 'https://iframe.videodelivery.net/<%= remote_id %>',
html: `<iframe style="width:100%; height: ${ html: `<iframe style="width:100%; height: ${
window.innerWidth < 640 ? '15rem' : '30rem' window.innerWidth < 640 ? '15rem' : '30rem'
};" frameborder="0" allowfullscreen></iframe>`, };" frameborder="0" allowfullscreen></iframe>`,
id: ([id]) => id,
}, },
codepen: true, codepen: true,
aparat: { aparat: {
@@ -491,7 +455,7 @@ export function getSidebarLinks() {
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
}, },
{ {
label: 'Certified Participants', label: 'Certified Members',
icon: 'GraduationCap', icon: 'GraduationCap',
to: 'CertifiedParticipants', to: 'CertifiedParticipants',
activeFor: ['CertifiedParticipants'], activeFor: ['CertifiedParticipants'],
@@ -583,5 +547,35 @@ export const escapeHTML = (text) => {
export const canCreateCourse = () => { export const canCreateCourse = () => {
const { userResource } = usersStore() const { userResource } = usersStore()
return userResource.data?.is_instructor || userResource.data?.is_moderator return (
!readOnlyMode &&
(userResource.data?.is_instructor || userResource.data?.is_moderator)
)
}
export const enablePlyr = () => {
setTimeout(() => {
const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return
const src = videoElement[0].getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
videoElement[0].setAttribute('data-plyr-embed-id', videoID)
}
new Plyr('.video-player', {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
} }

View File

@@ -25,7 +25,7 @@ export default defineConfig({
}), }),
], ],
server: { server: {
allowedHosts: ['fs', 'onb2'], allowedHosts: ['fs', 'persona'],
}, },
resolve: { resolve: {
alias: { alias: {
@@ -40,6 +40,7 @@ export default defineConfig({
'engine.io-client', 'engine.io-client',
'tailwind.config.js', 'tailwind.config.js',
'highlight.js', 'highlight.js',
'plyr',
], ],
}, },
}) })

2812
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.27.0" __version__ = "2.28.0"

View File

@@ -11,7 +11,7 @@ def after_install():
def after_sync(): def after_sync():
create_lms_roles() create_lms_roles()
set_default_certificate_print_format() set_default_certificate_print_format()
add_all_roles_to("Administrator") give_lms_roles_to_admin()
def before_uninstall(): def before_uninstall():
@@ -172,3 +172,15 @@ def create_batch_source():
doc = frappe.new_doc("LMS Source") doc = frappe.new_doc("LMS Source")
doc.source = source doc.source = source
doc.save() doc.save()
def give_lms_roles_to_admin():
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
for role in roles:
if not frappe.db.exists("Has Role", {"parent": "Administrator", "role": role}):
doc = frappe.new_doc("Has Role")
doc.parent = "Administrator"
doc.parenttype = "User"
doc.parentfield = "roles"
doc.role = role
doc.save()

View File

@@ -9,18 +9,19 @@
"field_order": [ "field_order": [
"job_title", "job_title",
"location", "location",
"disabled", "country",
"column_break_5", "column_break_5",
"type", "type",
"status", "status",
"disabled",
"section_break_6", "section_break_6",
"description",
"company_details_section",
"company_name", "company_name",
"company_website", "company_website",
"column_break_11", "column_break_phkm",
"company_logo", "company_logo",
"company_email_address" "company_email_address",
"company_details_section",
"description"
], ],
"fields": [ "fields": [
{ {
@@ -36,7 +37,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Location", "label": "City",
"reqd": 1 "reqd": 1
}, },
{ {
@@ -62,7 +63,8 @@
}, },
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
"fieldtype": "Section Break" "fieldtype": "Section Break",
"label": "Company Details"
}, },
{ {
"fieldname": "description", "fieldname": "description",
@@ -72,8 +74,7 @@
}, },
{ {
"fieldname": "company_details_section", "fieldname": "company_details_section",
"fieldtype": "Section Break", "fieldtype": "Section Break"
"label": "Company Details"
}, },
{ {
"fieldname": "company_name", "fieldname": "company_name",
@@ -89,10 +90,6 @@
"label": "Company Website", "label": "Company Website",
"reqd": 1 "reqd": 1
}, },
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{ {
"fieldname": "company_logo", "fieldname": "company_logo",
"fieldtype": "Attach Image", "fieldtype": "Attach Image",
@@ -111,13 +108,30 @@
"label": "Company Email Address", "label": "Company Email Address",
"options": "Email", "options": "Email",
"reqd": 1 "reqd": 1
},
{
"fieldname": "column_break_phkm",
"fieldtype": "Column Break"
},
{
"fieldname": "country",
"fieldtype": "Link",
"label": "Country",
"options": "Country",
"reqd": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
{
"link_doctype": "LMS Job Application",
"link_fieldname": "job"
}
],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-01-17 12:38:57.134919", "modified": "2025-04-24 14:34:35.920242",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "Job", "module": "Job",
"name": "Job Opportunity", "name": "Job Opportunity",
"owner": "Administrator", "owner": "Administrator",
@@ -157,8 +171,9 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "job_title" "title_field": "job_title"
} }

View File

@@ -19,6 +19,8 @@ from frappe.utils import (
format_date, format_date,
date_diff, date_diff,
) )
from frappe.query_builder import DocType
from pypika.functions import DistinctOptionFunction
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
@@ -182,9 +184,10 @@ def get_user_info():
) )
user.is_fc_site = is_fc_site() user.is_fc_site = is_fc_site()
user.is_system_manager = "System Manager" in user.roles user.is_system_manager = "System Manager" in user.roles
user.sitename = frappe.local.site
user.developer_mode = frappe.conf.developer_mode
if user.is_fc_site and user.is_system_manager: if user.is_fc_site and user.is_system_manager:
user.site_info = current_site_info() user.site_info = current_site_info()
user.sitename = frappe.local.site
return user return user
@@ -237,6 +240,11 @@ def validate_billing_access(billing_type, name):
access = False access = False
message = _("Batch is sold out.") message = _("Batch is sold out.")
start_date = frappe.get_cached_value("LMS Batch", name, "start_date")
if start_date and date_diff(start_date, now()) < 0:
access = False
message = _("Batch has already started.")
elif access and billing_type == "certificate": elif access and billing_type == "certificate":
purchased_certificate = frappe.db.exists( purchased_certificate = frappe.db.exists(
"LMS Enrollment", "LMS Enrollment",
@@ -278,6 +286,7 @@ def get_job_details(job):
[ [
"job_title", "job_title",
"location", "location",
"country",
"type", "type",
"company_name", "company_name",
"company_logo", "company_logo",
@@ -303,14 +312,20 @@ def get_job_opportunities(filters=None, orFilters=None):
fields=[ fields=[
"job_title", "job_title",
"location", "location",
"country",
"type", "type",
"company_name", "company_name",
"company_logo", "company_logo",
"name", "name",
"creation", "creation",
"description",
], ],
order_by="creation desc", order_by="creation desc",
) )
for job in jobs:
job.description = frappe.utils.strip_html_tags(job.description)
job.applicants = frappe.db.count("LMS Job Application", {"job": job.name})
return jobs return jobs
@@ -331,7 +346,7 @@ def get_chart_details():
details.completions = frappe.db.count( details.completions = frappe.db.count(
"LMS Enrollment", {"progress": ["like", "%100%"]} "LMS Enrollment", {"progress": ["like", "%100%"]}
) )
details.lesson_completions = frappe.db.count("LMS Course Progress") details.certifications = frappe.db.count("LMS Certificate", {"published": 1})
return details return details
@@ -411,29 +426,50 @@ def get_certified_participants(filters=None, start=0, page_length=30):
or_filters["course_title"] = ["like", f"%{category}%"] or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"] or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.get_all( participants = frappe.db.get_all(
"LMS Certificate", "LMS Certificate",
filters=filters, filters=filters,
or_filters=or_filters, or_filters=or_filters,
fields=["member"], fields=["member", "issue_date"],
group_by="member", group_by="member",
order_by="creation desc", order_by="issue_date desc",
start=start, start=start,
page_length=page_length, page_length=page_length,
) )
for participant in participants: for participant in participants:
count = frappe.db.count("LMS Certificate", {"member": participant.member})
details = frappe.db.get_value( details = frappe.db.get_value(
"User", "User",
participant.member, participant.member,
["full_name", "user_image", "username", "country", "headline"], ["full_name", "user_image", "username", "country", "headline"],
as_dict=1, as_dict=1,
) )
details["certificate_count"] = count
participant.update(details) participant.update(details)
return participants return participants
class CountDistinct(DistinctOptionFunction):
def __init__(self, field):
super().__init__("COUNT", field, distinct=True)
@frappe.whitelist(allow_guest=True)
def get_count_of_certified_members():
Certificate = DocType("LMS Certificate")
query = (
frappe.qb.from_(Certificate)
.select(CountDistinct(Certificate.member).as_("total"))
.where(Certificate.published == 1)
)
result = query.run(as_dict=True)
return result[0]["total"] if result else 0
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certification_categories(): def get_certification_categories():
categories = [] categories = []
@@ -655,13 +691,13 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """Get members for the given search term and start index.
Args: start (int): Start index for the query. Args: start (int): Start index for the query.
<<<<<<< HEAD <<<<<<< HEAD
search (str): Search term to filter the results. search (str): Search term to filter the results.
======= =======
search (str): Search term to filter the results. search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577 >>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members. Returns: List of members.
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
@@ -1366,3 +1402,17 @@ def add_an_evaluator(email):
evaluator.insert() evaluator.insert()
return evaluator return evaluator
@frappe.whitelist()
def capture_user_persona(responses):
frappe.only_for("System Manager")
data = frappe.parse_json(responses)
data = json.dumps(data)
response = frappe.integrations.utils.make_post_request(
"https://school.frappe.io/api/method/capture-persona",
data={"response": data},
)
if response.get("message").get("name"):
frappe.db.set_single_value("LMS Settings", "persona_captured", True)
return response

View File

@@ -0,0 +1,31 @@
{
"based_on": "issue_date",
"chart_name": "Certification",
"chart_type": "Count",
"creation": "2025-04-28 17:47:28.517149",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "LMS Certificate",
"dynamic_filters_json": "[]",
"filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1,false]]",
"group_by_type": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-04-28 17:47:28.517149",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Certification",
"number_of_groups": 0,
"owner": "sayali@frappe.io",
"parent_document_type": "",
"roles": [],
"source": "",
"time_interval": "Daily",
"timeseries": 1,
"timespan": "Last Month",
"type": "Line",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View File

@@ -9,14 +9,14 @@
"doctype": "Dashboard Chart", "doctype": "Dashboard Chart",
"document_type": "User", "document_type": "User",
"dynamic_filters_json": "[]", "dynamic_filters_json": "[]",
"filters_json": "[]", "filters_json": "[[\"User\",\"enabled\",\"=\",1,false]]",
"group_by_type": "Count", "group_by_type": "Count",
"idx": 1, "idx": 5,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"last_synced_on": "2022-10-20 10:46:56.849265", "last_synced_on": "2025-04-28 15:09:52.161688",
"modified": "2022-10-20 11:31:17.184897", "modified": "2025-04-28 17:47:58.168293",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "New Signups", "name": "New Signups",
"number_of_groups": 0, "number_of_groups": 0,
@@ -30,4 +30,4 @@
"use_report_chart": 0, "use_report_chart": 0,
"value_based_on": "", "value_based_on": "",
"y_axis": [] "y_axis": []
} }

View File

@@ -4,8 +4,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import validate_url, validate_email_address from frappe.utils import validate_url
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
@@ -15,14 +14,6 @@ class LMSAssignmentSubmission(Document):
self.validate_url() self.validate_url()
self.validate_status() self.validate_status()
def after_insert(self):
if not frappe.flags.in_test:
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if outgoing_email_account or frappe.conf.get("mail_login"):
self.send_mail()
def validate_duplicates(self): def validate_duplicates(self):
if frappe.db.exists( if frappe.db.exists(
"LMS Assignment Submission", "LMS Assignment Submission",
@@ -39,38 +30,6 @@ class LMSAssignmentSubmission(Document):
if self.type == "URL" and not validate_url(self.answer): if self.type == "URL" and not validate_url(self.answer):
frappe.throw(_("Please enter a valid URL.")) frappe.throw(_("Please enter a valid URL."))
def send_mail(self):
subject = _("New Assignment Submission")
template = "assignment_submission"
custom_template = frappe.db.get_single_value(
"LMS Settings", "assignment_submission_template"
)
args = {
"member_name": self.member_name,
"assignment_name": self.assignment,
"assignment_title": self.assignment_title,
"submission_name": self.name,
}
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
for moderator in moderators:
if not validate_email_address(moderator):
moderators.remove(moderator)
if custom_template:
email_template = get_email_template(custom_template, args)
subject = email_template.get("subject")
content = email_template.get("message")
frappe.sendmail(
recipients=moderators,
subject=subject,
template=template if not custom_template else None,
content=content if custom_template else None,
args=args,
header=[subject, "green"],
)
def validate_status(self): def validate_status(self):
if not self.is_new(): if not self.is_new():
doc_before_save = self.get_doc_before_save() doc_before_save = self.get_doc_before_save()

View File

@@ -91,7 +91,7 @@
"fetch_from": "member.username", "fetch_from": "member.username",
"fieldname": "member_username", "fieldname": "member_username",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Memeber Username", "label": "Member Username",
"read_only": 1 "read_only": 1
}, },
{ {
@@ -145,10 +145,11 @@
"options": "LMS Certificate" "options": "LMS Certificate"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-02-21 17:11:37.986157", "modified": "2025-04-25 10:06:25.824119",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Enrollment", "name": "LMS Enrollment",
"owner": "Administrator", "owner": "Administrator",
@@ -192,10 +193,11 @@
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "member_name", "title_field": "member_name",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -8,6 +8,7 @@
"general_tab", "general_tab",
"default_home", "default_home",
"send_calendar_invite_for_evaluations", "send_calendar_invite_for_evaluations",
"persona_captured",
"column_break_zdel", "column_break_zdel",
"allow_guest_access", "allow_guest_access",
"enable_learning_paths", "enable_learning_paths",
@@ -58,10 +59,12 @@
"certification_template", "certification_template",
"batch_confirmation_template", "batch_confirmation_template",
"column_break_uwsp", "column_break_uwsp",
"assignment_submission_template",
"payment_reminder_template", "payment_reminder_template",
"seo_tab", "seo_tab",
"meta_description" "meta_description",
"meta_image",
"column_break_xijv",
"meta_keywords"
], ],
"fields": [ "fields": [
{ {
@@ -106,7 +109,7 @@
"default": "0", "default": "0",
"fieldname": "user_category", "fieldname": "user_category",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Identify User Persona" "label": "Identify User Category"
}, },
{ {
"default": "0", "default": "0",
@@ -238,12 +241,6 @@
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Email Templates" "label": "Email Templates"
}, },
{
"fieldname": "assignment_submission_template",
"fieldtype": "Link",
"label": "Assignment Submission Template",
"options": "Email Template"
},
{ {
"fieldname": "column_break_uwsp", "fieldname": "column_break_uwsp",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -377,13 +374,36 @@
"fieldname": "meta_description", "fieldname": "meta_description",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Meta Description" "label": "Meta Description"
},
{
"description": "This image will be shown on lists and pages that don't have an image by default",
"fieldname": "meta_image",
"fieldtype": "Attach Image",
"label": "Meta Image"
},
{
"fieldname": "column_break_xijv",
"fieldtype": "Column Break"
},
{
"description": "Common keywords that will be used for all pages",
"fieldname": "meta_keywords",
"fieldtype": "Small Text",
"label": "Meta Keywords"
},
{
"default": "0",
"fieldname": "persona_captured",
"fieldtype": "Check",
"label": "Persona Captured",
"read_only": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-04-10 16:17:00.658698", "modified": "2025-04-22 16:05:27.914422",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",

View File

@@ -181,6 +181,7 @@ def get_lesson_icon(body, content):
if block.get("type") == "embed" and block.get("data").get("service") in [ if block.get("type") == "embed" and block.get("data").get("service") in [
"youtube", "youtube",
"vimeo", "vimeo",
"cloudflareStream",
]: ]:
return "icon-youtube" return "icon-youtube"
@@ -205,10 +206,13 @@ def get_tags(course):
return tags.split(",") if tags else [] return tags.split(",") if tags else []
def get_instructors(course): def get_instructors(doctype, docname):
instructor_details = [] instructor_details = []
instructors = frappe.get_all( instructors = frappe.get_all(
"Course Instructor", {"parent": course}, order_by="idx", pluck="instructor" "Course Instructor",
{"parent": docname, "parenttype": doctype},
order_by="idx",
pluck="instructor",
) )
for instructor in instructors: for instructor in instructors:
@@ -308,7 +312,7 @@ def get_lesson_index(lesson_name):
def get_lesson_url(course, lesson_number): def get_lesson_url(course, lesson_number):
if not lesson_number: if not lesson_number:
return return
return f"/courses/{course}/learn/{lesson_number}" return f"/lms/courses/{course}/learn/{lesson_number}"
def get_batch(course, batch_name): def get_batch(course, batch_name):
@@ -417,10 +421,11 @@ def get_initial_members(course):
def is_instructor(course): def is_instructor(course):
return ( instructors = get_instructors("LMS Course", course)
len(list(filter(lambda x: x.name == frappe.session.user, get_instructors(course)))) for instructor in instructors:
> 0 if instructor.name == frappe.session.user:
) return True
return False
def convert_number_to_character(number): def convert_number_to_character(number):
@@ -788,16 +793,15 @@ def get_chart_data(
) )
result = get_result(data, timegrain, from_date, to_date, chart.chart_type) result = get_result(data, timegrain, from_date, to_date, chart.chart_type)
data = []
return { for row in result:
"labels": [ data.append(
format_date(get_period(r[0], timegrain), parse_day_first=True) {
if timegrain in ("Daily", "Weekly") "date": row[0],
else get_period(r[0], timegrain) "count": row[1],
for r in result }
], )
"datasets": [{"name": chart.name, "data": [r[1] for r in result]}], return data
}
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@@ -805,15 +809,10 @@ def get_course_completion_data():
all_membership = frappe.db.count("LMS Enrollment") all_membership = frappe.db.count("LMS Enrollment")
completed = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]}) completed = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]})
return { return [
"labels": ["Completed", "In Progress"], {"label": "Completed", "value": completed},
"datasets": [ {"label": "In Progress", "value": all_membership - completed},
{ ]
"name": "Course Completion",
"data": [completed, all_membership - completed],
}
],
}
def get_telemetry_boot_info(): def get_telemetry_boot_info():
@@ -1012,7 +1011,7 @@ def get_courses(filters=None, start=0, page_length=20):
def get_course_card_details(courses): def get_course_card_details(courses):
for course in courses: for course in courses:
course.instructors = get_instructors(course.name) course.instructors = get_instructors("LMS Course", course.name)
if course.paid_course and course.published == 1: if course.paid_course and course.published == 1:
course.amount, course.currency = check_multicurrency( course.amount, course.currency = check_multicurrency(
@@ -1156,7 +1155,7 @@ def get_course_details(course):
as_dict=1, as_dict=1,
) )
course_details.instructors = get_instructors(course_details.name) course_details.instructors = get_instructors("LMS Course", course_details.name)
# course_details.is_instructor = is_instructor(course_details.name) # course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course or course_details.paid_certificate: if course_details.paid_course or course_details.paid_certificate:
"""course_details.course_price, course_details.currency = check_multicurrency( """course_details.course_price, course_details.currency = check_multicurrency(
@@ -1272,7 +1271,10 @@ def get_lesson(course, chapter, lesson):
membership = get_membership(course) membership = get_membership(course)
course_info = frappe.db.get_value( course_info = frappe.db.get_value(
"LMS Course", course, ["title", "paid_certificate"], as_dict=1 "LMS Course",
course,
["title", "paid_certificate", "disable_self_learning"],
as_dict=1,
) )
if ( if (
@@ -1285,6 +1287,7 @@ def get_lesson(course, chapter, lesson):
"no_preview": 1, "no_preview": 1,
"title": lesson_details.title, "title": lesson_details.title,
"course_title": course_info.title, "course_title": course_info.title,
"disable_self_learning": course_info.disable_self_learning,
} }
lesson_details = frappe.db.get_value( lesson_details = frappe.db.get_value(
@@ -1313,15 +1316,19 @@ def get_lesson(course, chapter, lesson):
else: else:
progress = get_progress(course, lesson_details.name) progress = get_progress(course, lesson_details.name)
lesson_details.chapter_title = frappe.db.get_value(
"Course Chapter", chapter_name, "title"
)
lesson_details.rendered_content = render_html(lesson_details) lesson_details.rendered_content = render_html(lesson_details)
neighbours = get_neighbour_lesson(course, chapter, lesson) neighbours = get_neighbour_lesson(course, chapter, lesson)
lesson_details.next = neighbours["next"] lesson_details.next = neighbours["next"]
lesson_details.progress = progress lesson_details.progress = progress
lesson_details.prev = neighbours["prev"] lesson_details.prev = neighbours["prev"]
lesson_details.membership = membership lesson_details.membership = membership
lesson_details.instructors = get_instructors(course) lesson_details.instructors = get_instructors("LMS Course", course)
lesson_details.course_title = course_info.title lesson_details.course_title = course_info.title
lesson_details.paid_certificate = course_info.paid_certificate lesson_details.paid_certificate = course_info.paid_certificate
lesson_details.disable_self_learning = course_info.disable_self_learning
return lesson_details return lesson_details
@@ -1385,7 +1392,7 @@ def get_batch_details(batch):
as_dict=True, as_dict=True,
) )
batch_details.instructors = get_instructors(batch) batch_details.instructors = get_instructors("LMS Batch", batch)
batch_details.accept_enrollments = batch_details.start_date > getdate() batch_details.accept_enrollments = batch_details.start_date > getdate()
if ( if (
@@ -2132,7 +2139,7 @@ def get_batch_type(filters):
def get_batch_card_details(batches): def get_batch_card_details(batches):
for batch in batches: for batch in batches:
batch.instructors = get_instructors(batch.name) batch.instructors = get_instructors("LMS Batch", batch.name)
students_count = frappe.db.count("LMS Batch Enrollment", {"batch": batch.name}) students_count = frappe.db.count("LMS Batch Enrollment", {"batch": batch.name})
if batch.seat_count: if batch.seat_count:
@@ -2167,3 +2174,7 @@ def get_palette(full_name):
hash_name = hashlib.md5(encoded_name).hexdigest() hash_name = hashlib.md5(encoded_name).hexdigest()
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33) idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
return palette[idx % 8] return palette[idx % 8]
def persona_captured():
frappe.db.set_single_value("LMS Settings", "persona_captured", 1)

View File

@@ -81,7 +81,7 @@
<div class="course-card-footer"> <div class="course-card-footer">
<div class="course-card-instructors"> <div class="course-card-instructors">
{% set instructors = get_instructors(course.name) %} {% set instructors = get_instructors("LMS Course", course.name) %}
{% set ins_len = instructors | length %} {% set ins_len = instructors | length %}
{% for instructor in instructors %} {% for instructor in instructors %}
{% if ins_len > 1 and loop.index == 1 %} {% if ins_len > 1 and loop.index == 1 %}
@@ -128,7 +128,7 @@
<a class="stretched-link" href="{{ get_lesson_url(course.name, lesson_index) }}{{ query_parameter }}"></a> <a class="stretched-link" href="{{ get_lesson_url(course.name, lesson_index) }}{{ query_parameter }}"></a>
{% else %} {% else %}
<a class="stretched-link" href="/courses/{{ course.name }}"></a> <a class="stretched-link" href="/lms/courses/{{ course.name }}"></a>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -101,4 +101,6 @@ lms.patches.v2_0.allow_guest_access #05-02-2025
lms.patches.v2_0.migrate_batch_student_data #10-02-2025 lms.patches.v2_0.migrate_batch_student_data #10-02-2025
lms.patches.v2_0.delete_old_enrollment_doctypes lms.patches.v2_0.delete_old_enrollment_doctypes
lms.patches.v2_0.delete_unused_custom_fields lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.update_certificate_request_status lms.patches.v2_0.update_certificate_request_status
lms.patches.v2_0.update_job_city_and_country
lms.patches.v2_0.update_course_evaluator_data

View File

@@ -0,0 +1,19 @@
import frappe
def execute():
evaluators = frappe.get_all("Course Evaluator", pluck="name")
for evaluator in evaluators:
details = frappe.db.get_value(
"User", evaluator, ["full_name", "user_image", "username"], as_dict=True
)
frappe.db.set_value(
"Course Evaluator",
evaluator,
{
"full_name": details.full_name,
"user_image": details.user_image,
"username": details.username,
},
)

View File

@@ -0,0 +1,28 @@
import frappe
def execute():
jobs = frappe.get_all("Job Opportunity", fields=["name", "location"])
for job in jobs:
if "," in job.location:
city, country = job.location.split(",", 1)
city = city.strip()
country = country.strip()
save_country(country, job)
frappe.db.set_value("Job Opportunity", job.name, "location", city)
else:
save_country(job.location, job)
def save_country(country, job):
if frappe.db.exists("Country", country):
frappe.db.set_value("Job Opportunity", job.name, "country", country)
else:
country_mapping = {
"US": "United States",
"USA": "United States",
"UAE": "United Arab Emirates",
}
country = country_mapping.get(country, country)
frappe.db.set_value("Job Opportunity", job.name, "country", country)

View File

@@ -1621,30 +1621,6 @@ pre {
border: 1px solid var(--primary-color); border: 1px solid var(--primary-color);
} }
.show-attachments {
padding-right: 0.5rem;
display: flex;
align-items: center;
}
.attachment-controls {
display: flex;
align-items: center;
width: fit-content;
cursor: pointer;
}
.attachments {
flex-direction: column;
padding: 0.5rem 0;
margin-top: 1rem;
position: absolute;
z-index: 1;
width: fit-content;
border-collapse: separate;
border-spacing: 1rem 0.5rem;
}
li { li {
line-height: 1.7; line-height: 1.7;
} }

View File

@@ -8,32 +8,44 @@ no_cache = 1
def get_context(): def get_context():
context = frappe._dict()
context.boot = get_boot()
frappe.db.commit()
app_path = frappe.form_dict.get("app_path") app_path = frappe.form_dict.get("app_path")
favicon = ( favicon = (
frappe.db.get_single_value("Website Settings", "favicon") frappe.db.get_single_value("Website Settings", "favicon")
or "/assets/lms/frontend/favicon.png" or "/assets/lms/frontend/favicon.png"
) )
title = frappe.db.get_single_value("Website Settings", "app_name") or "Frappe Learning" title = frappe.db.get_single_value("Website Settings", "app_name") or "Frappe Learning"
description = frappe.db.get_single_value("LMS Settings", "meta_description")
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit()
context = frappe._dict() context.meta = get_meta(app_path, title, favicon)
context.csrf_token = csrf_token
context.meta = get_meta(app_path, title, favicon, description)
capture("active_site", "lms")
context.title = title context.title = title
context.favicon = favicon context.favicon = favicon
capture("active_site", "lms")
return context return context
def get_meta(app_path, title, favicon, description): def get_boot():
meta = frappe._dict() return frappe._dict(
{
"frappe_version": frappe.__version__,
"read_only_mode": frappe.flags.read_only,
"csrf_token": frappe.sessions.get_csrf_token(),
}
)
def get_meta(app_path, title, favicon):
meta = frappe._dict()
if app_path: if app_path:
meta = get_meta_from_document(app_path) meta = get_meta_from_document(app_path)
route_meta = frappe.get_all("Website Meta Tag", {"parent": app_path}, ["key", "value"]) route_meta = frappe.get_all("Website Meta Tag", {"parent": app_path}, ["key", "value"])
description = frappe.db.get_single_value("LMS Settings", "meta_description")
image = frappe.db.get_single_value("LMS Settings", "meta_image")
keywords = frappe.db.get_single_value("LMS Settings", "meta_keywords")
if len(route_meta) > 0: if len(route_meta) > 0:
for row in route_meta: for row in route_meta:
@@ -55,10 +67,9 @@ def get_meta(app_path, title, favicon, description):
meta["description"] = description meta["description"] = description
if not meta.get("image"): if not meta.get("image"):
meta["image"] = favicon meta["image"] = image or favicon
if not meta.get("keywords"): meta["keywords"] = f"{meta.get('keywords')}, {keywords}"
meta["keywords"] = ""
if not meta: if not meta:
meta = { meta = {
@@ -286,4 +297,11 @@ def get_meta_from_document(app_path):
"link": "/programs", "link": "/programs",
} }
if app_path == "certified-participants":
return {
"title": _("Certified Participants"),
"keywords": "All Certified Participants, Certified Participants, Learn, Certification",
"link": "/certified-participants",
}
return {} return {}

958
yarn.lock

File diff suppressed because it is too large Load Diff