chore: merged conflicts
This commit is contained in:
3
.github/workflows/make_release_pr.yml
vendored
3
.github/workflows/make_release_pr.yml
vendored
@@ -1,8 +1,7 @@
|
||||
name: Create weekly release
|
||||
on:
|
||||
schedule:
|
||||
# 13:00 UTC -> 7pm IST on every Wednesday
|
||||
- cron: '30 4 * * 3'
|
||||
- cron: '30 4 15 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -100,6 +100,7 @@ jobs:
|
||||
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 set-password frappe@example.com admin
|
||||
bench --site lms.test execute lms.lms.utils.persona_captured
|
||||
|
||||
- name: cypress pre-requisites
|
||||
run: |
|
||||
|
||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
||||
openMode: 0,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://testui:8000",
|
||||
baseUrl: "http://pertest:8000",
|
||||
},
|
||||
});
|
||||
|
||||
Submodule frappe-ui updated: 8cd9b06a5e...175be05a92
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@@ -16,6 +16,7 @@ declare module 'vue' {
|
||||
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
||||
Assessments: typeof import('./src/components/Assessments.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']
|
||||
Autocomplete: typeof import('./src/components/Controls/Autocomplete.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']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.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']
|
||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||
|
||||
@@ -26,12 +26,8 @@
|
||||
<a href="{{ meta.link }}">Know More</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modals"></div>
|
||||
<div id="popovers"></div>
|
||||
|
||||
<script>
|
||||
document.getElementById('seo-content').style.display = 'none';
|
||||
window.csrf_token = '{{ csrf_token }}'
|
||||
</script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -26,11 +26,12 @@
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.122",
|
||||
"frappe-ui": "^0.1.134",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"plyr": "^3.7.8",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
@@ -24,7 +24,7 @@ const router = useRouter()
|
||||
const noSidebar = ref(false)
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.query.fromLesson) {
|
||||
if (to.query.fromLesson || to.path === '/persona') {
|
||||
noSidebar.value = true
|
||||
} else {
|
||||
noSidebar.value = false
|
||||
|
||||
@@ -39,7 +39,11 @@
|
||||
{{ __('More') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
||||
<Button
|
||||
v-if="isModerator && !readOnlyMode"
|
||||
variant="ghost"
|
||||
@click="openPageModal()"
|
||||
>
|
||||
<template #icon>
|
||||
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
@@ -63,6 +67,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-2 flex flex-col gap-1">
|
||||
<div
|
||||
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed"
|
||||
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<TrialBanner
|
||||
v-if="
|
||||
userResource.data?.is_system_manager && userResource.data?.is_fc_site
|
||||
@@ -74,43 +88,69 @@
|
||||
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
appName="learning"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isOnboardingStepsCompleted"
|
||||
:link="{
|
||||
label: __('Help'),
|
||||
}"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
@click="
|
||||
() => {
|
||||
showHelpModal = minimize ? true : !showHelpModal
|
||||
minimize = !showHelpModal
|
||||
}
|
||||
|
||||
<div
|
||||
class="flex items-center mt-4"
|
||||
:class="
|
||||
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<CircleHelp class="h-4 w-4 stroke-1.5" />
|
||||
</span>
|
||||
</template>
|
||||
</SidebarLink>
|
||||
<SidebarLink
|
||||
:link="{
|
||||
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
|
||||
}"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
@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,
|
||||
}"
|
||||
<div
|
||||
class="flex items-center flex-1"
|
||||
:class="
|
||||
sidebarStore.isSidebarCollapsed
|
||||
? 'flex-col space-y-3'
|
||||
: 'flex-row space-x-3'
|
||||
"
|
||||
>
|
||||
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
|
||||
<CircleAlert
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SidebarLink>
|
||||
<template #body>
|
||||
<div
|
||||
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Learning')">
|
||||
<Zap
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Help')">
|
||||
<CircleHelp
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="
|
||||
() => {
|
||||
showHelpModal = minimize ? true : !showHelpModal
|
||||
minimize = !showHelpModal
|
||||
}
|
||||
"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="
|
||||
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
|
||||
"
|
||||
>
|
||||
<CollapseSidebar
|
||||
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
|
||||
:class="{
|
||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||
}"
|
||||
@click="toggleSidebar()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<HelpModal
|
||||
v-if="showOnboarding && showHelpModal"
|
||||
@@ -148,7 +188,7 @@ import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
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 { capture } from '@/telemetry'
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
@@ -156,6 +196,7 @@ import { useRouter } from 'vue-router'
|
||||
import InviteIcon from './Icons/InviteIcon.vue'
|
||||
import {
|
||||
BookOpen,
|
||||
CircleAlert,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
CircleHelp,
|
||||
@@ -164,6 +205,7 @@ import {
|
||||
UserPlus,
|
||||
Users,
|
||||
BookText,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TrialBanner,
|
||||
@@ -192,6 +234,7 @@ const currentStep = ref({})
|
||||
const router = useRouter()
|
||||
let onboardingDetails
|
||||
let isOnboardingStepsCompleted = false
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const iconProps = {
|
||||
strokeWidth: 1.5,
|
||||
width: 16,
|
||||
@@ -578,4 +621,8 @@ watch(userResource, () => {
|
||||
setUpOnboarding()
|
||||
}
|
||||
})
|
||||
|
||||
const redirectToWebsite = () => {
|
||||
window.open('https://frappe.io/learning', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Assessments') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
||||
<Button v-if="canAddAssessments()" @click="showModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const showModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { showToast } from '@/utils'
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const showCourseModal = ref(false)
|
||||
const user = inject('$user')
|
||||
@@ -159,6 +160,9 @@ const removeCourses = (selections, unselectAll) => {
|
||||
}
|
||||
|
||||
const canSeeAddButton = () => {
|
||||
if (readOnlyMode) {
|
||||
return false
|
||||
}
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -111,7 +111,6 @@ import {
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</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" />
|
||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
@@ -46,68 +49,70 @@
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="isModerator || isStudent"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<span>
|
||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||
</span>
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="isModerator || isStudent"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<script setup>
|
||||
@@ -120,7 +125,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<div class="text-ink-gray-7 font-medium">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<Button @click="openStudentModal()">
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
@@ -247,6 +247,7 @@ const chartData = ref(null)
|
||||
const chartOptions = ref(null)
|
||||
const showProgressChart = ref(false)
|
||||
const assessmentCount = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
|
||||
@@ -9,88 +9,94 @@
|
||||
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
|
||||
{{ course.data.price }}
|
||||
</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
|
||||
v-else-if="course.data.paid_course"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
name: 'Billing',
|
||||
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,
|
||||
type: 'course',
|
||||
name: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<span>
|
||||
{{ __('Continue Learning') }}
|
||||
{{ __('Buy this course') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<CertificationLinks :courseName="course.data.name" class="w-full" />
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'course',
|
||||
name: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<Badge
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-else
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
size="md"
|
||||
>
|
||||
<span>
|
||||
{{ __('Buy this course') }}
|
||||
{{ __('Start Learning') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
</div>
|
||||
<Button
|
||||
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
|
||||
v-if="canGetCertificate"
|
||||
@click="fetchCertificate()"
|
||||
variant="subtle"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
>
|
||||
{{ __('Get Certificate') }}
|
||||
</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="mt-8 font-medium text-ink-gray-9">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': !readOnlyMode }"
|
||||
>
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
@@ -140,7 +146,7 @@
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
||||
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 { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -148,6 +154,7 @@ import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
@@ -172,7 +179,7 @@ function enrollStudent() {
|
||||
)
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 2000)
|
||||
}, 1000)
|
||||
} else {
|
||||
const enrollStudentResource = createResource({
|
||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
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()">
|
||||
{{ __('Add Chapter') }}
|
||||
</Button>
|
||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||
</span> -->
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-if="user.data.name == reply.owner && !reply.editable"
|
||||
v-if="
|
||||
user.data.name == reply.owner && !reply.editable && !readOnlyMode
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
@@ -71,7 +73,7 @@
|
||||
</div>
|
||||
|
||||
<TextEditor
|
||||
v-if="renderEditor"
|
||||
v-if="renderEditor && !readOnlyMode"
|
||||
class="mt-5"
|
||||
:content="newReply"
|
||||
:mentions="mentionUsers"
|
||||
@@ -80,7 +82,7 @@
|
||||
: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"
|
||||
/>
|
||||
<div class="flex justify-between mt-2">
|
||||
<div v-if="!readOnlyMode" class="flex justify-between mt-2">
|
||||
<span> </span>
|
||||
<Button @click="postReply()">
|
||||
<span>
|
||||
@@ -105,6 +107,7 @@ const user = inject('$user')
|
||||
const allUsers = inject('$allUsers')
|
||||
const mentionUsers = ref([])
|
||||
const renderEditor = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<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)) }}
|
||||
</Button>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
@@ -77,6 +81,7 @@ const currentTopic = ref(null)
|
||||
const socket = inject('$socket')
|
||||
const user = inject('$user')
|
||||
const showTopicModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
|
||||
@@ -97,7 +97,7 @@ const evaluators = createResource({
|
||||
return {
|
||||
doctype: 'Course Evaluator',
|
||||
fields: ['evaluator', 'full_name', 'user_image', 'username'],
|
||||
filters: search.value ? [['evaluator', 'like', search.value]] : [],
|
||||
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
|
||||
16
frontend/src/components/Icons/Play.vue
Normal file
16
frontend/src/components/Icons/Play.vue
Normal 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>
|
||||
@@ -1,25 +1,35 @@
|
||||
<template>
|
||||
<div class="border rounded-md p-4">
|
||||
<div class="flex space-x-4">
|
||||
<img
|
||||
:src="job.company_logo"
|
||||
class="size-10 rounded-full object-contain"
|
||||
/>
|
||||
<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">
|
||||
<div
|
||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
|
||||
>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<div class="flex flex-col space-y-2 flex-1">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ job.company_name }}
|
||||
</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>
|
||||
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
|
||||
</div>
|
||||
<div class="space-x-4 mt-2">
|
||||
<Badge>
|
||||
{{ job.location }}
|
||||
</Badge>
|
||||
<div class="space-x-2 mt-auto">
|
||||
<Badge>
|
||||
{{ job.type }}
|
||||
</Badge>
|
||||
@@ -27,11 +37,16 @@
|
||||
{{ dayjs(job.creation).fromNow() }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="description text-ink-gray-9 text-sm"
|
||||
v-html="job.description"
|
||||
></div> -->
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { Badge } from 'frappe-ui'
|
||||
import { MapPin, User } from 'lucide-vue-next'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const props = defineProps({
|
||||
@@ -41,3 +56,15 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
@@ -87,6 +87,7 @@ import { formatTime } from '@/utils/'
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
@@ -116,6 +117,11 @@ const liveClasses = createListResource({
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
|
||||
147
frontend/src/components/Modals/AssignmentForm.vue
Normal file
147
frontend/src/components/Modals/AssignmentForm.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
assignmentID === 'new'
|
||||
? __('Create an Assignment')
|
||||
: __('Edit Assignment'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveAssignment(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4 text-base max-h-[65vh] overflow-y-auto">
|
||||
<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>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog, FormControl, TextEditor } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const show = defineModel('show')
|
||||
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 = (close) => {
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('Assignment created successfully'),
|
||||
'check'
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
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>
|
||||
@@ -315,12 +315,6 @@ const tabsStructure = computed(() => {
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
{
|
||||
label: 'Assignment Submission Template',
|
||||
name: 'assignment_submission_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -328,11 +322,11 @@ const tabsStructure = computed(() => {
|
||||
icon: 'LogIn',
|
||||
fields: [
|
||||
{
|
||||
label: 'Identify User Persona',
|
||||
label: 'Identify User Category',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'Enable this option to identify the user persona during signup.',
|
||||
'Enable this option to identify the user category during signup.',
|
||||
},
|
||||
{
|
||||
label: 'Disable signup',
|
||||
@@ -358,10 +352,23 @@ const tabsStructure = computed(() => {
|
||||
label: 'Meta Description',
|
||||
name: 'meta_description',
|
||||
type: 'textarea',
|
||||
rows: 5,
|
||||
rows: 4,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -653,3 +653,8 @@ const getSubmissionColumns = () => {
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
p {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,9 @@ const props = defineProps({
|
||||
|
||||
const update = () => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,21 +54,30 @@
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<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 class="flex flex-col flex-wrap">
|
||||
<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 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) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@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>
|
||||
@@ -99,7 +108,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
@@ -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,
|
||||
label: 'Log out',
|
||||
|
||||
@@ -1,32 +1,53 @@
|
||||
<template>
|
||||
<div ref="videoContainer" class="video-block group relative">
|
||||
<div ref="videoContainer" class="video-block relative group">
|
||||
<video
|
||||
@timeupdate="updateTime"
|
||||
@ended="videoEnded"
|
||||
@click="togglePlay"
|
||||
oncontextmenu="return false"
|
||||
class="rounded-lg border border-gray-100 group cursor-pointer"
|
||||
class="rounded-md border border-gray-100 cursor-pointer"
|
||||
ref="videoRef"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
<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">
|
||||
<template #icon>
|
||||
<Play
|
||||
v-if="!playing"
|
||||
@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>
|
||||
</Button>
|
||||
<Button variant="ghost" @click="toggleMute">
|
||||
<template #icon>
|
||||
<Volume2 v-if="!muted" class="w-4 h-4 text-ink-gray-9" />
|
||||
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" />
|
||||
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
||||
<VolumeX v-else class="size-5 text-ink-white" />
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
@@ -38,12 +59,12 @@
|
||||
@input="changeCurrentTime"
|
||||
class="duration-slider w-full h-1"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
<span class="text-sm font-semibold">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</span>
|
||||
<Button variant="ghost" @click="toggleFullscreen">
|
||||
<template #icon>
|
||||
<Maximize class="w-4 h-4 text-ink-gray-9" />
|
||||
<Maximize class="size-5 text-ink-white" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,8 +72,9 @@
|
||||
</template>
|
||||
<script setup>
|
||||
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 Play from '@/components/Icons/Play.vue'
|
||||
|
||||
const videoRef = ref(null)
|
||||
const videoContainer = ref(null)
|
||||
@@ -147,7 +169,6 @@ const toggleFullscreen = () => {
|
||||
<style scoped>
|
||||
.video-block {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -165,15 +186,16 @@ iframe {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: theme('colors.gray.400');
|
||||
border-radius: 10px;
|
||||
background-color: theme('colors.gray.100');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duration-slider::-webkit-slider-thumb {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
width: 2px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
background-color: theme('colors.gray.900');
|
||||
background-color: theme('colors.gray.500');
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
@@ -186,7 +208,7 @@ iframe {
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
box-shadow: -500px 0 0 500px theme('colors.gray.900');
|
||||
box-shadow: -500px 0 0 500px theme('colors.gray.600');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,5 +26,6 @@ app.mount('#app')
|
||||
const { userResource, allUsers } = usersStore()
|
||||
app.provide('$user', userResource)
|
||||
app.provide('$allUsers', allUsers)
|
||||
|
||||
app.config.globalProperties.$user = userResource
|
||||
app.config.globalProperties.$dialog = createDialog
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'AssignmentForm',
|
||||
params: {
|
||||
assignmentID: 'new',
|
||||
},
|
||||
}"
|
||||
<Button
|
||||
v-if="!readOnlyMode"
|
||||
variant="solid"
|
||||
@click="
|
||||
() => {
|
||||
assignmentID = 'new'
|
||||
showAssignmentForm = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
@@ -38,12 +38,11 @@
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'AssignmentForm',
|
||||
params: {
|
||||
assignmentID: row.name,
|
||||
},
|
||||
}),
|
||||
onRowClick: (row) => {
|
||||
if (readOnlyMode) return
|
||||
assignmentID = row.name
|
||||
showAssignmentForm = true
|
||||
},
|
||||
}"
|
||||
>
|
||||
</ListView>
|
||||
@@ -72,6 +71,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AssignmentForm
|
||||
v-model="showAssignmentForm"
|
||||
v-model:assignments="assignments"
|
||||
:assignmentID="assignmentID"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -86,13 +90,17 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Pencil } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const titleFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const showAssignmentForm = ref(false)
|
||||
const assignmentID = ref('new')
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
@@ -136,7 +144,7 @@ const assignmentFilter = computed(() => {
|
||||
|
||||
const assignments = createListResource({
|
||||
doctype: 'LMS Assignment',
|
||||
fields: ['name', 'title', 'type', 'creation'],
|
||||
fields: ['name', 'title', 'type', 'creation', 'question'],
|
||||
orderBy: 'modified desc',
|
||||
cache: ['assignments'],
|
||||
transform(data) {
|
||||
@@ -166,7 +174,7 @@ const assignmentColumns = computed(() => {
|
||||
label: __('Created'),
|
||||
key: 'creation',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
{{ __('Generate Certificates') }}
|
||||
</Button>
|
||||
<Button v-if="user.data?.is_moderator" @click="openAnnouncementModal()">
|
||||
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
|
||||
<span>
|
||||
{{ __('Make an Announcement') }}
|
||||
</span>
|
||||
@@ -242,6 +242,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
const tabIndex = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
@@ -354,6 +355,11 @@ watch(tabIndex, () => {
|
||||
}
|
||||
})
|
||||
|
||||
const canMakeAnnouncement = () => {
|
||||
if (readOnlyMode) return false
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
{{ batch.data.description }}
|
||||
</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">
|
||||
<BookOpen class="h-4 w-4 mr-2" />
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
<span class="hidden lg:block" v-if="batch.data.courses"
|
||||
<span v-if="batch.data?.courses?.length" class="hidden lg:block"
|
||||
>·</span
|
||||
>
|
||||
<DateRange
|
||||
@@ -31,7 +34,7 @@
|
||||
>·</span
|
||||
>
|
||||
<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>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator"
|
||||
v-if="canCreateBatch()"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: { batchName: 'new' },
|
||||
@@ -124,6 +124,7 @@ const filters = ref({})
|
||||
const is_student = computed(() => user.data?.is_student)
|
||||
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
||||
const orderBy = ref('start_date')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
@@ -299,6 +300,12 @@ const batchTabs = computed(() => {
|
||||
return tabs
|
||||
})
|
||||
|
||||
const canCreateBatch = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Batches'),
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
<div class="p-5 lg:w-3/4 mx-auto">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5"
|
||||
>
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('All Certified Participants') }}
|
||||
<div
|
||||
v-if="participants.data?.length"
|
||||
class="mx-auto w-full max-w-4xl pt-6 pb-10"
|
||||
>
|
||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||
{{ memberCount }} {{ __('certified members') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
@@ -40,57 +41,80 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="participants.data?.length">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div class="divide-y">
|
||||
<template v-for="participant in participants.data">
|
||||
<router-link
|
||||
v-for="participant in participants.data"
|
||||
:to="{
|
||||
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
|
||||
class="flex items-center space-x-2 border rounded-md hover:bg-surface-menu-bar p-2 text-ink-gray-7"
|
||||
>
|
||||
<div class="flex items-center w-full space-x-3">
|
||||
<Avatar
|
||||
:image="participant.user_image"
|
||||
class="size-8 rounded-full object-contain"
|
||||
:label="participant.full_name"
|
||||
size="2xl"
|
||||
/>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="font-medium">
|
||||
{{ participant.full_name }}
|
||||
<div class="flex flex-col md:flex-row w-full">
|
||||
<div class="flex-1">
|
||||
<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
|
||||
v-if="participant.headline"
|
||||
class="headline text-sm text-ink-gray-7"
|
||||
class="flex items-center space-x-3 md:space-x-24 text-sm md:text-base mt-1.5"
|
||||
>
|
||||
{{ 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>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="!participants.list.loading && participants.hasNextPage"
|
||||
class="flex justify-center mt-5"
|
||||
>
|
||||
<Button @click="participants.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!participants.list.loading"
|
||||
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
|
||||
v-if="!participants.list.loading && participants.hasNextPage"
|
||||
class="flex justify-center mt-5"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-lg font-medium mb-1">
|
||||
{{ __('No participants found') }}
|
||||
</div>
|
||||
<div class="leading-5 w-2/5 text-center">
|
||||
{{ __('There are no participants matching this criteria.') }}
|
||||
</div>
|
||||
<Button @click="participants.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
@@ -99,13 +123,13 @@ import {
|
||||
Avatar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FormControl,
|
||||
Select,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { BookOpen, GraduationCap } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
@@ -113,6 +137,8 @@ const currentCategory = ref('')
|
||||
const filters = ref({})
|
||||
const nameFilter = ref('')
|
||||
const { brand } = sessionStore()
|
||||
const memberCount = ref(0)
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
onMounted(() => {
|
||||
updateParticipants()
|
||||
@@ -126,6 +152,12 @@ const participants = createListResource({
|
||||
pageLength: 30,
|
||||
})
|
||||
|
||||
const count = call('lms.lms.api.get_count_of_certified_members').then(
|
||||
(data) => {
|
||||
memberCount.value = data
|
||||
}
|
||||
)
|
||||
|
||||
const categories = createListResource({
|
||||
doctype: 'LMS Certificate',
|
||||
url: 'lms.lms.api.get_certification_categories',
|
||||
@@ -161,14 +193,14 @@ const updateFilters = () => {
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Certified Participants'),
|
||||
label: __('Certified Members'),
|
||||
route: { name: 'CertifiedParticipants' },
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Certified Participants'),
|
||||
title: __('Certified Members'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
||||
<div
|
||||
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 class="mt-10">
|
||||
<CourseOutline
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
v-for="course in courses.data"
|
||||
@@ -96,6 +96,7 @@
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FormControl,
|
||||
Select,
|
||||
@@ -107,6 +108,7 @@ import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { canCreateCourse } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import router from '../router'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -119,8 +121,10 @@ const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const currentTab = ref('Live')
|
||||
const { brand } = sessionStore()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
identifyUserPersona()
|
||||
setFiltersFromQuery()
|
||||
updateCourses()
|
||||
categories.value = [
|
||||
@@ -145,16 +149,45 @@ const courses = createListResource({
|
||||
pageLength: pageLength.value,
|
||||
start: start.value,
|
||||
onSuccess(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)
|
||||
}
|
||||
setCategories(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 = () => {
|
||||
updateFilters()
|
||||
courses.update({
|
||||
|
||||
@@ -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
|
||||
v-if="user.data.name == job.data?.owner"
|
||||
:to="{
|
||||
name: 'JobCreation',
|
||||
name: 'JobForm',
|
||||
params: { jobName: job.data?.name },
|
||||
}"
|
||||
>
|
||||
@@ -47,8 +50,14 @@
|
||||
</template>
|
||||
{{ __('Apply') }}
|
||||
</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 v-else>
|
||||
<div v-else-if="!readOnlyMode">
|
||||
<Button @click="redirectToLogin(job.data?.name)">
|
||||
<span>
|
||||
{{ __('Login to apply') }}
|
||||
@@ -56,13 +65,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
</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="space-y-5 mb-10">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
: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"
|
||||
@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"
|
||||
>
|
||||
<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">
|
||||
<span class="text-xs text-ink-gray-5 font-medium uppercase">
|
||||
{{ __('Organisation') }}
|
||||
@@ -86,20 +95,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
<span class="text-xs text-ink-gray-5 font-medium uppercase">
|
||||
{{ __('Location') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.location }}
|
||||
{{ job.data.location }}, {{ job.data.country }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
<span class="text-xs text-ink-gray-5 font-medium uppercase">
|
||||
{{ __('Category') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
@@ -108,9 +117,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
<span class="text-xs text-ink-gray-5 font-medium uppercase">
|
||||
{{ __('Posted on') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
@@ -122,9 +131,9 @@
|
||||
v-if="applicationCount.data"
|
||||
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">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
<span class="text-xs text-ink-gray-5 font-medium uppercase">
|
||||
{{ __('Applications Received') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
@@ -149,12 +158,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<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 { sessionStore } from '../stores/session'
|
||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||
import {
|
||||
MapPin,
|
||||
Check,
|
||||
SendHorizonal,
|
||||
Pencil,
|
||||
Building2,
|
||||
@@ -168,6 +184,7 @@ const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
const showApplicationModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
|
||||
@@ -13,17 +13,22 @@
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Job Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="job.job_title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="job.location"
|
||||
:label="__('Location')"
|
||||
:label="__('City')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="job.country"
|
||||
doctype="Country"
|
||||
:label="__('Country')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,25 +50,12 @@
|
||||
/>
|
||||
</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 class="container mb-4 pb-4">
|
||||
<div class="container border-b mb-4 pb-4">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Company Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="job.company_name"
|
||||
@@ -128,6 +120,19 @@
|
||||
</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>
|
||||
</template>
|
||||
@@ -217,6 +222,7 @@ const imageResource = createResource({
|
||||
const job = reactive({
|
||||
job_title: '',
|
||||
location: '',
|
||||
country: '',
|
||||
type: 'Full Time',
|
||||
status: 'Open',
|
||||
company_name: '',
|
||||
@@ -317,7 +323,7 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
{
|
||||
label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
|
||||
route: { name: 'JobCreation' },
|
||||
route: { name: 'JobForm' },
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
@@ -10,13 +10,13 @@
|
||||
<router-link
|
||||
v-if="user.data?.name"
|
||||
:to="{
|
||||
name: 'JobCreation',
|
||||
name: 'JobForm',
|
||||
params: {
|
||||
jobName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<Button v-if="!readOnlyMode" variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
@@ -25,40 +25,48 @@
|
||||
</router-link>
|
||||
</header>
|
||||
<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
|
||||
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">
|
||||
{{ __('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>
|
||||
{{ __('{0} Open Jobs').format(jobCount) }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 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>
|
||||
<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
|
||||
v-for="job in jobs.data"
|
||||
:to="{
|
||||
@@ -73,18 +81,17 @@
|
||||
</div>
|
||||
<div
|
||||
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" />
|
||||
<div class="text-lg font-medium mb-1">
|
||||
{{ __('No jobs found') }}
|
||||
</div>
|
||||
<div class="leading-5 w-2/5 text-center">
|
||||
{{
|
||||
__(
|
||||
'There are no jobs available at the moment. Open a job opportunity or check here again later.'
|
||||
)
|
||||
}}
|
||||
{{ __('There are no jobs available at the moment.') }}
|
||||
</div>
|
||||
<div class="leading-5 w-1/5 text-center">
|
||||
{{ __('Post a new job or check again later.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,21 +101,26 @@
|
||||
import {
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
FormControl,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { Laptop, Plus, Search } from 'lucide-vue-next'
|
||||
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 Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const jobType = ref(null)
|
||||
const { brand } = sessionStore()
|
||||
const searchQuery = ref('')
|
||||
const country = ref(null)
|
||||
const filters = ref({})
|
||||
const orFilters = ref({})
|
||||
const jobCount = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -116,6 +128,7 @@ onMounted(() => {
|
||||
jobType.value = queries.get('type')
|
||||
}
|
||||
updateJobs()
|
||||
getJobCount()
|
||||
})
|
||||
|
||||
const jobs = createResource({
|
||||
@@ -153,8 +166,30 @@ const updateFilters = () => {
|
||||
} else {
|
||||
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(() => {
|
||||
return [
|
||||
'',
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<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>
|
||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||
<div
|
||||
v-if="lesson.data.no_preview"
|
||||
class="border-r text-center pt-10 px-5 md:px-0 pb-10"
|
||||
>
|
||||
<p class="mb-4">
|
||||
{{
|
||||
__(
|
||||
'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 v-if="lesson.data.no_preview" class="border-r">
|
||||
<div class="shadow rounded-md w-3/4 mt-10 mx-auto text-center p-4">
|
||||
<div class="flex items-center justify-center mt-4 space-x-2">
|
||||
<LockKeyholeIcon class="size-4 stroke-2 text-ink-gray-5" />
|
||||
<div class="text-lg font-semibold text-ink-gray-7">
|
||||
{{ __('This lesson is locked') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-2 md:mt-0">
|
||||
<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 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 class="mt-1 mb-4 text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
'This lesson is not available for preview. Please enroll in the course to access it.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
<Button
|
||||
v-if="user.data && !lesson.data.disable_self_learning"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
<Badge
|
||||
theme="blue"
|
||||
size="lg"
|
||||
v-else-if="lesson.data.disable_self_learning"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ __('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
|
||||
v-else
|
||||
ref="lessonContainer"
|
||||
class="bg-surface-white"
|
||||
:class="{
|
||||
'overflow-y-auto': zenModeEnabled,
|
||||
}"
|
||||
>
|
||||
<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"
|
||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
||||
:class="{
|
||||
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
|
||||
}"
|
||||
>
|
||||
<div class="text-ink-gray-5 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
<div
|
||||
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
|
||||
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
|
||||
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 mt-6"
|
||||
>
|
||||
<LessonContent :content="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
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-5"
|
||||
>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<div
|
||||
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-5"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
:key="lesson.data.name"
|
||||
/>
|
||||
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 mt-8"
|
||||
>
|
||||
<LessonContent :content="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20" ref="discussionsContainer">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
:key="lesson.data.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
@@ -193,14 +264,37 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Button, usePageMeta } from 'frappe-ui'
|
||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
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 UserAvatar from '@/components/UserAvatar.vue'
|
||||
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 { getEditorTools } from '../utils'
|
||||
import { getEditorTools, enablePlyr } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
@@ -215,6 +309,10 @@ const allowDiscussions = ref(false)
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
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 { brand } = sessionStore()
|
||||
let timerInterval
|
||||
@@ -236,11 +334,28 @@ const props = defineProps({
|
||||
|
||||
onMounted(() => {
|
||||
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({
|
||||
url: 'lms.lms.utils.get_lesson',
|
||||
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
@@ -249,36 +364,37 @@ const lesson = createResource({
|
||||
}
|
||||
},
|
||||
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) => {
|
||||
// empty the holder
|
||||
if (document.getElementById(holder))
|
||||
@@ -348,10 +464,18 @@ watch(
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
startTimer()
|
||||
enablePlyr()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lesson.data,
|
||||
(data) => {
|
||||
setupLesson(data)
|
||||
}
|
||||
)
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
@@ -367,13 +491,13 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const checkIfDiscussionsAllowed = () => {
|
||||
let quizPresent = false
|
||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||
if (block.type === 'quiz') quizPresent = true
|
||||
if (block.type === 'quiz') hasQuiz.value = true
|
||||
})
|
||||
|
||||
if (
|
||||
!quizPresent &&
|
||||
!hasQuiz.value &&
|
||||
!zenModeEnabled.value &&
|
||||
(lesson.data?.membership ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor)
|
||||
@@ -382,6 +506,7 @@ const checkIfDiscussionsAllowed = () => {
|
||||
}
|
||||
|
||||
const allowEdit = () => {
|
||||
if (window.read_only_mode) return false
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors?.includes(user.data?.name)) return true
|
||||
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 = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
@@ -590,4 +757,30 @@ usePageMeta(() => {
|
||||
.tc-table {
|
||||
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>
|
||||
|
||||
@@ -97,7 +97,7 @@ import { sessionStore } from '../stores/session'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonHelp from '@/components/LessonHelp.vue'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { createToast, getEditorTools } from '@/utils'
|
||||
import { createToast, getEditorTools, enablePlyr } from '@/utils'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
|
||||
@@ -133,6 +133,7 @@ onMounted(() => {
|
||||
editor.value = renderEditor('content')
|
||||
instructorEditor.value = renderEditor('instructor-notes')
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
enablePlyr()
|
||||
})
|
||||
|
||||
const renderEditor = (holder) => {
|
||||
@@ -141,6 +142,9 @@ const renderEditor = (holder) => {
|
||||
tools: getEditorTools(true),
|
||||
autofocus: true,
|
||||
defaultBlock: 'markdown',
|
||||
onChange: async (api, event) => {
|
||||
enablePlyr()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -624,8 +628,7 @@ usePageMeta(() => {
|
||||
}
|
||||
|
||||
iframe {
|
||||
border-top: 3px solid theme('colors.gray.700');
|
||||
border-bottom: 3px solid theme('colors.gray.700');
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.tc-table {
|
||||
@@ -639,4 +642,30 @@ iframe {
|
||||
.ce-popover-item[data-item-name='markdown'] {
|
||||
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>
|
||||
|
||||
198
frontend/src/pages/PersonaForm.vue
Normal file
198
frontend/src/pages/PersonaForm.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<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 space-y-5 w-full h-fit bg-white px-4 py-8 sm:mt-6 sm:w-96 sm:rounded-lg sm:px-8 sm:shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm text-gray-700 mb-2">
|
||||
{{ __('1. What best describes your role?') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="persona.role"
|
||||
type="select"
|
||||
:options="roleOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-700 mb-2">
|
||||
{{ __('2. How many students are you planning to teach?') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="persona.noOfStudents"
|
||||
type="select"
|
||||
:options="noOfStudentsOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-700 mb-2">
|
||||
{{ __('3. What is your main use case for Frappe Learning?') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="persona.useCase"
|
||||
type="select"
|
||||
:options="useCaseOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-700 mb-2">
|
||||
{{ __('4. Are you currently using any Frappe products?') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="persona.frappeProducts"
|
||||
type="select"
|
||||
:options="frappeProductsOptions"
|
||||
/>
|
||||
</div>
|
||||
</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({
|
||||
role: null,
|
||||
noOfStudents: null,
|
||||
useCase: null,
|
||||
frappeProducts: null,
|
||||
})
|
||||
|
||||
const submitPersona = () => {
|
||||
let responses = {
|
||||
site: user.data?.sitename,
|
||||
role: persona.role,
|
||||
no_of_students: persona.noOfStudents,
|
||||
use_case: persona.useCase,
|
||||
frappe_products: persona.frappeProducts,
|
||||
}
|
||||
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 roleOptions = computed(() => {
|
||||
const options = [
|
||||
'Trainer / Instructor',
|
||||
'Freelancer / Consultant',
|
||||
'HR / L&D Professional',
|
||||
'School / University Admin',
|
||||
'Software Developer',
|
||||
'Community Manager',
|
||||
'Business Owner / Team Lead',
|
||||
'Other',
|
||||
]
|
||||
|
||||
return options.map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
})
|
||||
|
||||
const frappeProductsOptions = computed(() => {
|
||||
const options = [
|
||||
'Frappe Framework',
|
||||
'ERPNext / Frappe HR',
|
||||
'Frappe CRM / Helpdesk',
|
||||
'Custom Frappe App',
|
||||
'Other',
|
||||
'Not using any Frappe product',
|
||||
]
|
||||
|
||||
return options.map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Persona',
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -25,7 +25,11 @@
|
||||
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
|
||||
>
|
||||
<template v-slot="{ togglePopover }">
|
||||
<Button variant="outline" @click="togglePopover()">
|
||||
<Button
|
||||
v-if="!readOnlyMode"
|
||||
variant="outline"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
@@ -58,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isSessionUser()"
|
||||
v-if="isSessionUser() && !readOnlyMode"
|
||||
class="mt-3 sm:mt-0 md:ml-auto"
|
||||
@click="editProfile()"
|
||||
>
|
||||
@@ -95,7 +99,7 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
@@ -109,6 +113,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('')
|
||||
const showProfileModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
username: {
|
||||
|
||||
@@ -4,134 +4,150 @@
|
||||
{{ __('My availability') }}
|
||||
</h2>
|
||||
|
||||
<div class="">
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
|
||||
>
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Start Time') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('End Time') }}
|
||||
</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
|
||||
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 availability when the site is being updated.'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="my-10">
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_from',
|
||||
value: from,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('To')"
|
||||
v-model="to"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_to',
|
||||
value: to,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div v-else>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
|
||||
>
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Start Time') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('End Time') }}
|
||||
</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>
|
||||
<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 class="my-10">
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
@blur="
|
||||
() => {
|
||||
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>
|
||||
<Button @click="() => authorizeCalendar.submit()">
|
||||
{{ __('Authorize Google Calendar Access') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<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 { 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 readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
{{ __('Settings') }}
|
||||
</h2>
|
||||
<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"
|
||||
>
|
||||
<FormControl
|
||||
@@ -37,11 +47,13 @@
|
||||
import { FormControl, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { showToast, convertToTitleCase } from '@/utils'
|
||||
import { CircleAlert } from 'lucide-vue-next'
|
||||
|
||||
const moderator = ref(false)
|
||||
const course_creator = ref(false)
|
||||
const batch_evaluator = ref(false)
|
||||
const lms_student = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadbrumbs" />
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
v-if="canCreateProgram()"
|
||||
@click="showDialog = true"
|
||||
variant="solid"
|
||||
>
|
||||
@@ -46,7 +46,7 @@
|
||||
params: { programName: program.name },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<Button v-if="!readOnlyMode">
|
||||
<template #prefix>
|
||||
<Edit class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -142,6 +142,7 @@ const showDialog = ref(false)
|
||||
const router = useRouter()
|
||||
const title = ref('')
|
||||
const settings = useSettings()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
@@ -208,9 +209,15 @@ const lockCourse = (course) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const canCreateProgram = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const breadbrumbs = computed(() => [
|
||||
{
|
||||
label: 'Programs',
|
||||
label: __('Programs'),
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<div v-if="!readOnlyMode" class="space-x-2">
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
:to="{
|
||||
@@ -116,7 +116,7 @@
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button @click="openQuestionModal()">
|
||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -223,6 +223,7 @@ const currentQuestion = reactive({
|
||||
})
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
v-if="!readOnlyMode"
|
||||
:to="{
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
@@ -89,6 +90,7 @@ import { sessionStore } from '@/stores/session'
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
|
||||
@@ -7,109 +7,115 @@
|
||||
</header>
|
||||
<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="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">
|
||||
<BookOpen class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.courses) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Courses') }}
|
||||
</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">
|
||||
<LogIn class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.users) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Signups') }}
|
||||
</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>
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: 'Courses', value: chartDetails.data.courses }"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: 'Signups', value: chartDetails.data.users }"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: 'Enrollments',
|
||||
value: chartDetails.data.enrollments,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: 'Completions',
|
||||
value: chartDetails.data.completions,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: 'Certifications',
|
||||
value: chartDetails.data.certifications,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
<div class="border rounded-md p-5 min-h-72">
|
||||
<Line
|
||||
<div class="border rounded-md min-h-72">
|
||||
<AxisChart
|
||||
v-if="signupsChart.data"
|
||||
:data="signupsChart.data"
|
||||
:options="signupChartOptions()"
|
||||
:config="{
|
||||
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 class="border rounded-md p-5 min-h-72">
|
||||
<Line
|
||||
<div class="border rounded-md min-h-72">
|
||||
<AxisChart
|
||||
v-if="enrollmentChart.data"
|
||||
:data="enrollmentChart.data"
|
||||
:options="enrollmentChartOptions()"
|
||||
:config="{
|
||||
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 class="border rounded-md p-5">
|
||||
<Line
|
||||
v-if="lessonCompletion.data"
|
||||
:data="lessonCompletion.data"
|
||||
:options="lessonChartOptions()"
|
||||
<div class="border rounded-md">
|
||||
<AxisChart
|
||||
v-if="certification.data"
|
||||
:config="{
|
||||
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 class="border rounded-md p-5">
|
||||
<Pie
|
||||
<div class="border rounded-md">
|
||||
<DonutChart
|
||||
v-if="courseCompletion.data"
|
||||
:data="courseCompletion.data"
|
||||
:options="courseChartOptions()"
|
||||
:config="{
|
||||
data: courseCompletion.data,
|
||||
title: 'Completions',
|
||||
subtitle: 'Course Completion',
|
||||
categoryColumn: 'label',
|
||||
valueColumn: 'value',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,42 +123,16 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, usePageMeta } from 'frappe-ui'
|
||||
import {
|
||||
AxisChart,
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
DonutChart,
|
||||
NumberChart,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
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()
|
||||
|
||||
@@ -175,11 +155,18 @@ const chartDetails = createResource({
|
||||
|
||||
const signupsChart = createResource({
|
||||
url: 'lms.lms.utils.get_chart_data',
|
||||
cache: ['signups'],
|
||||
params: {
|
||||
chart_name: 'New Signups',
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
date: new Date(item.date),
|
||||
signups: item.count,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const enrollmentChart = createResource({
|
||||
@@ -189,15 +176,31 @@ const enrollmentChart = createResource({
|
||||
chart_name: 'Course Enrollments',
|
||||
},
|
||||
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',
|
||||
cache: ['lessonCompletion'],
|
||||
cache: ['certifications'],
|
||||
params: {
|
||||
chart_name: 'Lesson Completion',
|
||||
chart_name: 'Certification',
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
date: new Date(item.date),
|
||||
certifications: item.count,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const courseCompletion = createResource({
|
||||
@@ -206,117 +209,6 @@ const courseCompletion = createResource({
|
||||
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(() => {
|
||||
return {
|
||||
title: __('Statistics'),
|
||||
|
||||
@@ -134,8 +134,8 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/job-opening/:jobName/edit',
|
||||
name: 'JobCreation',
|
||||
component: () => import('@/pages/JobCreation.vue'),
|
||||
name: 'JobForm',
|
||||
component: () => import('@/pages/JobForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -199,12 +199,6 @@ const routes = [
|
||||
name: 'Assignments',
|
||||
component: () => import('@/pages/Assignments.vue'),
|
||||
},
|
||||
{
|
||||
path: '/assignments/:assignmentID',
|
||||
name: 'AssignmentForm',
|
||||
component: () => import('@/pages/AssignmentForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/assignment-submission/:assignmentID/:submissionName',
|
||||
name: 'AssignmentSubmission',
|
||||
@@ -216,6 +210,11 @@ const routes = [
|
||||
name: 'AssignmentSubmissionList',
|
||||
component: () => import('@/pages/AssignmentSubmissionList.vue'),
|
||||
},
|
||||
{
|
||||
path: '/persona',
|
||||
name: 'PersonaForm',
|
||||
component: () => import('@/pages/PersonaForm.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
|
||||
@@ -15,6 +15,10 @@ import Embed from '@editorjs/embed'
|
||||
import SimpleImage from '@editorjs/simple-image'
|
||||
import Table from '@editorjs/table'
|
||||
import { usersStore } from '../stores/user'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
export function createToast(options) {
|
||||
toast({
|
||||
@@ -199,64 +203,24 @@ export function getEditorTools() {
|
||||
services: {
|
||||
youtube: {
|
||||
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
|
||||
embedUrl:
|
||||
'https://www.youtube.com/embed/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||
};" 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('&')
|
||||
},
|
||||
embedUrl: '<%= remote_id %>',
|
||||
/* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&iv_load_policy=3&modestbranding=1&playsinline=1&showinfo=0&rel=0&enablejsapi=1' */
|
||||
html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
|
||||
id: ([id]) => id,
|
||||
},
|
||||
vimeo: {
|
||||
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:
|
||||
'https://player.vimeo.com/video/<%= remote_id %>',
|
||||
'https://iframe.videodelivery.net/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||
};" frameborder="0" allowfullscreen></iframe>`,
|
||||
id: ([id]) => id,
|
||||
},
|
||||
codepen: true,
|
||||
aparat: {
|
||||
@@ -491,7 +455,7 @@ export function getSidebarLinks() {
|
||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||
},
|
||||
{
|
||||
label: 'Certified Participants',
|
||||
label: 'Certified Members',
|
||||
icon: 'GraduationCap',
|
||||
to: 'CertifiedParticipants',
|
||||
activeFor: ['CertifiedParticipants'],
|
||||
@@ -583,5 +547,35 @@ export const escapeHTML = (text) => {
|
||||
|
||||
export const canCreateCourse = () => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
allowedHosts: ['fs', 'onb2'],
|
||||
allowedHosts: ['fs', 'persona'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
||||
'engine.io-client',
|
||||
'tailwind.config.js',
|
||||
'highlight.js',
|
||||
'plyr',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
2812
frontend/yarn.lock
Normal file
2812
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,18 +9,19 @@
|
||||
"field_order": [
|
||||
"job_title",
|
||||
"location",
|
||||
"disabled",
|
||||
"country",
|
||||
"column_break_5",
|
||||
"type",
|
||||
"status",
|
||||
"disabled",
|
||||
"section_break_6",
|
||||
"description",
|
||||
"company_details_section",
|
||||
"company_name",
|
||||
"company_website",
|
||||
"column_break_11",
|
||||
"column_break_phkm",
|
||||
"company_logo",
|
||||
"company_email_address"
|
||||
"company_email_address",
|
||||
"company_details_section",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -36,7 +37,7 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Location",
|
||||
"label": "City",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -62,7 +63,8 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_6",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Company Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
@@ -72,8 +74,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "company_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Company Details"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "company_name",
|
||||
@@ -89,10 +90,6 @@
|
||||
"label": "Company Website",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "company_logo",
|
||||
"fieldtype": "Attach Image",
|
||||
@@ -111,13 +108,30 @@
|
||||
"label": "Company Email Address",
|
||||
"options": "Email",
|
||||
"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,
|
||||
"links": [],
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "LMS Job Application",
|
||||
"link_fieldname": "job"
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-01-17 12:38:57.134919",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-04-24 14:34:35.920242",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "Job",
|
||||
"name": "Job Opportunity",
|
||||
"owner": "Administrator",
|
||||
@@ -157,8 +171,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "job_title"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ from frappe.utils import (
|
||||
format_date,
|
||||
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 xml.dom.minidom import parseString
|
||||
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_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:
|
||||
user.site_info = current_site_info()
|
||||
user.sitename = frappe.local.site
|
||||
return user
|
||||
|
||||
|
||||
@@ -237,6 +240,11 @@ def validate_billing_access(billing_type, name):
|
||||
access = False
|
||||
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":
|
||||
purchased_certificate = frappe.db.exists(
|
||||
"LMS Enrollment",
|
||||
@@ -278,6 +286,7 @@ def get_job_details(job):
|
||||
[
|
||||
"job_title",
|
||||
"location",
|
||||
"country",
|
||||
"type",
|
||||
"company_name",
|
||||
"company_logo",
|
||||
@@ -303,14 +312,20 @@ def get_job_opportunities(filters=None, orFilters=None):
|
||||
fields=[
|
||||
"job_title",
|
||||
"location",
|
||||
"country",
|
||||
"type",
|
||||
"company_name",
|
||||
"company_logo",
|
||||
"name",
|
||||
"creation",
|
||||
"description",
|
||||
],
|
||||
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
|
||||
|
||||
|
||||
@@ -331,7 +346,7 @@ def get_chart_details():
|
||||
details.completions = frappe.db.count(
|
||||
"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
|
||||
|
||||
|
||||
@@ -411,29 +426,50 @@ def get_certified_participants(filters=None, start=0, page_length=30):
|
||||
or_filters["course_title"] = ["like", f"%{category}%"]
|
||||
or_filters["batch_title"] = ["like", f"%{category}%"]
|
||||
|
||||
participants = frappe.get_all(
|
||||
participants = frappe.db.get_all(
|
||||
"LMS Certificate",
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
fields=["member"],
|
||||
fields=["member", "issue_date"],
|
||||
group_by="member",
|
||||
order_by="creation desc",
|
||||
order_by="issue_date desc",
|
||||
start=start,
|
||||
page_length=page_length,
|
||||
)
|
||||
|
||||
for participant in participants:
|
||||
count = frappe.db.count("LMS Certificate", {"member": participant.member})
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
participant.member,
|
||||
["full_name", "user_image", "username", "country", "headline"],
|
||||
as_dict=1,
|
||||
)
|
||||
details["certificate_count"] = count
|
||||
participant.update(details)
|
||||
|
||||
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)
|
||||
def get_certification_categories():
|
||||
categories = []
|
||||
@@ -655,13 +691,13 @@ def get_categories(doctype, filters):
|
||||
@frappe.whitelist()
|
||||
def get_members(start=0, search=""):
|
||||
"""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
|
||||
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
|
||||
Returns: List of members.
|
||||
Returns: List of members.
|
||||
"""
|
||||
|
||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||
@@ -1366,3 +1402,17 @@ def add_an_evaluator(email):
|
||||
evaluator.insert()
|
||||
|
||||
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
|
||||
|
||||
31
lms/lms/dashboard_chart/certification/certification.json
Normal file
31
lms/lms/dashboard_chart/certification/certification.json
Normal 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": []
|
||||
}
|
||||
@@ -9,14 +9,14 @@
|
||||
"doctype": "Dashboard Chart",
|
||||
"document_type": "User",
|
||||
"dynamic_filters_json": "[]",
|
||||
"filters_json": "[]",
|
||||
"filters_json": "[[\"User\",\"enabled\",\"=\",1,false]]",
|
||||
"group_by_type": "Count",
|
||||
"idx": 1,
|
||||
"idx": 5,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"last_synced_on": "2022-10-20 10:46:56.849265",
|
||||
"modified": "2022-10-20 11:31:17.184897",
|
||||
"modified_by": "Administrator",
|
||||
"last_synced_on": "2025-04-28 15:09:52.161688",
|
||||
"modified": "2025-04-28 17:47:58.168293",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "New Signups",
|
||||
"number_of_groups": 0,
|
||||
@@ -30,4 +30,4 @@
|
||||
"use_report_chart": 0,
|
||||
"value_based_on": "",
|
||||
"y_axis": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import validate_url, validate_email_address
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
from frappe.utils import validate_url
|
||||
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||
|
||||
|
||||
@@ -15,14 +14,6 @@ class LMSAssignmentSubmission(Document):
|
||||
self.validate_url()
|
||||
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):
|
||||
if frappe.db.exists(
|
||||
"LMS Assignment Submission",
|
||||
@@ -39,38 +30,6 @@ class LMSAssignmentSubmission(Document):
|
||||
if self.type == "URL" and not validate_url(self.answer):
|
||||
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):
|
||||
if not self.is_new():
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"fetch_from": "member.username",
|
||||
"fieldname": "member_username",
|
||||
"fieldtype": "Data",
|
||||
"label": "Memeber Username",
|
||||
"label": "Member Username",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -145,10 +145,11 @@
|
||||
"options": "LMS Certificate"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-02-21 17:11:37.986157",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-04-25 10:06:25.824119",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Enrollment",
|
||||
"owner": "Administrator",
|
||||
@@ -192,10 +193,11 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "member_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"general_tab",
|
||||
"default_home",
|
||||
"send_calendar_invite_for_evaluations",
|
||||
"persona_captured",
|
||||
"column_break_zdel",
|
||||
"allow_guest_access",
|
||||
"enable_learning_paths",
|
||||
@@ -58,10 +59,12 @@
|
||||
"certification_template",
|
||||
"batch_confirmation_template",
|
||||
"column_break_uwsp",
|
||||
"assignment_submission_template",
|
||||
"payment_reminder_template",
|
||||
"seo_tab",
|
||||
"meta_description"
|
||||
"meta_description",
|
||||
"meta_image",
|
||||
"column_break_xijv",
|
||||
"meta_keywords"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -106,7 +109,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "user_category",
|
||||
"fieldtype": "Check",
|
||||
"label": "Identify User Persona"
|
||||
"label": "Identify User Category"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -238,12 +241,6 @@
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Email Templates"
|
||||
},
|
||||
{
|
||||
"fieldname": "assignment_submission_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Assignment Submission Template",
|
||||
"options": "Email Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uwsp",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -377,13 +374,36 @@
|
||||
"fieldname": "meta_description",
|
||||
"fieldtype": "Small Text",
|
||||
"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,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-04-10 16:17:00.658698",
|
||||
"modified": "2025-04-22 16:05:27.914422",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
|
||||
@@ -181,6 +181,7 @@ def get_lesson_icon(body, content):
|
||||
if block.get("type") == "embed" and block.get("data").get("service") in [
|
||||
"youtube",
|
||||
"vimeo",
|
||||
"cloudflareStream",
|
||||
]:
|
||||
return "icon-youtube"
|
||||
|
||||
@@ -205,10 +206,13 @@ def get_tags(course):
|
||||
return tags.split(",") if tags else []
|
||||
|
||||
|
||||
def get_instructors(course):
|
||||
def get_instructors(doctype, docname):
|
||||
instructor_details = []
|
||||
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:
|
||||
@@ -417,10 +421,11 @@ def get_initial_members(course):
|
||||
|
||||
|
||||
def is_instructor(course):
|
||||
return (
|
||||
len(list(filter(lambda x: x.name == frappe.session.user, get_instructors(course))))
|
||||
> 0
|
||||
)
|
||||
instructors = get_instructors("LMS Course", course)
|
||||
for instructor in instructors:
|
||||
if instructor.name == frappe.session.user:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
"labels": [
|
||||
format_date(get_period(r[0], timegrain), parse_day_first=True)
|
||||
if timegrain in ("Daily", "Weekly")
|
||||
else get_period(r[0], timegrain)
|
||||
for r in result
|
||||
],
|
||||
"datasets": [{"name": chart.name, "data": [r[1] for r in result]}],
|
||||
}
|
||||
data = []
|
||||
for row in result:
|
||||
data.append(
|
||||
{
|
||||
"date": row[0],
|
||||
"count": row[1],
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@@ -805,15 +809,10 @@ def get_course_completion_data():
|
||||
all_membership = frappe.db.count("LMS Enrollment")
|
||||
completed = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]})
|
||||
|
||||
return {
|
||||
"labels": ["Completed", "In Progress"],
|
||||
"datasets": [
|
||||
{
|
||||
"name": "Course Completion",
|
||||
"data": [completed, all_membership - completed],
|
||||
}
|
||||
],
|
||||
}
|
||||
return [
|
||||
{"label": "Completed", "value": completed},
|
||||
{"label": "In Progress", "value": all_membership - completed},
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
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:
|
||||
course.amount, course.currency = check_multicurrency(
|
||||
@@ -1156,7 +1155,7 @@ def get_course_details(course):
|
||||
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)
|
||||
if course_details.paid_course or course_details.paid_certificate:
|
||||
"""course_details.course_price, course_details.currency = check_multicurrency(
|
||||
@@ -1272,7 +1271,10 @@ def get_lesson(course, chapter, lesson):
|
||||
|
||||
membership = get_membership(course)
|
||||
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 (
|
||||
@@ -1285,6 +1287,7 @@ def get_lesson(course, chapter, lesson):
|
||||
"no_preview": 1,
|
||||
"title": lesson_details.title,
|
||||
"course_title": course_info.title,
|
||||
"disable_self_learning": course_info.disable_self_learning,
|
||||
}
|
||||
|
||||
lesson_details = frappe.db.get_value(
|
||||
@@ -1313,15 +1316,19 @@ def get_lesson(course, chapter, lesson):
|
||||
else:
|
||||
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)
|
||||
neighbours = get_neighbour_lesson(course, chapter, lesson)
|
||||
lesson_details.next = neighbours["next"]
|
||||
lesson_details.progress = progress
|
||||
lesson_details.prev = neighbours["prev"]
|
||||
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.paid_certificate = course_info.paid_certificate
|
||||
lesson_details.disable_self_learning = course_info.disable_self_learning
|
||||
return lesson_details
|
||||
|
||||
|
||||
@@ -1385,7 +1392,7 @@ def get_batch_details(batch):
|
||||
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()
|
||||
|
||||
if (
|
||||
@@ -2132,7 +2139,7 @@ def get_batch_type(filters):
|
||||
|
||||
def get_batch_card_details(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})
|
||||
|
||||
if batch.seat_count:
|
||||
@@ -2167,3 +2174,7 @@ def get_palette(full_name):
|
||||
hash_name = hashlib.md5(encoded_name).hexdigest()
|
||||
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
|
||||
return palette[idx % 8]
|
||||
|
||||
|
||||
def persona_captured():
|
||||
frappe.db.set_single_value("LMS Settings", "persona_captured", 1)
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="course-card-footer">
|
||||
|
||||
<div class="course-card-instructors">
|
||||
{% set instructors = get_instructors(course.name) %}
|
||||
{% set instructors = get_instructors("LMS Course", course.name) %}
|
||||
{% set ins_len = instructors | length %}
|
||||
{% for instructor in instructors %}
|
||||
{% if ins_len > 1 and loop.index == 1 %}
|
||||
|
||||
609
lms/locale/ar.po
609
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
619
lms/locale/bs.po
619
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
613
lms/locale/de.po
613
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
619
lms/locale/eo.po
619
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
613
lms/locale/es.po
613
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
621
lms/locale/fa.po
621
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
609
lms/locale/fr.po
609
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
607
lms/locale/hr.po
607
lms/locale/hr.po
File diff suppressed because it is too large
Load Diff
609
lms/locale/hu.po
609
lms/locale/hu.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
609
lms/locale/pl.po
609
lms/locale/pl.po
File diff suppressed because it is too large
Load Diff
605
lms/locale/pt.po
605
lms/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
605
lms/locale/ru.po
605
lms/locale/ru.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
619
lms/locale/sv.po
619
lms/locale/sv.po
File diff suppressed because it is too large
Load Diff
607
lms/locale/th.po
607
lms/locale/th.po
File diff suppressed because it is too large
Load Diff
613
lms/locale/tr.po
613
lms/locale/tr.po
File diff suppressed because it is too large
Load Diff
667
lms/locale/zh.po
667
lms/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -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.delete_old_enrollment_doctypes
|
||||
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
|
||||
19
lms/patches/v2_0/update_course_evaluator_data.py
Normal file
19
lms/patches/v2_0/update_course_evaluator_data.py
Normal 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,
|
||||
},
|
||||
)
|
||||
28
lms/patches/v2_0/update_job_city_and_country.py
Normal file
28
lms/patches/v2_0/update_job_city_and_country.py
Normal 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)
|
||||
@@ -1621,30 +1621,6 @@ pre {
|
||||
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 {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@@ -8,32 +8,44 @@ no_cache = 1
|
||||
|
||||
|
||||
def get_context():
|
||||
context = frappe._dict()
|
||||
context.boot = get_boot()
|
||||
frappe.db.commit()
|
||||
|
||||
app_path = frappe.form_dict.get("app_path")
|
||||
favicon = (
|
||||
frappe.db.get_single_value("Website Settings", "favicon")
|
||||
or "/assets/lms/frontend/favicon.png"
|
||||
)
|
||||
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.csrf_token = csrf_token
|
||||
context.meta = get_meta(app_path, title, favicon, description)
|
||||
capture("active_site", "lms")
|
||||
context.meta = get_meta(app_path, title, favicon)
|
||||
context.title = title
|
||||
context.favicon = favicon
|
||||
|
||||
capture("active_site", "lms")
|
||||
return context
|
||||
|
||||
|
||||
def get_meta(app_path, title, favicon, description):
|
||||
meta = frappe._dict()
|
||||
def get_boot():
|
||||
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:
|
||||
meta = get_meta_from_document(app_path)
|
||||
|
||||
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:
|
||||
for row in route_meta:
|
||||
@@ -55,10 +67,9 @@ def get_meta(app_path, title, favicon, description):
|
||||
meta["description"] = description
|
||||
|
||||
if not meta.get("image"):
|
||||
meta["image"] = favicon
|
||||
meta["image"] = image or favicon
|
||||
|
||||
if not meta.get("keywords"):
|
||||
meta["keywords"] = ""
|
||||
meta["keywords"] = f"{meta.get('keywords')}, {keywords}"
|
||||
|
||||
if not meta:
|
||||
meta = {
|
||||
@@ -286,4 +297,11 @@ def get_meta_from_document(app_path):
|
||||
"link": "/programs",
|
||||
}
|
||||
|
||||
if app_path == "certified-participants":
|
||||
return {
|
||||
"title": _("Certified Participants"),
|
||||
"keywords": "All Certified Participants, Certified Participants, Learn, Certification",
|
||||
"link": "/certified-participants",
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user