mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
chore: resolved conflicts
This commit is contained in:
@@ -71,6 +71,9 @@ jobs:
|
||||
- name: setup requirements
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench setup requirements --dev
|
||||
- name: block endpoints
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local set-config block_endpoints 1
|
||||
- name: allow tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local set-config allow_tests true
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"branches": ["develop"],
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer", {
|
||||
"preset": "angular"
|
||||
|
||||
@@ -11,7 +11,6 @@ describe("Course Creation", () => {
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Course").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
@@ -35,21 +34,6 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get("label")
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
@@ -69,13 +53,32 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Create").last().click();
|
||||
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get("label")
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.button("Add Chapter").click();
|
||||
cy.button("Add").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get("[data-dismissable-layer]")
|
||||
|
||||
Vendored
+1
-1
@@ -54,7 +54,6 @@ declare module 'vue' {
|
||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||
@@ -94,6 +93,7 @@ declare module 'vue' {
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||
NumberChartGraph: typeof import('./src/components/NumberChartGraph.vue')['default']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
|
||||
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"@editorjs/paragraph": "2.11.3",
|
||||
"@editorjs/simple-image": "1.6.0",
|
||||
"@editorjs/table": "2.4.2",
|
||||
"@vueuse/core": "10.4.1",
|
||||
"@vueuse/router": "12.7.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"ace-builds": "1.36.2",
|
||||
"apexcharts": "4.3.0",
|
||||
"chart.js": "4.4.1",
|
||||
@@ -34,7 +33,7 @@
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.2.6",
|
||||
"feather-icons": "4.28.0",
|
||||
"frappe-ui": "^0.1.256",
|
||||
"frappe-ui": "^0.1.261",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
@@ -43,11 +42,11 @@
|
||||
"socket.io-client": "4.7.2",
|
||||
"thememirror": "2.0.1",
|
||||
"typescript": "5.7.2",
|
||||
"vue": "^3.5.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-chartjs": "5.3.0",
|
||||
"vue-codemirror": "6.1.1",
|
||||
"vue-draggable-next": "2.2.1",
|
||||
"vue-router": "4.2.2",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue3-apexcharts": "1.8.0",
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
<script setup>
|
||||
import { FrappeUIProvider } from 'frappe-ui'
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
@@ -23,7 +22,6 @@ import InstallPrompt from './components/InstallPrompt.vue'
|
||||
const { isMobile } = useScreenSize()
|
||||
const router = useRouter()
|
||||
const noSidebar = ref(false)
|
||||
const { userResource } = usersStore()
|
||||
const { settings } = useSettings()
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,152 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
@@ -26,8 +26,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-5 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||
submissionResource.doc?.owner == user.data?.name
|
||||
"
|
||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
|
||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm"
|
||||
>
|
||||
{{ __("You've successfully submitted the assignment.") }}
|
||||
{{
|
||||
@@ -63,12 +63,17 @@
|
||||
}}
|
||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||
</div>
|
||||
<div v-if="showUploader()">
|
||||
<div class="text-xs text-ink-gray-5 mt-1 mb-2">
|
||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||
<div v-if="showUploader()" class="border rounded-lg p-3">
|
||||
<div class="font-semibold mb-2">
|
||||
{{ __('Upload Assignment') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-5 text-sm mt-1 mb-4">
|
||||
{{
|
||||
__('You can only upload {0} files').format(assignment.data.type)
|
||||
}}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionFile"
|
||||
v-if="!submissionResource.doc?.assignment_attachment"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
@@ -87,21 +92,24 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex text-ink-gray-7">
|
||||
<div class="border self-start rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<a
|
||||
:href="submissionFile.file_url"
|
||||
:href="submissionResource.doc.assignment_attachment"
|
||||
target="_blank"
|
||||
class="flex flex-col cursor-pointer !no-underline"
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
<span class="text-sm leading-5">
|
||||
{{ submissionFile.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-5 mt-1">
|
||||
{{ getFileSize(submissionFile.file_size) }}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
{{
|
||||
submissionResource.doc.assignment_attachment
|
||||
.split('/')
|
||||
.pop()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<X
|
||||
v-if="canModifyAssignment"
|
||||
@@ -142,13 +150,13 @@
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 bg-surface-blue-2 rounded-md"
|
||||
class="mt-8 p-3 border rounded-lg"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-5 font-medium mb-2">
|
||||
{{ __('Comments by Evaluator') }}:
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Comments by Evaluator') }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-ink-gray-9"
|
||||
class="leading-6 text-ink-gray-9"
|
||||
v-html="submissionResource.doc.comments"
|
||||
></div>
|
||||
</div>
|
||||
@@ -204,10 +212,8 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const submissionFile = ref(null)
|
||||
const answer = ref(null)
|
||||
const comments = ref(null)
|
||||
const router = useRouter()
|
||||
@@ -266,9 +272,7 @@ const newSubmission = createResource({
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (showUploader()) {
|
||||
doc.assignment_attachment = submissionFile.value.file_url
|
||||
} else {
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
@@ -277,19 +281,6 @@ const newSubmission = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
submissionFile.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
@@ -302,11 +293,6 @@ const submissionResource = createDocumentResource({
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
imageResource.reload({
|
||||
image: submissionResource.doc.assignment_attachment,
|
||||
})
|
||||
}
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
@@ -315,7 +301,10 @@ watch(submissionResource, () => {
|
||||
}
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (showUploader() && !submissionFile.value) {
|
||||
} else if (
|
||||
showUploader() &&
|
||||
!submissionResource.doc.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
@@ -325,11 +314,17 @@ watch(submissionResource, () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(submissionFile, () => {
|
||||
if (props.submissionName == 'new' && submissionFile.value) {
|
||||
isDirty.value = true
|
||||
watch(
|
||||
() => submissionResource.doc,
|
||||
() => {
|
||||
if (
|
||||
props.submissionName == 'new' &&
|
||||
submissionResource.doc?.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
@@ -341,13 +336,13 @@ const submitAssignment = () => {
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
assignment_attachment: submissionFile.value?.file_url,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
}
|
||||
@@ -388,7 +383,7 @@ const addNewSubmission = () => {
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
isDirty.value = true
|
||||
submissionFile.value = file
|
||||
submissionResource.doc.assignment_attachment = file.file_url
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
@@ -439,7 +434,7 @@ const validateFile = (file) => {
|
||||
|
||||
const removeSubmission = () => {
|
||||
isDirty.value = true
|
||||
submissionFile.value = null
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||
<div
|
||||
v-if="batch.data.seat_count && seats_left > 0"
|
||||
v-if="batch.data.seat_count && batch.data.seats_left > 0"
|
||||
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
@@ -9,16 +9,16 @@
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
>
|
||||
{{ seats_left }}
|
||||
<span v-if="seats_left > 1">
|
||||
{{ batch.data.seats_left }}
|
||||
<span v-if="batch.data.seats_left > 1">
|
||||
{{ __('Seats Left') }}
|
||||
</span>
|
||||
<span v-else-if="seats_left == 1">
|
||||
<span v-else-if="batch.data.seats_left == 1">
|
||||
{{ __('Seat Left') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
|
||||
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
@@ -54,6 +54,7 @@
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="canAccessBatch"
|
||||
@@ -190,15 +191,10 @@ const enrollInBatch = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const seats_left = computed(() => {
|
||||
if (props.batch.data?.seat_count) {
|
||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return props.batch.data?.students?.includes(user.data?.name)
|
||||
return user.data
|
||||
? props.batch.data?.students?.includes(user.data?.name)
|
||||
: false
|
||||
})
|
||||
|
||||
const isModerator = computed(() => {
|
||||
@@ -218,6 +214,9 @@ const isInstructor = computed(() => {
|
||||
})
|
||||
|
||||
const canAccessBatch = computed(() => {
|
||||
if (!user.data) {
|
||||
return false
|
||||
}
|
||||
return isModerator.value || isStudent.value || isEvaluator.value
|
||||
})
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in students.data"
|
||||
class="group cursor-pointer"
|
||||
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
|
||||
@click="openStudentProgressModal(row)"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
<div class="mt-4" v-if="students.hasNextPage">
|
||||
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
@@ -170,7 +170,7 @@ const studentColumns = [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: '20rem',
|
||||
width: '25rem',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,9 +19,16 @@
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
@click="
|
||||
(e) => {
|
||||
showOptions = true
|
||||
nextTick(() => {
|
||||
setFocus()
|
||||
})
|
||||
}
|
||||
"
|
||||
@focus="
|
||||
() => {
|
||||
showOptions = true
|
||||
if (!filterOptions.data || filterOptions.data.length === 0) {
|
||||
reload('')
|
||||
}
|
||||
@@ -33,10 +40,10 @@
|
||||
<template #body="{ isOpen, close }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
static
|
||||
>
|
||||
@@ -55,7 +62,11 @@
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ option.description }}
|
||||
{{
|
||||
option.value == option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
@@ -66,22 +77,19 @@
|
||||
<div v-else class="text-ink-gray-7 px-4">
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
<div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -115,7 +123,7 @@ import {
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { set, watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -149,18 +157,20 @@ const props = defineProps({
|
||||
|
||||
const values = defineModel()
|
||||
const attrs = useAttrs()
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
val?.value && addValue(val.value)
|
||||
showOptions.value = false
|
||||
emit('update:modelValue', values.value)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -232,6 +242,7 @@ const addValue = (value) => {
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
emit('update:modelValue', values.value)
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
|
||||
@@ -34,7 +34,12 @@
|
||||
<img
|
||||
v-if="type == 'image'"
|
||||
:src="modelValue"
|
||||
class="border rounded-md w-44 h-auto"
|
||||
:class="[
|
||||
'border object-cover',
|
||||
shape === 'circle'
|
||||
? 'w-20 h-20 rounded-full'
|
||||
: 'w-44 h-auto min-h-20 rounded-md',
|
||||
]"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
<source :src="modelValue" />
|
||||
@@ -67,11 +72,12 @@ const emit = defineEmits<{
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
modelValue: string | null
|
||||
label?: string
|
||||
description?: string
|
||||
type?: 'image' | 'video'
|
||||
required?: boolean
|
||||
shape?: 'square' | 'circle'
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
@@ -79,6 +85,7 @@ const props = withDefaults(
|
||||
description: '',
|
||||
type: 'image',
|
||||
required: true,
|
||||
shape: 'square',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<CertificationLinks :courseName="course.data.name" class="w-full" />
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
v-else-if="course.data.paid_course && !isAdmin"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
@@ -56,14 +56,15 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
<Badge
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
v-else-if="course.data.disable_self_learning && !isAdmin"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
{{ __('Contact the Administrator to enroll for this course') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||
v-else-if="!isAdmin"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@@ -88,40 +89,11 @@
|
||||
</template>
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
@click="showProgressSummary"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
{{ __('Progress Summary') }}
|
||||
</template>
|
||||
</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">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': !readOnlyMode }"
|
||||
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
|
||||
>
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
@@ -168,12 +140,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseProgressSummary
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
v-model="showProgressModal"
|
||||
:courseName="course.data.name"
|
||||
:enrollments="course.data.enrollments"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -191,12 +157,10 @@ import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils/'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showProgressModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
@@ -216,7 +180,7 @@ const video_link = computed(() => {
|
||||
|
||||
function enrollStudent() {
|
||||
if (!user.data) {
|
||||
toast.success(__('You need to login first to enroll for this course'))
|
||||
toast.warning(__('You need to login first to enroll for this course'))
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 500)
|
||||
@@ -295,7 +259,7 @@ const fetchCertificate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const showProgressSummary = () => {
|
||||
showProgressModal.value = true
|
||||
}
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || is_instructor()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
{{ __('Add Chapter') }}
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -174,6 +177,7 @@ import {
|
||||
FilePenLine,
|
||||
HelpCircle,
|
||||
MonitorPlay,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -107,7 +107,11 @@
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
|
||||
@@ -23,10 +23,8 @@
|
||||
(value, close) => {
|
||||
close()
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
name: 'Courses',
|
||||
query: { newCourse: '1' },
|
||||
})
|
||||
}
|
||||
"
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Course Progress Summary'),
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div
|
||||
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-[70vh] overflow-y-auto">
|
||||
<ListView
|
||||
v-if="progressList.loading || progressList.data?.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in progressColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
:name="item.icon?.toString()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="progressList.data && progressList.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="progressList.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 self-start w-full space-y-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
|
||||
>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: memberCount || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: chartDetails.data?.average_progress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
getColor('red', 400),
|
||||
getColor('amber', 400),
|
||||
getColor('pink', 400),
|
||||
getColor('blue', 400),
|
||||
getColor('green', 400),
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getColor } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
courseName?: string
|
||||
enrollments?: number
|
||||
}>()
|
||||
|
||||
const memberCount = ref<number>(props.enrollments || 0)
|
||||
|
||||
const chartDetails = createResource({
|
||||
url: 'lms.lms.api.get_course_progress_distribution',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
course: props.courseName,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'progress',
|
||||
],
|
||||
pageLength: 50,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.courseName,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
filters: filters,
|
||||
})
|
||||
progressList.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data: any[]) {
|
||||
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '60%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="flex items-center mb-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Edit Profile') }}
|
||||
</div>
|
||||
<Badge v-if="isDirty" class="ml-4" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<div class="space-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveProfile()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
@@ -19,52 +27,13 @@
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="profile.image?.file_url"
|
||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="profile.image"
|
||||
:label="__('Profile Image')"
|
||||
:required="true"
|
||||
shape="circle"
|
||||
/>
|
||||
|
||||
<div class="text-base flex flex-col ml-2">
|
||||
<span>
|
||||
{{ profile.image?.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image?.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
@@ -90,7 +59,7 @@
|
||||
<FormControl
|
||||
v-model="profile.open_to"
|
||||
type="select"
|
||||
:options="[' ', 'Opportunities', 'Hiring']"
|
||||
:options="[' ', 'Work', 'Hiring']"
|
||||
:label="__('Open to')"
|
||||
:placeholder="__('Looking for new work or hiring talent?')"
|
||||
/>
|
||||
@@ -115,13 +84,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveProfile(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -131,15 +93,14 @@ import {
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { getFileSize, sanitizeHTML } from '@/utils'
|
||||
import { sanitizeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
const hasLanguageChanged = ref(false)
|
||||
const isDirty = ref(false)
|
||||
@@ -163,19 +124,6 @@ const profile = reactive({
|
||||
twitter: '',
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
profile.image = data
|
||||
},
|
||||
})
|
||||
|
||||
const updateProfile = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
@@ -183,7 +131,7 @@ const updateProfile = createResource({
|
||||
doctype: 'User',
|
||||
name: props.profile.data.name,
|
||||
fieldname: {
|
||||
user_image: profile.image?.file_url || null,
|
||||
user_image: profile.image || null,
|
||||
...profile,
|
||||
},
|
||||
}
|
||||
@@ -193,13 +141,13 @@ const updateProfile = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
const saveProfile = () => {
|
||||
profile.bio = sanitizeHTML(profile.bio)
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
show.value = false
|
||||
reloadProfile.value.reload()
|
||||
if (hasLanguageChanged.value) {
|
||||
hasLanguageChanged.value = false
|
||||
@@ -213,21 +161,6 @@ const saveProfile = (close) => {
|
||||
)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
profile.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
profile.image = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => profile,
|
||||
(newVal) => {
|
||||
@@ -240,7 +173,7 @@ watch(
|
||||
return
|
||||
}
|
||||
}
|
||||
if (profile.image?.file_url !== props.profile.data.user_image) {
|
||||
if (profile.image !== props.profile.data.user_image) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
@@ -262,7 +195,7 @@ watch(
|
||||
profile.linkedin = newVal.linkedin
|
||||
profile.github = newVal.github
|
||||
profile.twitter = newVal.twitter
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
profile.image = newVal.user_image
|
||||
isDirty.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('course', event.course)"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.course_title }}
|
||||
@@ -30,7 +33,10 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('batch', event.batch_name)"
|
||||
>
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.batch_title }}
|
||||
@@ -334,7 +340,7 @@ const certificateDetails = createResource({
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
certificate.template = defaultTemplate.data.value
|
||||
certificate.template = defaultTemplate.data?.value
|
||||
},
|
||||
auto: false,
|
||||
})
|
||||
@@ -377,6 +383,16 @@ const openCertificate = (certificate) => {
|
||||
)
|
||||
}
|
||||
|
||||
const openLink = (type, name) => {
|
||||
let url = ''
|
||||
if (type === 'course') {
|
||||
url = `/lms/courses/${name}`
|
||||
} else if (type === 'batch') {
|
||||
url = `/lms/batches/${name}#students`
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="border rounded-lg p-3 space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-2xl">
|
||||
{{ value }}
|
||||
</div>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
value: number | string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<Tooltip :text="`${props.progress}%`">
|
||||
<div class="w-full bg-surface-gray-3 rounded-full h-1">
|
||||
<div
|
||||
class="w-full bg-surface-gray-3 rounded-full h-1"
|
||||
:class="$attrs.class"
|
||||
>
|
||||
<div
|
||||
class="bg-surface-gray-7 rounded-full"
|
||||
:class="progressBarHeight"
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
:loading="saveSettings.loading"
|
||||
@click="update"
|
||||
>
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
@@ -19,11 +28,6 @@
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@@ -186,8 +186,9 @@ const openProfile = (username: string) => {
|
||||
}
|
||||
|
||||
const deleteEvaluator = (evaluator: string) => {
|
||||
call('lms.lms.api.delete_evaluator', {
|
||||
evaluator: evaluator,
|
||||
call('frappe.client.delete', {
|
||||
doctype: 'Course Evaluator',
|
||||
name: evaluator,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Evaluator deleted successfully'))
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
gatewayID === 'new'
|
||||
? __('New Payment Gateway')
|
||||
: __('Edit Payment Gateway'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="text-lg font-semibold">
|
||||
{{
|
||||
gatewayID === 'new'
|
||||
? __('New Payment Gateway')
|
||||
: __('Edit Payment Gateway')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<SettingFields
|
||||
v-if="gatewayID != 'new' && paymentGateway.data"
|
||||
:fields="paymentGateway.data.fields"
|
||||
:sections="paymentGateway.data.sections"
|
||||
:data="paymentGateway.data.data"
|
||||
class="pt-5 my-0"
|
||||
/>
|
||||
<div v-else>
|
||||
<div v-else class="mt-5">
|
||||
<FormControl
|
||||
v-model="newGateway"
|
||||
:label="__('Select Payment Gateway')"
|
||||
@@ -26,9 +30,8 @@
|
||||
/>
|
||||
<SettingFields
|
||||
v-if="newGateway"
|
||||
:fields="newGatewayFields"
|
||||
:sections="newGatewayFields"
|
||||
:data="newGatewayData"
|
||||
class="pt-5 my-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,7 +59,7 @@ import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const paymentGateways = defineModel<any>('paymentGateways')
|
||||
const newGateway = ref(null)
|
||||
const newGatewayFields = ref([])
|
||||
const newGatewayFields = ref<{ columns: { fields: any[] }[] }[]>([])
|
||||
const newGatewayData = ref<Record<string, any>>({})
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -72,6 +75,7 @@ const paymentGateway = createResource({
|
||||
},
|
||||
transform(data: any) {
|
||||
arrangeFields(data.fields)
|
||||
data.sections = makeSections(data.fields)
|
||||
return data
|
||||
},
|
||||
})
|
||||
@@ -102,10 +106,6 @@ const arrangeFields = (fields: any[]) => {
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
fields.splice(3, 0, {
|
||||
type: 'Column Break',
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -130,7 +130,7 @@ watch(newGateway, () => {
|
||||
gatewayFields.reload({ doctype: gatewayDoc.name }).then(() => {
|
||||
let fields = gatewayFields.data || []
|
||||
arrangeFields(fields)
|
||||
newGatewayFields.value = fields
|
||||
newGatewayFields.value = makeSections(fields)
|
||||
prepareGatewayData()
|
||||
})
|
||||
})
|
||||
@@ -192,19 +192,6 @@ const getGatewayFields = () => {
|
||||
}, {})
|
||||
}
|
||||
|
||||
const createGatewayRecord = (gatewayDoc: any, data: any = {}) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Payment Gateway',
|
||||
gateway: newGateway.value,
|
||||
gateway_controller: gatewayDoc.issingle ? '' : gatewayDoc.name,
|
||||
gateway_settings: gatewayDoc.issingle ? '' : data.name,
|
||||
},
|
||||
}).then(() => {
|
||||
paymentGateways.value?.reload()
|
||||
})
|
||||
}
|
||||
|
||||
const allGatewayOptions = computed(() => {
|
||||
let options: string[] = []
|
||||
let gatewayList = allGateways.data?.map((gateway: any) => gateway.name) || []
|
||||
@@ -230,4 +217,20 @@ const prepareGatewayData = () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const makeSections = (fields: any[]) => {
|
||||
const columnCount = fields.length / 3
|
||||
let sections: { columns: { fields: any[] }[] }[] = [
|
||||
{
|
||||
columns: [],
|
||||
},
|
||||
]
|
||||
|
||||
for (let i = 0; i < columnCount; i++) {
|
||||
sections[0].columns.push({
|
||||
fields: fields.slice(i * 3, i * 3 + 3),
|
||||
})
|
||||
}
|
||||
return sections
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,22 +8,24 @@
|
||||
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
</h1>
|
||||
<div v-for="tab in tabs" :key="tab.label">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<div v-for="item in tab.items" @click="activeTab = item">
|
||||
<SidebarLink
|
||||
:link="item"
|
||||
:key="item.label"
|
||||
:activeTab="activeTab?.label"
|
||||
/>
|
||||
<div class="space-y-5">
|
||||
<div v-for="tab in tabs" :key="tab.label">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="space-y-1">
|
||||
<div v-for="item in tab.items" @click="activeTab = item">
|
||||
<SidebarLink
|
||||
:link="item"
|
||||
:key="item.label"
|
||||
:activeTab="activeTab?.label"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-base">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ __('Transaction Details') }}
|
||||
<div class="flex items-center justify-between mb-10 -ml-1.5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ __('Transaction Details') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
v-if="
|
||||
transactionData?.payment_for_document_type &&
|
||||
transactionData?.payment_for_document
|
||||
"
|
||||
@click="openDetails()"
|
||||
>
|
||||
{{ __('Open the ') }}
|
||||
{{
|
||||
transactionData.payment_for_document_type == 'LMS Course'
|
||||
? __('Course')
|
||||
: __('Batch')
|
||||
}}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveTransaction()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="transactionData" class="overflow-y-auto">
|
||||
@@ -21,6 +42,12 @@
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_for_certificate"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Member Consent')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.member_consent"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -28,22 +55,27 @@
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
/>
|
||||
<Link
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="documentTypeOptions"
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
/>
|
||||
<Link
|
||||
v-if="transactionData.payment_for_document_type"
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
@@ -58,8 +90,13 @@
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Amount')"
|
||||
v-model="transactionData.amount"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
||||
<FormControl
|
||||
v-if="transactionData.amount_with_gst"
|
||||
:label="__('Amount with GST')"
|
||||
@@ -103,6 +140,7 @@
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
@@ -116,25 +154,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2 mt-auto ml-auto">
|
||||
<Button @click="openDetails()">
|
||||
{{ __('Open the ') }}
|
||||
{{
|
||||
data.payment_for_document_type == 'LMS Course'
|
||||
? __('Course')
|
||||
: __('Batch')
|
||||
}}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveTransaction()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl } from 'frappe-ui'
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
@@ -148,21 +173,40 @@ const props = defineProps<{
|
||||
data: any
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const saveTransaction = () => {
|
||||
if (props.data?.name) {
|
||||
updateTransaction()
|
||||
} else {
|
||||
createTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
const saveTransaction = (close: () => void) => {
|
||||
props.transactions.value.setValue
|
||||
const createTransaction = () => {
|
||||
console.log(props.transactions)
|
||||
props.transactions.insert
|
||||
.submit({
|
||||
...transactionData.value,
|
||||
})
|
||||
.then(() => {
|
||||
close()
|
||||
toast.success(__('Transaction created successfully'))
|
||||
})
|
||||
.catch((err: any) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const updateTransaction = () => {
|
||||
props.transactions.setValue
|
||||
.submit({
|
||||
...transactionData.value,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Transaction updated successfully'))
|
||||
})
|
||||
.catch((err: any) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,4 +225,48 @@ const openDetails = () => {
|
||||
show.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTransactionData = {
|
||||
payment_received: false,
|
||||
payment_for_certificate: false,
|
||||
member: null,
|
||||
billing_name: null,
|
||||
source: null,
|
||||
payment_for_document_type: null,
|
||||
payment_for_document: null,
|
||||
member_consent: false,
|
||||
currency: null,
|
||||
amount: null,
|
||||
amount_with_gst: null,
|
||||
coupon: null,
|
||||
coupon_code: null,
|
||||
discount_amount: null,
|
||||
original_amount: null,
|
||||
order_id: null,
|
||||
payment_id: null,
|
||||
gstin: null,
|
||||
pan: null,
|
||||
address: null,
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const documentTypeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Course'),
|
||||
value: 'LMS Course',
|
||||
},
|
||||
{
|
||||
label: __('Batch'),
|
||||
value: 'LMS Batch',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="mb-5">
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="emit('updateStep', 'new', null)">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add Transaction') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-5 mb-4">
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<TransactionDetails
|
||||
v-if="step == 'new'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<TransactionList
|
||||
v-if="step === 'list'"
|
||||
v-else-if="step === 'list'"
|
||||
:label="props.label"
|
||||
:description="props.description"
|
||||
:transactions="transactions"
|
||||
@@ -33,6 +40,8 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
|
||||
step.value = newStep
|
||||
if (newData) {
|
||||
data.value = newData
|
||||
} else {
|
||||
data.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,12 +269,13 @@ const iconProps = {
|
||||
onMounted(() => {
|
||||
setUpOnboarding()
|
||||
addKeyboardShortcut()
|
||||
updateSidebarLinks()
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unreadNotifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
const setSidebarLinks = () => {
|
||||
const updateSidebarLinksVisibility = () => {
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
@@ -405,9 +406,13 @@ const steps = reactive([
|
||||
minimize.value = true
|
||||
let course = await getFirstCourse()
|
||||
if (course) {
|
||||
router.push({ name: 'CourseForm', params: { courseName: course } })
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: course },
|
||||
hash: '#settings',
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'CourseForm' })
|
||||
router.push({ name: 'Courses', query: { newCourse: '1' } })
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -422,11 +427,12 @@ const steps = reactive([
|
||||
let course = await getFirstCourse()
|
||||
if (course) {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: course },
|
||||
hash: '#settings',
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'Courses' })
|
||||
router.push({ name: 'Courses', query: { newCourse: '1' } })
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -591,10 +597,18 @@ watch(userResource, async () => {
|
||||
await programs.reload()
|
||||
setUpOnboarding()
|
||||
}
|
||||
sidebarLinks.value = getSidebarLinks()
|
||||
setSidebarLinks()
|
||||
updateSidebarLinks()
|
||||
})
|
||||
|
||||
watch(settingsStore.settings, () => {
|
||||
updateSidebarLinks()
|
||||
})
|
||||
|
||||
const updateSidebarLinks = () => {
|
||||
sidebarLinks.value = getSidebarLinks()
|
||||
updateSidebarLinksVisibility()
|
||||
}
|
||||
|
||||
const redirectToWebsite = () => {
|
||||
window.open('https://frappe.io/learning', '_blank')
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ const evaluationCourses = computed(() => {
|
||||
|
||||
const canScheduleEvals = computed(() => {
|
||||
return (
|
||||
upcoming_evals.data?.length != evaluationCourses.length &&
|
||||
upcoming_evals.data?.length != evaluationCourses.value?.length &&
|
||||
!props.forHome &&
|
||||
!endDateHasPassed.value
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
:size="size"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template v-if="user.open_to === 'Opportunities'" #indicator>
|
||||
<Tooltip :text="__('Open to Opportunities')" placement="right">
|
||||
<template v-if="user.open_to === 'Work'" #indicator>
|
||||
<Tooltip :text="__('Open to Work')" placement="right">
|
||||
<div class="rounded-full bg-surface-green-3 w-fit">
|
||||
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
@import './assets/Inter/inter.css';
|
||||
@import 'frappe-ui/style.css';
|
||||
@import './styles/codemirror.css';
|
||||
@@ -1,5 +1,5 @@
|
||||
import './index.css'
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, watch } from 'vue'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -19,7 +19,6 @@ app.use(FrappeUI)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(translationPlugin)
|
||||
app.use(telemetryPlugin, { app_name: 'lms' })
|
||||
app.use(pageMetaPlugin)
|
||||
app.provide('$dayjs', dayjs)
|
||||
app.provide('$socket', initSocket())
|
||||
@@ -29,5 +28,11 @@ const { userResource, allUsers } = usersStore()
|
||||
app.provide('$user', userResource)
|
||||
app.provide('$allUsers', allUsers)
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
app.use(telemetryPlugin, { app_name: 'lms' })
|
||||
}
|
||||
})
|
||||
|
||||
app.config.globalProperties.$user = userResource
|
||||
app.config.globalProperties.$dialog = createDialog
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
@@ -185,24 +184,27 @@ const batches = createListResource({
|
||||
cache: ['batches', user.data?.name],
|
||||
pageLength: pageLength.value,
|
||||
start: start.value,
|
||||
onSuccess(data) {
|
||||
let allCategories = data.map((batch) => batch.category)
|
||||
allCategories = allCategories.filter(
|
||||
(category, index) => allCategories.indexOf(category) === index && category
|
||||
)
|
||||
if (categories.value.length <= allCategories.length) {
|
||||
updateCategories(data)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const setCategories = (data) => {
|
||||
let allCategories = data.map((batch) => batch.category)
|
||||
allCategories = allCategories.filter(
|
||||
(category, index) => allCategories.indexOf(category) === index && category
|
||||
)
|
||||
if (categories.value.length <= allCategories.length) {
|
||||
updateCategories(data)
|
||||
}
|
||||
}
|
||||
|
||||
const updateBatches = () => {
|
||||
updateFilters()
|
||||
batches.update({
|
||||
filters: filters.value,
|
||||
orderBy: orderBy.value,
|
||||
})
|
||||
batches.reload()
|
||||
batches.reload().then((data) => {
|
||||
setCategories(data)
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilters = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link :to="{ name: 'Batches', query: { certification: true } }">
|
||||
<router-link :to="{ name: 'Courses', query: { certification: true } }">
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<GraduationCap class="h-4 w-4 stroke-1.5" />
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
v-model="openToOpportunities"
|
||||
:label="__('Open to Opportunities')"
|
||||
v-model="openToWork"
|
||||
:label="__('Open to Work')"
|
||||
type="checkbox"
|
||||
@change="updateParticipants()"
|
||||
/>
|
||||
@@ -134,19 +134,26 @@ import {
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { GraduationCap } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const filters = ref({})
|
||||
const currentCategory = ref('')
|
||||
const nameFilter = ref('')
|
||||
const openToOpportunities = ref(false)
|
||||
const openToWork = ref(false)
|
||||
const hiring = ref(false)
|
||||
const { brand } = sessionStore()
|
||||
const memberCount = ref(0)
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
router.push({ name: 'Courses' })
|
||||
return
|
||||
}
|
||||
setFiltersFromQuery()
|
||||
updateParticipants()
|
||||
})
|
||||
@@ -171,7 +178,7 @@ const categories = createListResource({
|
||||
doctype: 'LMS Certificate',
|
||||
url: 'lms.lms.api.get_certification_categories',
|
||||
cache: ['certification_categories'],
|
||||
auto: true,
|
||||
auto: user.data ? true : false,
|
||||
transform(data) {
|
||||
data.unshift({ label: __(' '), value: ' ' })
|
||||
return data
|
||||
@@ -197,8 +204,8 @@ const updateFilters = () => {
|
||||
...(nameFilter.value && {
|
||||
member_name: ['like', `%${nameFilter.value}%`],
|
||||
}),
|
||||
...(openToOpportunities.value && {
|
||||
open_to_opportunities: true,
|
||||
...(openToWork.value && {
|
||||
open_to_work: true,
|
||||
}),
|
||||
...(hiring.value && {
|
||||
hiring: true,
|
||||
@@ -211,7 +218,7 @@ const setQueryParams = () => {
|
||||
let filterKeys = {
|
||||
category: currentCategory.value,
|
||||
name: nameFilter.value,
|
||||
'open-to-opportunities': openToOpportunities.value,
|
||||
'open-to-work': openToWork.value,
|
||||
hiring: hiring.value,
|
||||
}
|
||||
|
||||
@@ -240,7 +247,7 @@ const setFiltersFromQuery = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
nameFilter.value = queries.get('name') || ''
|
||||
currentCategory.value = queries.get('category') || ''
|
||||
openToOpportunities.value = queries.get('open-to-opportunities') === 'true'
|
||||
openToWork.value = queries.get('open-to-opportunities') === 'true'
|
||||
hiring.value = queries.get('hiring') === 'true'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
<div v-if="course.data">
|
||||
<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 class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div class="flex justify-between w-full space-x-5">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="parseInt(course.data.rating) > 0"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Users class="h-4 w-4 text-ink-gray-7" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.enrollment_count" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="md:hidden my-4">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
<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-10"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Badge,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch } from 'vue'
|
||||
import { Users, Star } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
watch(course, () => {
|
||||
if (
|
||||
!isInstructor() &&
|
||||
!user.data?.is_moderator &&
|
||||
!course.data?.published &&
|
||||
!course.data?.upcoming
|
||||
) {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isInstructor = () => {
|
||||
let user_is_instructor = false
|
||||
course.data?.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const courseTitle = ref(null)
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-4 gap-5 mb-5">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(course.data?.enrollments)"
|
||||
/>
|
||||
<NumberChartGraph
|
||||
:title="__('Average Completion Rate')"
|
||||
:value="averageCompletionRate"
|
||||
/>
|
||||
<NumberChartGraph
|
||||
:title="__('Average Rating')"
|
||||
:value="course.data?.rating || 0"
|
||||
>
|
||||
<template #prefix>
|
||||
<Star class="size-5 text-transparent fill-amber-500" />
|
||||
</template>
|
||||
</NumberChartGraph>
|
||||
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by name')"
|
||||
type="text"
|
||||
/>
|
||||
<Button @click="showEnrollmentModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="progressList.loading || progressList.data?.length"
|
||||
class="max-h-[63vh] overflow-y-auto"
|
||||
>
|
||||
<ListView
|
||||
:columns="progressColumns"
|
||||
:rows="progressList.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in progressColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="progressList.data && progressList.hasNextPage"
|
||||
class="flex justify-center my-3"
|
||||
>
|
||||
<Button @click="progressList.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-if="chartDetails.data?.average_progress > 0"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Progress Summary') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
||||
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
||||
<div
|
||||
class="flex items-center"
|
||||
v-for="row in chartDetails.data?.progress_distribution"
|
||||
>
|
||||
<div
|
||||
class="size-2 rounded"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
colors[theme][
|
||||
row.name.startsWith('Just')
|
||||
? 'red'
|
||||
: row.name.startsWith('In')
|
||||
? 'amber'
|
||||
: 'green'
|
||||
][400],
|
||||
}"
|
||||
></div>
|
||||
<Tooltip :text="row.name.split('(')[1].replace(')', '')">
|
||||
<div class="ml-2">
|
||||
{{ row.name.split('(')[0] }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="ml-auto">
|
||||
{{
|
||||
Math.round((row.value / course.data?.enrollments) * 100)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ECharts
|
||||
class="w-40 h-20"
|
||||
:options="{
|
||||
color: progressColors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
scale: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
},
|
||||
],
|
||||
showInlineLabels: false,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="lessonProgress.data?.length"
|
||||
class="border rounded-lg pt-4 px-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Lesson Completion') }}
|
||||
</div>
|
||||
<Select
|
||||
:options="lessonProgressSortingOptions"
|
||||
@update:modelValue="(value: string) => updateLessonProgress(value)"
|
||||
:placeholder="__('Sort by')"
|
||||
class="!w-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y max-h-[43vh] overflow-y-auto">
|
||||
<div
|
||||
v-for="progress in lessonProgress.data"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
>
|
||||
<div class="">
|
||||
<span class="mr-3 text-xs">
|
||||
{{ progress.chapter_idx }}.{{ progress.idx }}
|
||||
</span>
|
||||
<span>
|
||||
{{ progress.title }}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip :text="progress.completion_count">
|
||||
<div>
|
||||
{{
|
||||
Math.ceil(
|
||||
(progress.completion_count / course.data?.enrollments) *
|
||||
100
|
||||
)
|
||||
}}%
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseEnrollmentModal
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:course="course"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dropdown,
|
||||
ECharts,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
Select,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
|
||||
import { formatAmount } from '@/utils'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
|
||||
const showEnrollmentModal = ref(false)
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const theme = ref<'darkMode' | 'lightMode'>(
|
||||
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const chartDetails = createResource({
|
||||
url: 'lms.lms.api.get_course_progress_distribution',
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
course: props.course.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'progress',
|
||||
'creation',
|
||||
],
|
||||
pageLength: 100,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const lessonProgress = createResource({
|
||||
url: 'lms.lms.api.get_lesson_completion_stats',
|
||||
params: {
|
||||
course: props.course.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const updateLessonProgress = (value: string) => {
|
||||
if (value == 'completion_rate') {
|
||||
lessonProgress.data?.sort((a: any, b: any) => {
|
||||
const rateA = a.completion_count / (props.course.data?.enrollments || 1)
|
||||
const rateB = b.completion_count / (props.course.data?.enrollments || 1)
|
||||
return rateB - rateA
|
||||
})
|
||||
} else if (value == 'index') {
|
||||
lessonProgress.data?.sort((a: any, b: any) => {
|
||||
return a.chapter_idx - b.chapter_idx || a.idx - b.idx
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
filters: filters,
|
||||
})
|
||||
progressList.reload()
|
||||
})
|
||||
|
||||
const averageCompletionRate = computed(() => {
|
||||
let value = Math.ceil(chartDetails.data?.average_progress) || 0
|
||||
return value + '%'
|
||||
})
|
||||
|
||||
const progressColors = computed(() => {
|
||||
let colorList = []
|
||||
colorList.push(colors[theme.value]['red'][400])
|
||||
colorList.push(colors[theme.value]['amber'][400])
|
||||
colorList.push(colors[theme.value]['green'][400])
|
||||
return colorList
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Name'),
|
||||
key: 'member_name',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
label: __('Start Date'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const lessonProgressSortingOptions = [
|
||||
{
|
||||
label: __('Lesson Index'),
|
||||
value: 'index',
|
||||
onClick() {
|
||||
updateLessonProgress('index')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Completion Rate'),
|
||||
value: 'completion_rate',
|
||||
onClick() {
|
||||
updateLessonProgress('completion_rate')
|
||||
},
|
||||
},
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div v-if="course.data">
|
||||
<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 class="h-7" :items="breadcrumbs" />
|
||||
<div v-if="tabIndex == 2" class="flex items-center space-x-2">
|
||||
<Badge v-if="childRef?.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Button @click="childRef.trashCourse()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="childRef.submitCourse()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<CourseOverview v-if="!isAdmin" :course="course" />
|
||||
<div v-else>
|
||||
<Tabs :tabs="tabs" v-model="tabIndex">
|
||||
<template #tab-panel="{ tab }">
|
||||
<component :is="tab.component" :course="course" ref="childRef" />
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Tabs,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
|
||||
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
|
||||
import CourseForm from '@/pages/Courses/CourseForm.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const user = inject('$user')
|
||||
const tabIndex = ref(0)
|
||||
const childRef = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateTabIndex()
|
||||
})
|
||||
|
||||
const updateTabIndex = () => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: __('Overview'),
|
||||
component: markRaw(CourseOverview),
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
label: __('Dashboard'),
|
||||
component: markRaw(CourseDashboard),
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: __('Settings'),
|
||||
component: markRaw(CourseForm),
|
||||
icon: Settings2,
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
watch(course, () => {
|
||||
if (!isAdmin.value && !course.data?.published && !course.data?.upcoming) {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isInstructor = () => {
|
||||
let user_is_instructor = false
|
||||
course.data?.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || isInstructor()
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Enroll a Student'),
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:label="__('Purchased Certificate')"
|
||||
v-model="purchasedCertificate"
|
||||
/>
|
||||
<Link
|
||||
doctype="User"
|
||||
:label="__('Student')"
|
||||
placeholder=" "
|
||||
v-model="student"
|
||||
:required="true"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Members')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
v-if="purchasedCertificate"
|
||||
doctype="LMS Payment"
|
||||
:label="__('Payment')"
|
||||
placeholder=" "
|
||||
v-model="payment"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="enrollStudent(close)">
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { ref } from 'vue'
|
||||
import { openSettings } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const student = ref<string | null>(null)
|
||||
const payment = ref<string | null>(null)
|
||||
const purchasedCertificate = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
|
||||
const enrollStudent = (close: () => void) => {
|
||||
let validationPassed = validateData()
|
||||
if (!validationPassed) return
|
||||
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.course.data?.name,
|
||||
member: student.value,
|
||||
payment: purchasedCertificate.value ? payment.value : null,
|
||||
purchased_certificate: purchasedCertificate.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Student enrolled successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const validateData = (): boolean => {
|
||||
if (!student.value) {
|
||||
toast.error(__('Please select a student to enroll.'))
|
||||
return false
|
||||
}
|
||||
if (purchasedCertificate.value && !payment.value) {
|
||||
toast.error(__('Please select a payment for the purchased certificate.'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
@@ -1,40 +1,25 @@
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] h-full">
|
||||
<div>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center mt-3 md:mt-0">
|
||||
<Button v-if="courseResource.data?.name" @click="trashCourse()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||
<span>
|
||||
{{ __('Save') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-5 mb-5">
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="pl-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] overflow-hidden">
|
||||
<div v-if="courseResource.doc" class="h-[88vh] overflow-y-auto">
|
||||
<div class="my-5">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
v-model="courseResource.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
v-model="courseResource.doc.category"
|
||||
:label="__('Category')"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
@@ -45,6 +30,7 @@
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:onCreate="(close) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
@@ -60,8 +46,8 @@
|
||||
<div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
v-if="courseResource.doc.tags"
|
||||
v-for="tag in courseResource.doc.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -76,21 +62,23 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<Uploader
|
||||
v-model="course.image"
|
||||
v-model="courseResource.doc.image"
|
||||
:label="__('Course Image')"
|
||||
:required="false"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
|
||||
<ColorSwatches
|
||||
v-model="course.card_gradient"
|
||||
v-model="courseResource.doc.card_gradient"
|
||||
:label="__('Color')"
|
||||
:description="__('Choose a color for the course card')"
|
||||
class="w-full"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
@@ -101,41 +89,46 @@
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.published"
|
||||
v-model="courseResource.doc.published"
|
||||
:label="__('Published')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="course.published_on"
|
||||
v-model="courseResource.doc.published_on"
|
||||
:label="__('Published On')"
|
||||
type="date"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.upcoming"
|
||||
v-model="courseResource.doc.upcoming"
|
||||
:label="__('Upcoming')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.featured"
|
||||
v-model="courseResource.doc.featured"
|
||||
:label="__('Featured')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.disable_self_learning"
|
||||
v-model="courseResource.doc.disable_self_learning"
|
||||
:label="__('Disable Self Enrollment')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('About the Course') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
v-model="courseResource.doc.short_introduction"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:label="__('Short Introduction')"
|
||||
@@ -145,6 +138,7 @@
|
||||
)
|
||||
"
|
||||
:required="true"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
@@ -152,8 +146,13 @@
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@change="(val) => (course.description = val)"
|
||||
:content="courseResource.doc.description"
|
||||
@change="
|
||||
(val) => {
|
||||
courseResource.doc.description = val
|
||||
makeFormDirty()
|
||||
}
|
||||
"
|
||||
: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]"
|
||||
@@ -161,92 +160,113 @@
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
v-model="courseResource.doc.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="related_courses"
|
||||
doctype="LMS Course"
|
||||
:label="__('Related Courses')"
|
||||
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||
:filters="{ name: ['!=', courseResource.doc?.name] }"
|
||||
:onCreate="
|
||||
(close) => {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
name: 'Courses',
|
||||
query: { newCourse: '1' },
|
||||
})
|
||||
}
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5 border-b">
|
||||
<div class="pr-5 md:pr-10 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_course"
|
||||
v-model="courseResource.doc.paid_course"
|
||||
:label="__('Paid Course')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
v-model="courseResource.doc.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_certificate"
|
||||
v-model="courseResource.doc.paid_certificate"
|
||||
:label="__('Paid Certificate')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-if="course.paid_course || course.paid_certificate"
|
||||
v-model="course.course_price"
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
v-model="courseResource.doc.course_price"
|
||||
:label="__('Amount')"
|
||||
:required="course.paid_course || course.paid_certificate"
|
||||
:required="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<Link
|
||||
v-if="course.paid_certificate"
|
||||
v-if="courseResource.doc.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="course.evaluator"
|
||||
v-model="courseResource.doc.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
:required="course.paid_certificate"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:onCreate="
|
||||
(value, close) => openSettings('Evaluators', close)
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Link
|
||||
v-if="course.paid_course || course.paid_certificate"
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
doctype="Currency"
|
||||
v-model="course.currency"
|
||||
v-model="courseResource.doc.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
:required="course.paid_course || course.paid_certificate"
|
||||
:required="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="course.paid_certificate"
|
||||
v-model="course.timezone"
|
||||
v-if="courseResource.doc.paid_certificate"
|
||||
v-model="courseResource.doc.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="course.paid_certificate"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:placeholder="__('e.g. IST, UTC, GMT...')"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5">
|
||||
<div class="pr-5 md:pr-10 pb-5 space-y-5">
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
@@ -256,6 +276,7 @@
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
@@ -263,16 +284,17 @@
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
:placeholder="__('Comma separated keywords for SEO')"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l">
|
||||
<div class="border-l h-[88vh] overflow-y-auto">
|
||||
<CourseOutline
|
||||
v-if="courseResource.data"
|
||||
:courseName="courseResource.data.name"
|
||||
:title="__('Course Outline')"
|
||||
v-if="courseResource.doc"
|
||||
:courseName="courseResource.doc.name"
|
||||
:title="__('Chapters')"
|
||||
:allowEdit="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,10 +303,10 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
TextEditor,
|
||||
Button,
|
||||
createResource,
|
||||
createDocumentResource,
|
||||
FormControl,
|
||||
usePageMeta,
|
||||
toast,
|
||||
@@ -293,7 +315,6 @@ import {
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
computed,
|
||||
ref,
|
||||
reactive,
|
||||
watch,
|
||||
@@ -308,8 +329,7 @@ import {
|
||||
} from '@/utils'
|
||||
import { Trash2, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
@@ -323,39 +343,15 @@ const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const related_courses = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
course: {
|
||||
type: Object,
|
||||
},
|
||||
})
|
||||
|
||||
const course = reactive({
|
||||
title: '',
|
||||
short_introduction: '',
|
||||
description: '',
|
||||
video_link: '',
|
||||
image: null,
|
||||
card_gradient: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
published: false,
|
||||
published_on: '',
|
||||
featured: false,
|
||||
upcoming: false,
|
||||
disable_self_learning: false,
|
||||
enable_certification: false,
|
||||
paid_course: false,
|
||||
paid_certificate: false,
|
||||
course_price: '',
|
||||
currency: '',
|
||||
evaluator: '',
|
||||
timezone: '',
|
||||
})
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
@@ -365,18 +361,92 @@ onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (props.courseName !== 'new') {
|
||||
fetchCourseInfo()
|
||||
} else {
|
||||
capture('course_form_opened')
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const fetchCourseInfo = () => {
|
||||
courseResource.reload()
|
||||
getMetaInfo('courses', props.courseName, meta)
|
||||
const courseResource = createDocumentResource({
|
||||
doctype: 'LMS Course',
|
||||
name: props.course.data?.name,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => courseResource.doc,
|
||||
() => {
|
||||
check_permission()
|
||||
getMetaInfo('courses', courseResource.doc?.name, meta)
|
||||
updateCourseData()
|
||||
}
|
||||
)
|
||||
|
||||
const updateCourseData = () => {
|
||||
Object.keys(courseResource.doc).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
courseResource.doc.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (key == 'related_courses') {
|
||||
related_courses.value = []
|
||||
courseResource.doc.related_courses.forEach((course) => {
|
||||
related_courses.value.push(course.course)
|
||||
})
|
||||
}
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'upcoming',
|
||||
'disable_self_learning',
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certificate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
courseResource.doc[key] = courseResource.doc[key] ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
const submitCourse = () => {
|
||||
validateFields()
|
||||
updateCourse()
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
courseResource.doc.description = sanitizeHTML(courseResource.doc.description)
|
||||
|
||||
Object.keys(courseResource.doc).forEach((key) => {
|
||||
if (key != 'description' && typeof courseResource.doc[key] === 'string') {
|
||||
courseResource.doc[key] = escapeHTML(courseResource.doc[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateCourse = () => {
|
||||
courseResource.setValue.submit(
|
||||
{
|
||||
...courseResource.doc,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateMetaInfo('courses', courseResource.doc?.name, meta)
|
||||
toast.success(__('Course updated successfully'))
|
||||
isDirty.value = false
|
||||
courseResource.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
@@ -394,151 +464,11 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const courseCreationResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Course',
|
||||
image: course.image,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courseEditResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
name: values.course,
|
||||
fieldname: {
|
||||
image: course.image,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...course,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courseResource = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
name: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (key == 'related_courses') {
|
||||
related_courses.value = []
|
||||
data.related_courses.forEach((course) => {
|
||||
related_courses.value.push(course.course)
|
||||
})
|
||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'upcoming',
|
||||
'disable_self_learning',
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certificate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
course[key] = course[key] ? true : false
|
||||
}
|
||||
|
||||
check_permission()
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
course.description = sanitizeHTML(course.description)
|
||||
|
||||
Object.keys(course).forEach((key) => {
|
||||
if (key != 'description' && typeof course[key] === 'string') {
|
||||
course[key] = escapeHTML(course[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitCourse = () => {
|
||||
validateFields()
|
||||
if (courseResource.data) {
|
||||
editCourse()
|
||||
} else {
|
||||
createCourse()
|
||||
}
|
||||
}
|
||||
|
||||
const createCourse = () => {
|
||||
courseCreationResource.submit(course, {
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('courses', data.name, meta)
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_course', true, false, () => {
|
||||
localStorage.setItem('firstCourse', data.name)
|
||||
})
|
||||
}
|
||||
|
||||
capture('course_created')
|
||||
toast.success(__('Course created successfully'))
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: data.name },
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const editCourse = () => {
|
||||
courseEditResource.submit(
|
||||
{
|
||||
course: courseResource.data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateMetaInfo('courses', props.courseName, meta)
|
||||
toast.success(__('Course updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteCourse = createResource({
|
||||
url: 'lms.lms.api.delete_course',
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
course: courseResource.doc?.name,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
@@ -567,28 +497,23 @@ const trashCourse = () => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.courseName !== 'new',
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
fetchCourseInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const updateTags = () => {
|
||||
if (newTag.value) {
|
||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||
courseResource.doc.tags = courseResource.doc.tags
|
||||
? `${courseResource.doc.tags}, ${newTag.value}`
|
||||
: newTag.value
|
||||
newTag.value = ''
|
||||
makeFormDirty()
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag) => {
|
||||
course.tags = course.tags
|
||||
courseResource.doc.tags = courseResource.doc.tags
|
||||
?.split(', ')
|
||||
.filter((t) => t !== tag)
|
||||
.join(', ')
|
||||
newTag.value = ''
|
||||
makeFormDirty()
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
@@ -606,30 +531,20 @@ const check_permission = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Courses',
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
]
|
||||
if (courseResource.data) {
|
||||
crumbs.push({
|
||||
label: course.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
const makeFormDirty = () => {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: courseResource.data?.title || __('New Course'),
|
||||
title: courseResource.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
submitCourse,
|
||||
trashCourse,
|
||||
isDirty,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="flex justify-between w-full space-x-5">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="parseInt(course.data.rating) > 0"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Users class="h-4 w-4 text-ink-gray-7" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.enrollment_count" class="mx-3">·</span>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="md:hidden my-4">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
<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-10"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Star, Users } from 'lucide-vue-next'
|
||||
import { Badge, Tooltip } from 'frappe-ui'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
|
||||
<Dropdown
|
||||
placement="start"
|
||||
placement="right"
|
||||
side="bottom"
|
||||
v-if="canCreateCourse()"
|
||||
:options="[
|
||||
@@ -13,10 +13,7 @@
|
||||
label: __('New Course'),
|
||||
icon: 'book-open',
|
||||
onClick() {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
})
|
||||
showCourseModal = true
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -109,6 +106,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<NewCourseModal
|
||||
v-if="showCourseModal"
|
||||
v-model="showCourseModal"
|
||||
:courses="courses"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -128,13 +130,19 @@ import { sessionStore } from '@/stores/session'
|
||||
import { canCreateCourse } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import router from '../router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewCourseModal from '@/pages/Courses/NewCourseModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const start = ref(0)
|
||||
const pageLength = ref(30)
|
||||
const categories = ref([])
|
||||
const categories = ref([
|
||||
{
|
||||
label: '',
|
||||
value: null,
|
||||
},
|
||||
])
|
||||
const currentCategory = ref(null)
|
||||
const title = ref('')
|
||||
const certification = ref(false)
|
||||
@@ -142,17 +150,13 @@ const filters = ref({})
|
||||
const currentTab = ref('Live')
|
||||
const { brand } = sessionStore()
|
||||
const courseCount = ref(0)
|
||||
const router = useRouter()
|
||||
const showCourseModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
updateCourses()
|
||||
getCourseCount()
|
||||
categories.value = [
|
||||
{
|
||||
label: '',
|
||||
value: null,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const setFiltersFromQuery = () => {
|
||||
@@ -160,6 +164,9 @@ const setFiltersFromQuery = () => {
|
||||
title.value = queries.get('title') || ''
|
||||
currentCategory.value = queries.get('category') || null
|
||||
certification.value = queries.get('certification') || false
|
||||
if (queries.get('newCourse') == '1') {
|
||||
showCourseModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const courses = createListResource({
|
||||
@@ -168,9 +175,6 @@ const courses = createListResource({
|
||||
cache: ['courses', user.data?.name],
|
||||
pageLength: pageLength.value,
|
||||
start: start.value,
|
||||
onSuccess(data) {
|
||||
setCategories(data)
|
||||
},
|
||||
})
|
||||
|
||||
const setCategories = (data) => {
|
||||
@@ -205,7 +209,7 @@ const identifyUserPersona = async () => {
|
||||
|
||||
const getCourseCount = () => {
|
||||
if (!user.data) return
|
||||
|
||||
if (!user.data.is_moderator) return
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Course',
|
||||
}).then((data) => {
|
||||
@@ -219,7 +223,9 @@ const updateCourses = () => {
|
||||
courses.update({
|
||||
filters: filters.value,
|
||||
})
|
||||
courses.reload()
|
||||
courses.reload().then((data) => {
|
||||
setCategories(data)
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilters = () => {
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Create Course'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-5 border-b mb-5">
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="course.instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:onCreate="(close: () => void) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="course.image"
|
||||
:label="__('Course Image')"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
:label="__('Short Introduction')"
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course Description') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@change="(val: string) => (course.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-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="saveCourse(close)">
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const user = inject<any>('$user')
|
||||
|
||||
const props = defineProps<{
|
||||
courses: any
|
||||
}>()
|
||||
|
||||
const course = ref({
|
||||
title: '',
|
||||
short_introduction: '',
|
||||
description: '',
|
||||
instructors: [],
|
||||
category: null,
|
||||
image: null,
|
||||
})
|
||||
|
||||
const saveCourse = (close: () => void = () => {}) => {
|
||||
props.courses.insert.submit(
|
||||
{
|
||||
...course.value,
|
||||
instructors: course.value.instructors.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess(data: any) {
|
||||
toast.success(__('Course created successfully'))
|
||||
close()
|
||||
capture('course_created')
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: data.name },
|
||||
hash: '#settings',
|
||||
})
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_course', true, false, () => {
|
||||
localStorage.setItem('firstCourse', data.name)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveCourse()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
watch(show, () => {
|
||||
capture('course_form_opened')
|
||||
})
|
||||
</script>
|
||||
@@ -74,7 +74,7 @@
|
||||
}}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
|
||||
:to="{ name: 'Courses', query: { newCourse: '1' } }"
|
||||
class="mt-4"
|
||||
>
|
||||
<Button>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<Uploader
|
||||
v-model="job.company_logo"
|
||||
:label="__('Company Logo')"
|
||||
:required="false"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-10
@@ -51,12 +51,12 @@
|
||||
class="hidden lg:block"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
type="text"
|
||||
:placeholder="__('Search')"
|
||||
v-model="searchQuery"
|
||||
class="w-full max-w-40"
|
||||
class="w-full"
|
||||
@input="updateJobs"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -79,17 +79,17 @@
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="w-full"
|
||||
class="w-full min-w-32"
|
||||
:placeholder="__('Type')"
|
||||
@change="updateJobs"
|
||||
@update:modelValue="updateJobs"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="workMode"
|
||||
type="select"
|
||||
:options="workModes"
|
||||
class="w-full"
|
||||
class="w-full min-w-32"
|
||||
:placeholder="__('Work Mode')"
|
||||
@change="updateJobs"
|
||||
@update:modelValue="updateJobs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,13 +218,13 @@ const updateJobs = () => {
|
||||
const updateFilters = () => {
|
||||
filters.value.status = 'Open'
|
||||
|
||||
if (jobType.value) {
|
||||
if (jobType.value && jobType.value !== ' ') {
|
||||
filters.value.type = jobType.value
|
||||
} else {
|
||||
delete filters.value.type
|
||||
}
|
||||
|
||||
if (workMode.value) {
|
||||
if (workMode.value && workMode.value !== ' ') {
|
||||
filters.value.work_mode = workMode.value
|
||||
} else {
|
||||
delete filters.value.work_mode
|
||||
@@ -271,7 +271,7 @@ watch(jobs, () => {
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
{ label: '', value: '' },
|
||||
{ label: ' ', value: ' ' },
|
||||
{ label: __('Full Time'), value: 'Full Time' },
|
||||
{ label: __('Part Time'), value: 'Part Time' },
|
||||
{ label: __('Contract'), value: 'Contract' },
|
||||
@@ -281,7 +281,7 @@ const jobTypes = computed(() => {
|
||||
|
||||
const workModes = computed(() => {
|
||||
return [
|
||||
{ label: '', value: '' },
|
||||
{ label: ' ', value: ' ' },
|
||||
{ label: 'On site', value: 'On-site' },
|
||||
{ label: 'Hybrid', value: 'Hybrid' },
|
||||
{ label: 'Remote', value: 'Remote' },
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
@updateNotes="updateNotes"
|
||||
/>
|
||||
<VideoStatistics
|
||||
v-if="showStatsDialog"
|
||||
v-model="showStatsDialog"
|
||||
:lessonName="lesson.data?.name"
|
||||
:lessonTitle="lesson.data?.title"
|
||||
@@ -871,6 +872,7 @@ const scrollDiscussionsIntoView = () => {
|
||||
}
|
||||
|
||||
const updateNotes = () => {
|
||||
if (!user.data) return
|
||||
notes.update({
|
||||
filters: {
|
||||
lesson: lesson.data?.name,
|
||||
|
||||
@@ -471,7 +471,11 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
{
|
||||
label: lessonDetails.data?.course_title,
|
||||
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
||||
route: {
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: props.courseName },
|
||||
hash: '#settings',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
class="flex space-x-2 px-2 py-4"
|
||||
:class="{
|
||||
'cursor-pointer': log.link,
|
||||
'items-center': !showDetails(log) && !isMention(log),
|
||||
'items-center': !showDetails(log) && !isMentionOrComment(log),
|
||||
}"
|
||||
@click="navigateToPage(log)"
|
||||
>
|
||||
@@ -56,9 +56,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isMention(log)"
|
||||
v-if="isMentionOrComment(log)"
|
||||
v-html="log.email_content"
|
||||
class="bg-surface-gray-2 rounded-md px-3 py-2"
|
||||
class="bg-surface-gray-2 rounded-md px-3 py-2 line-clamp-3 overflow-hidden"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="showDetails(log)"
|
||||
@@ -185,10 +185,9 @@ const unReadNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
url: 'lms.lms.api.get_notifications',
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 0,
|
||||
},
|
||||
auto: true,
|
||||
auto: user.data ? true : false,
|
||||
cache: 'Unread Notifications',
|
||||
})
|
||||
|
||||
@@ -196,18 +195,17 @@ const readNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
url: 'lms.lms.api.get_notifications',
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 1,
|
||||
},
|
||||
auto: true,
|
||||
auto: user.data ? true : false,
|
||||
cache: 'Read Notifications',
|
||||
})
|
||||
|
||||
const markAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_as_read',
|
||||
url: 'frappe.desk.doctype.notification_log.notification_log.mark_as_read',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: values.name,
|
||||
docname: values.name,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
@@ -217,7 +215,7 @@ const markAsRead = createResource({
|
||||
})
|
||||
|
||||
const markAllAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_all_as_read',
|
||||
url: 'frappe.desk.doctype.notification_log.notification_log.mark_all_as_read',
|
||||
onSuccess(data) {
|
||||
unReadNotifications.reload()
|
||||
readNotifications.reload()
|
||||
@@ -260,7 +258,7 @@ const navigateToPage = (log) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isMention = (log) => {
|
||||
const isMentionOrComment = (log) => {
|
||||
if (log.type == 'Mention') {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
<Tooltip
|
||||
v-if="profile.data.open_to"
|
||||
:text="
|
||||
profile.data.open_to === 'Opportunities'
|
||||
? __('Open to Opportunities')
|
||||
profile.data.open_to === 'Work'
|
||||
? __('Open to Work')
|
||||
: __('Hiring')
|
||||
"
|
||||
placement="right"
|
||||
@@ -77,7 +77,7 @@
|
||||
<div
|
||||
class="rounded-full w-fit"
|
||||
:class="
|
||||
profile.data.open_to === 'Opportunities'
|
||||
profile.data.open_to === 'Work'
|
||||
? 'bg-surface-green-3'
|
||||
: 'bg-purple-500'
|
||||
"
|
||||
|
||||
@@ -226,7 +226,6 @@ import {
|
||||
onMounted,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||
@@ -252,7 +251,9 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const questions = ref([])
|
||||
const questions = computed(() => {
|
||||
return quizDetails.doc?.questions || []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
@@ -273,24 +274,10 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.quizID !== 'new',
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
quizDetails.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const quizDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz',
|
||||
name: props.quizID,
|
||||
auto: false,
|
||||
onSuccess(doc) {
|
||||
if (doc.questions && doc.questions.length > 0) {
|
||||
questions.value = doc.questions.map((question) => question)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
|
||||
@@ -116,20 +116,30 @@ const debouncedSaveProgress = (scormDetails) => {
|
||||
}
|
||||
|
||||
const saveDataToLMS = (key, value) => {
|
||||
if (key === 'cmi.core.lesson_status') {
|
||||
if (value === 'passed') {
|
||||
isSuccessfullyCompleted.value = true
|
||||
saveProgress({
|
||||
is_complete: isSuccessfullyCompleted.value,
|
||||
scorm_content: '',
|
||||
})
|
||||
} else if (value === 'failed' && courseRestartOnFailure) {
|
||||
saveProgress({
|
||||
is_complete: isSuccessfullyCompleted.value,
|
||||
scorm_content: '',
|
||||
})
|
||||
}
|
||||
} else if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
|
||||
const isLessonStatus = key === 'cmi.core.lesson_status' && value === 'passed'
|
||||
const isCompletionStatus =
|
||||
key === 'cmi.completion_status' && value === 'completed'
|
||||
const shouldRestart =
|
||||
(key === 'cmi.core.lesson_status' && value === 'failed') ||
|
||||
(key === 'cmi.completion_status' && value === 'incomplete')
|
||||
|
||||
if (isLessonStatus || isCompletionStatus) {
|
||||
isSuccessfullyCompleted.value = true
|
||||
}
|
||||
|
||||
if (
|
||||
isLessonStatus ||
|
||||
isCompletionStatus ||
|
||||
(shouldRestart && courseRestartOnFailure)
|
||||
) {
|
||||
saveProgress({
|
||||
is_complete: isSuccessfullyCompleted.value,
|
||||
scorm_content: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
|
||||
debouncedSaveProgress({
|
||||
is_complete: false,
|
||||
scorm_content: value,
|
||||
|
||||
@@ -13,12 +13,12 @@ const routes = [
|
||||
{
|
||||
path: '/courses',
|
||||
name: 'Courses',
|
||||
component: () => import('@/pages/Courses.vue'),
|
||||
component: () => import('@/pages/Courses/Courses.vue'),
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName',
|
||||
name: 'CourseDetail',
|
||||
component: () => import('@/pages/CourseDetail.vue'),
|
||||
component: () => import('@/pages/Courses/CourseDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -30,7 +30,7 @@ const routes = [
|
||||
{
|
||||
path: '/courses/:courseName/certification',
|
||||
name: 'CourseCertification',
|
||||
component: () => import('@/pages/CourseCertification.vue'),
|
||||
component: () => import('@/pages/Courses/CourseCertification.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -119,12 +119,6 @@ const routes = [
|
||||
component: () => import('@/pages/JobApplications.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/edit',
|
||||
name: 'CourseForm',
|
||||
component: () => import('@/pages/CourseForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
|
||||
name: 'LessonForm',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
let _sessionUser = cookies.get('user_id')
|
||||
if (_sessionUser === 'Guest') {
|
||||
_sessionUser = null
|
||||
} else {
|
||||
userResource.reload()
|
||||
}
|
||||
return _sessionUser
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export const usersStore = defineStore('lms-users', () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const allUsers = createResource({
|
||||
|
||||
@@ -465,7 +465,6 @@ const getSidebarItems = () => {
|
||||
'Courses',
|
||||
'CourseDetail',
|
||||
'Lesson',
|
||||
'CourseForm',
|
||||
'LessonForm',
|
||||
],
|
||||
},
|
||||
@@ -490,6 +489,9 @@ const getSidebarItems = () => {
|
||||
icon: 'GraduationCap',
|
||||
to: 'CertifiedParticipants',
|
||||
activeFor: ['CertifiedParticipants'],
|
||||
condition: () => {
|
||||
return userResource?.data
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
|
||||
@@ -34,7 +34,9 @@ export class Markdown {
|
||||
}
|
||||
|
||||
static get pasteConfig() {
|
||||
return { tags: ['P'] }
|
||||
return {
|
||||
tags: ['P'],
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -52,11 +54,277 @@ export class Markdown {
|
||||
this._togglePlaceholder()
|
||||
)
|
||||
this.wrapper.addEventListener('keydown', (e) => this._onKeyDown(e))
|
||||
this.wrapper.addEventListener(
|
||||
'paste',
|
||||
(e) => this._onNativePaste(e),
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
return this.wrapper
|
||||
}
|
||||
|
||||
_onNativePaste(event) {
|
||||
const clipboardData = event.clipboardData || window.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
const pastedText = clipboardData.getData('text/plain')
|
||||
|
||||
if (pastedText && this._looksLikeMarkdown(pastedText)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
this._insertMarkdownAsBlocks(pastedText)
|
||||
}
|
||||
}
|
||||
|
||||
_looksLikeMarkdown(text) {
|
||||
const markdownPatterns = [
|
||||
/^#{1,6}\s+/m,
|
||||
/^[\-\*]\s+/m,
|
||||
/^\d+\.\s+/m,
|
||||
/```[\s\S]*```/,
|
||||
]
|
||||
|
||||
return markdownPatterns.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
async _insertMarkdownAsBlocks(markdown) {
|
||||
const blocks = this._parseMarkdownToBlocks(markdown)
|
||||
|
||||
if (blocks.length === 0) return
|
||||
|
||||
const currentIndex = this.api.blocks.getCurrentBlockIndex()
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
try {
|
||||
await this.api.blocks.insert(
|
||||
blocks[i].type,
|
||||
blocks[i].data,
|
||||
{},
|
||||
currentIndex + i,
|
||||
false
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to insert block:', blocks[i], error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.blocks.delete(currentIndex + blocks.length)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete original block:', error)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.api.caret.setToBlock(currentIndex, 'end')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
_parseMarkdownToBlocks(markdown) {
|
||||
const lines = markdown.split('\n')
|
||||
const blocks = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
if (line.trim() === '') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
const codeBlock = this._parseCodeBlock(lines, i)
|
||||
blocks.push(codeBlock.block)
|
||||
i = codeBlock.nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^#{1,6}\s+/.test(line)) {
|
||||
blocks.push(this._parseHeading(line))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[\s]*[-*+]\s+/.test(line)) {
|
||||
const listBlock = this._parseUnorderedList(lines, i)
|
||||
blocks.push(listBlock.block)
|
||||
i = listBlock.nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[\s]*(\d+)\.\s+/.test(line)) {
|
||||
const listBlock = this._parseOrderedList(lines, i)
|
||||
blocks.push(listBlock.block)
|
||||
i = listBlock.nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'paragraph',
|
||||
data: { text: this._parseInlineMarkdown(line) },
|
||||
})
|
||||
i++
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
_parseHeading(line) {
|
||||
const match = line.match(/^(#{1,6})\s+(.*)$/)
|
||||
const level = match[1].length
|
||||
const text = match[2]
|
||||
|
||||
return {
|
||||
type: 'header',
|
||||
data: {
|
||||
text: this._parseInlineMarkdown(text),
|
||||
level: level,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_parseUnorderedList(lines, startIndex) {
|
||||
const items = []
|
||||
let i = startIndex
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
if (/^[\s]*[-*+]\s+/.test(line)) {
|
||||
const text = line.replace(/^[\s]*[-*+]\s+/, '')
|
||||
items.push({
|
||||
content: this._parseInlineMarkdown(text),
|
||||
items: [],
|
||||
})
|
||||
i++
|
||||
} else if (line.trim() === '') {
|
||||
i++
|
||||
if (i < lines.length && /^[\s]*[-*+]\s+/.test(lines[i])) {
|
||||
continue
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
block: {
|
||||
type: 'list',
|
||||
data: {
|
||||
style: 'unordered',
|
||||
items: items,
|
||||
},
|
||||
},
|
||||
nextIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
_parseOrderedList(lines, startIndex) {
|
||||
const items = []
|
||||
let i = startIndex
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
const match = line.match(/^[\s]*(\d+)\.\s+(.*)$/)
|
||||
|
||||
if (match) {
|
||||
const number = match[1]
|
||||
const text = match[2]
|
||||
|
||||
if (number === '1') {
|
||||
if (items.length > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
content: this._parseInlineMarkdown(text),
|
||||
items: [],
|
||||
})
|
||||
i++
|
||||
} else if (line.trim() === '') {
|
||||
i++
|
||||
if (i < lines.length && /^[\s]*(\d+)\.\s+/.test(lines[i])) {
|
||||
continue
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
block: {
|
||||
type: 'list',
|
||||
data: {
|
||||
style: 'ordered',
|
||||
items: items,
|
||||
},
|
||||
},
|
||||
nextIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
_parseCodeBlock(lines, startIndex) {
|
||||
let i = startIndex + 1
|
||||
const codeLines = []
|
||||
let language = lines[startIndex].trim().substring(3).trim()
|
||||
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim().startsWith('```')) {
|
||||
i++
|
||||
break
|
||||
}
|
||||
codeLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
|
||||
return {
|
||||
block: {
|
||||
type: 'codeBox',
|
||||
data: {
|
||||
code: codeLines.join('\n'),
|
||||
language: language || 'plaintext',
|
||||
},
|
||||
},
|
||||
nextIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
_parseInlineMarkdown(text) {
|
||||
if (!text) return ''
|
||||
|
||||
let html = this._escapeHtml(text)
|
||||
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
|
||||
html = html.replace(/\*\*([^\*\n]+?)\*\*/g, '<b>$1</b>')
|
||||
html = html.replace(/__([^_\n]+?)__/g, '<b>$1</b>')
|
||||
|
||||
html = html.replace(/\*([^\*\n]+?)\*/g, '<i>$1</i>')
|
||||
html = html.replace(/(?<!\w)_([^_\n]+?)_(?!\w)/g, '<i>$1</i>')
|
||||
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
_escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
_togglePlaceholder() {
|
||||
const blocks = document.querySelectorAll(
|
||||
'.cdx-block.ce-paragraph[data-placeholder]'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const isDev = mode === 'development'
|
||||
console.log(mode, isDev)
|
||||
const frappeui = await importFrappeUIPlugin(isDev)
|
||||
|
||||
const config = {
|
||||
|
||||
+272
-295
@@ -920,16 +920,16 @@
|
||||
crelt "^1.0.5"
|
||||
|
||||
"@codemirror/state@6.x", "@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.3.tgz#256e256d466f49ed0879d462031de8bd541e1403"
|
||||
integrity sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19"
|
||||
integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==
|
||||
dependencies:
|
||||
"@marijn/find-cluster-break" "^1.0.0"
|
||||
|
||||
"@codemirror/view@6.x", "@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.37.0":
|
||||
version "6.39.10"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.10.tgz#ae0dfcb635fd307aa3b800e305c9f46152503dba"
|
||||
integrity sha512-QfT/PXhiiP76PxMnX0RQVPDQrqfRt9wr9QhInNHnEUu4PWoNS8QwwcIDEneXFChJv22y+Yu/Cz5lFMTPz+h16w==
|
||||
version "6.39.11"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.11.tgz#200aebef2074bfbbb7a3d5f0644c1b560d876b39"
|
||||
integrity sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.5.0"
|
||||
crelt "^1.0.6"
|
||||
@@ -1400,130 +1400,130 @@
|
||||
estree-walker "^1.0.1"
|
||||
picomatch "^2.2.2"
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28"
|
||||
integrity sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==
|
||||
"@rollup/rollup-android-arm-eabi@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz#067cfcd81f1c1bfd92aefe3ad5ef1523549d5052"
|
||||
integrity sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==
|
||||
|
||||
"@rollup/rollup-android-arm64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz#d3cfc675a40bbdec97bda6d7fe3b3b05f0e1cd93"
|
||||
integrity sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==
|
||||
"@rollup/rollup-android-arm64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz#85e39a44034d7d4e4fee2a1616f0bddb85a80517"
|
||||
integrity sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==
|
||||
|
||||
"@rollup/rollup-darwin-arm64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz#eb912b8f59dd47c77b3c50a78489013b1d6772b4"
|
||||
integrity sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==
|
||||
"@rollup/rollup-darwin-arm64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz#17d92fe98f2cc277b91101eb1528b7c0b6c00c54"
|
||||
integrity sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==
|
||||
|
||||
"@rollup/rollup-darwin-x64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz#e7d0839fdfd1276a1d34bc5ebbbd0dfd7d0b81a0"
|
||||
integrity sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==
|
||||
"@rollup/rollup-darwin-x64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz#89ae6c66b1451609bd1f297da9384463f628437d"
|
||||
integrity sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==
|
||||
|
||||
"@rollup/rollup-freebsd-arm64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz#7ff8118760f7351e48fd0cd3717ff80543d6aac8"
|
||||
integrity sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==
|
||||
"@rollup/rollup-freebsd-arm64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz#cdbdb9947b26e76c188a31238c10639347413628"
|
||||
integrity sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==
|
||||
|
||||
"@rollup/rollup-freebsd-x64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz#49d330dadbda1d4e9b86b4a3951b59928a9489a9"
|
||||
integrity sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==
|
||||
"@rollup/rollup-freebsd-x64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz#9b1458d07b6e040be16ee36d308a2c9520f7f7cc"
|
||||
integrity sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz#98c5f1f8b9776b4a36e466e2a1c9ed1ba52ef1b6"
|
||||
integrity sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==
|
||||
"@rollup/rollup-linux-arm-gnueabihf@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz#1d50ded7c965d5f125f5832c971ad5b287befef7"
|
||||
integrity sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz#b9acecd3672e742f70b0c8a94075c816a91ff040"
|
||||
integrity sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==
|
||||
"@rollup/rollup-linux-arm-musleabihf@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz#53597e319b7e65990d3bc2a5048097384814c179"
|
||||
integrity sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz#7a6ab06651bc29e18b09a50ed1a02bc972977c9b"
|
||||
integrity sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==
|
||||
"@rollup/rollup-linux-arm64-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz#597002909dec198ca4bdccb25f043d32db3d6283"
|
||||
integrity sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz#3c8c9072ba4a4d4ef1156b85ab9a2cbb57c1fad0"
|
||||
integrity sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==
|
||||
"@rollup/rollup-linux-arm64-musl@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz#286f0e0f799545ce288bdc5a7c777261fcba3d54"
|
||||
integrity sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz#17a7af13530f4e4a7b12cd26276c54307a84a8b0"
|
||||
integrity sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==
|
||||
"@rollup/rollup-linux-loong64-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz#1fab07fa1a4f8d3697735b996517f1bae0ba101b"
|
||||
integrity sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz#5cd7a900fd7b077ecd753e34a9b7ff1157fe70c1"
|
||||
integrity sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==
|
||||
"@rollup/rollup-linux-loong64-musl@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz#efc2cb143d6c067f95205482afb177f78ed9ea3d"
|
||||
integrity sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz#03a097e70243ddf1c07b59d3c20f38e6f6800539"
|
||||
integrity sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==
|
||||
"@rollup/rollup-linux-ppc64-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz#e8de8bd3463f96b92b7dfb7f151fd80ffe8a937c"
|
||||
integrity sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz#a5389873039d4650f35b4fa060d286392eb21a94"
|
||||
integrity sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==
|
||||
"@rollup/rollup-linux-ppc64-musl@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz#8c508fe28a239da83b3a9da75bcf093186e064b4"
|
||||
integrity sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz#789e60e7d6e2b76132d001ffb24ba80007fb17d0"
|
||||
integrity sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==
|
||||
"@rollup/rollup-linux-riscv64-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz#ff6d51976e0830732880770a9e18553136b8d92b"
|
||||
integrity sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz#3556fa88d139282e9a73c337c9a170f3c5fe7aa4"
|
||||
integrity sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==
|
||||
"@rollup/rollup-linux-riscv64-musl@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz#325fb35eefc7e81d75478318f0deee1e4a111493"
|
||||
integrity sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz#c085995b10143c16747a67f1a5487512b2ff04b2"
|
||||
integrity sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==
|
||||
"@rollup/rollup-linux-s390x-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz#37410fabb5d3ba4ad34abcfbe9ba9b6288413f30"
|
||||
integrity sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz#9563a5419dd2604841bad31a39ccfdd2891690fb"
|
||||
integrity sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==
|
||||
"@rollup/rollup-linux-x64-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz#8ef907a53b2042068fc03fcc6a641e2b02276eca"
|
||||
integrity sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==
|
||||
|
||||
"@rollup/rollup-linux-x64-musl@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz#691bb06e6269a8959c13476b0cd2aa7458facb31"
|
||||
integrity sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==
|
||||
"@rollup/rollup-linux-x64-musl@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz#61b9ba09ea219e0174b3f35a6ad2afc94bdd5662"
|
||||
integrity sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==
|
||||
|
||||
"@rollup/rollup-openbsd-x64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz#223e71224746a59ce6d955bbc403577bb5a8be9d"
|
||||
integrity sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==
|
||||
"@rollup/rollup-openbsd-x64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz#fc4e54133134c1787d0b016ffdd5aeb22a5effd3"
|
||||
integrity sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==
|
||||
|
||||
"@rollup/rollup-openharmony-arm64@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz#0817e5d8ecbfeb8b7939bf58f8ce3c9dd67fce77"
|
||||
integrity sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==
|
||||
"@rollup/rollup-openharmony-arm64@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz#959ae225b1eeea0cc5b7c9f88e4834330fb6cd09"
|
||||
integrity sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz#de56d8f2013c84570ef5fb917aae034abda93e4a"
|
||||
integrity sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==
|
||||
"@rollup/rollup-win32-arm64-msvc@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz#842acd38869fa1cbdbc240c76c67a86f93444c27"
|
||||
integrity sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz#659aff5244312475aeea2c9479a6c7d397b517bf"
|
||||
integrity sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==
|
||||
"@rollup/rollup-win32-ia32-msvc@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz#7ab654def4042df44cb29f8ed9d5044e850c66d5"
|
||||
integrity sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz#2cb09549cbb66c1b979f9238db6dd454cac14a88"
|
||||
integrity sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==
|
||||
"@rollup/rollup-win32-x64-gnu@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz#7426cdec1b01d2382ffd5cda83cbdd1c8efb3ca6"
|
||||
integrity sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc@4.55.1":
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b"
|
||||
integrity sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==
|
||||
"@rollup/rollup-win32-x64-msvc@4.56.0":
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz#9eec0212732a432c71bde0350bc40b673d15b2db"
|
||||
integrity sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==
|
||||
|
||||
"@socket.io/component-emitter@~3.1.0":
|
||||
version "3.1.2"
|
||||
@@ -1896,9 +1896,9 @@
|
||||
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
|
||||
|
||||
"@types/node@*":
|
||||
version "25.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.8.tgz#e54e00f94fe1db2497b3e42d292b8376a2678c8d"
|
||||
integrity sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==
|
||||
version "25.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.10.tgz#4864459c3c9459376b8b75fd051315071c8213e7"
|
||||
integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==
|
||||
dependencies:
|
||||
undici-types "~7.16.0"
|
||||
|
||||
@@ -1919,11 +1919,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
|
||||
integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
|
||||
|
||||
"@types/web-bluetooth@^0.0.17":
|
||||
version "0.0.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz#5c9f3c617f64a9735d7b72a7cc671e166d900c40"
|
||||
integrity sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==
|
||||
|
||||
"@types/web-bluetooth@^0.0.20":
|
||||
version "0.0.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
|
||||
@@ -1953,100 +1948,90 @@
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.3.tgz#164b36653910d27c130cf6c945b4bd9bde5bcbee"
|
||||
integrity sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA==
|
||||
|
||||
"@vue/compiler-core@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz#1a91ea90980528bedff7b1c292690bfb30612485"
|
||||
integrity sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==
|
||||
"@vue/compiler-core@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz#ce4402428e26095586eb889c41f6e172eb3960bd"
|
||||
integrity sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.28.5"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/shared" "3.5.27"
|
||||
entities "^7.0.0"
|
||||
estree-walker "^2.0.2"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
"@vue/compiler-dom@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz#66c36b6ed8bdf43236d7188ea332bc9d078eb286"
|
||||
integrity sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==
|
||||
"@vue/compiler-dom@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz#32b2bc87f0a652c253986796ace0ed6213093af8"
|
||||
integrity sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==
|
||||
dependencies:
|
||||
"@vue/compiler-core" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/compiler-core" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
|
||||
"@vue/compiler-sfc@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz#fb1c6c4bf9a9e22bb169e039e19437cb6995917a"
|
||||
integrity sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==
|
||||
"@vue/compiler-sfc@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz#84651b8816bf8e7d6e62fddd14db86efd6d6f1b6"
|
||||
integrity sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.28.5"
|
||||
"@vue/compiler-core" "3.5.26"
|
||||
"@vue/compiler-dom" "3.5.26"
|
||||
"@vue/compiler-ssr" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/compiler-core" "3.5.27"
|
||||
"@vue/compiler-dom" "3.5.27"
|
||||
"@vue/compiler-ssr" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
estree-walker "^2.0.2"
|
||||
magic-string "^0.30.21"
|
||||
postcss "^8.5.6"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
"@vue/compiler-ssr@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz#f6e94bccbb5339180779036ddfb614f998a197ea"
|
||||
integrity sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==
|
||||
"@vue/compiler-ssr@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz#b480cad09dacf8f3d9c82b9843402f1a803baee7"
|
||||
integrity sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==
|
||||
dependencies:
|
||||
"@vue/compiler-dom" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/compiler-dom" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
|
||||
"@vue/devtools-api@^6.5.0":
|
||||
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4":
|
||||
version "6.6.4"
|
||||
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
|
||||
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
||||
|
||||
"@vue/reactivity@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.26.tgz#59a1edf566dc80133c1c26c93711c877e8602c48"
|
||||
integrity sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==
|
||||
"@vue/reactivity@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.27.tgz#d870557de1389a27b8abcb7cbfa30978dc69a000"
|
||||
integrity sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==
|
||||
dependencies:
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/shared" "3.5.27"
|
||||
|
||||
"@vue/runtime-core@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz#3f2c040bcf8018c03a1ab5adb0d788c13c986f0e"
|
||||
integrity sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==
|
||||
"@vue/runtime-core@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.27.tgz#bb43744ed070166c7d581b849ac22b71a9ccf127"
|
||||
integrity sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==
|
||||
dependencies:
|
||||
"@vue/reactivity" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/reactivity" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
|
||||
"@vue/runtime-dom@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz#5954848614883948ecc1f631a67b32cc32f81936"
|
||||
integrity sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==
|
||||
"@vue/runtime-dom@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz#392513252c7ca7e5277240fdc70b8093449127f5"
|
||||
integrity sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==
|
||||
dependencies:
|
||||
"@vue/reactivity" "3.5.26"
|
||||
"@vue/runtime-core" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/reactivity" "3.5.27"
|
||||
"@vue/runtime-core" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
csstype "^3.2.3"
|
||||
|
||||
"@vue/server-renderer@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz#269055497fcc75b3984063f866f17c748b565ef4"
|
||||
integrity sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==
|
||||
"@vue/server-renderer@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.27.tgz#8137d0d7ec3b59d5992bb04c553775d209dddba7"
|
||||
integrity sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==
|
||||
dependencies:
|
||||
"@vue/compiler-ssr" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/compiler-ssr" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
|
||||
"@vue/shared@3.5.26":
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.26.tgz#1e02ef2d64aced818cd31d81ce5175711dc90a9f"
|
||||
integrity sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==
|
||||
|
||||
"@vueuse/core@10.4.1":
|
||||
version "10.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.4.1.tgz#fc2c8a83a571c207aaedbe393b22daa6d35123f2"
|
||||
integrity sha512-DkHIfMIoSIBjMgRRvdIvxsyboRZQmImofLyOHADqiVbQVilP8VVHDhBX2ZqoItOgu7dWa8oXiNnScOdPLhdEXg==
|
||||
dependencies:
|
||||
"@types/web-bluetooth" "^0.0.17"
|
||||
"@vueuse/metadata" "10.4.1"
|
||||
"@vueuse/shared" "10.4.1"
|
||||
vue-demi ">=0.14.5"
|
||||
"@vue/shared@3.5.27":
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.27.tgz#33a63143d8fb9ca1b3efbc7ecf9bd0ab05f7e06e"
|
||||
integrity sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==
|
||||
|
||||
"@vueuse/core@^10.11.0", "@vueuse/core@^10.4.1":
|
||||
version "10.11.1"
|
||||
@@ -2068,28 +2053,29 @@
|
||||
"@vueuse/shared" "12.8.2"
|
||||
vue "^3.5.13"
|
||||
|
||||
"@vueuse/core@^14.1.0":
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-14.1.0.tgz#274e98e591a505333b7dfb2bcaf7b4530a10b9c9"
|
||||
integrity sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==
|
||||
dependencies:
|
||||
"@types/web-bluetooth" "^0.0.21"
|
||||
"@vueuse/metadata" "14.1.0"
|
||||
"@vueuse/shared" "14.1.0"
|
||||
|
||||
"@vueuse/metadata@10.11.1":
|
||||
version "10.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
|
||||
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
|
||||
|
||||
"@vueuse/metadata@10.4.1":
|
||||
version "10.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.4.1.tgz#9d2ff5c67abf17a8c07865c2413fbd0e92f7b7d7"
|
||||
integrity sha512-2Sc8X+iVzeuMGHr6O2j4gv/zxvQGGOYETYXEc41h0iZXIRnRbJZGmY/QP8dvzqUelf8vg0p/yEA5VpCEu+WpZg==
|
||||
|
||||
"@vueuse/metadata@12.8.2":
|
||||
version "12.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
|
||||
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
|
||||
|
||||
"@vueuse/router@12.7.0":
|
||||
version "12.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-12.7.0.tgz#b349b66e337057bb489b6d64d2dab044d41ca74d"
|
||||
integrity sha512-Jp6dIel54oc2nh++zqjY06ipCcTT6YWDCNQ8dSSnqRwx90wIl7w7MQP7Wpp1wrDwXEoqhelfeZf2gjfrkAhq3g==
|
||||
dependencies:
|
||||
"@vueuse/shared" "12.7.0"
|
||||
vue "^3.5.13"
|
||||
"@vueuse/metadata@14.1.0":
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.1.0.tgz#70fc2e94775e4a07369f11f86f6f0a465b04a381"
|
||||
integrity sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==
|
||||
|
||||
"@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0":
|
||||
version "10.11.1"
|
||||
@@ -2098,20 +2084,6 @@
|
||||
dependencies:
|
||||
vue-demi ">=0.14.8"
|
||||
|
||||
"@vueuse/shared@10.4.1":
|
||||
version "10.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.4.1.tgz#d5ce33033c156efb60664b5d6034d6cd4e2f530c"
|
||||
integrity sha512-vz5hbAM4qA0lDKmcr2y3pPdU+2EVw/yzfRsBdu+6+USGa4PxqSQRYIUC9/NcT06y+ZgaTsyURw2I9qOFaaXHAg==
|
||||
dependencies:
|
||||
vue-demi ">=0.14.5"
|
||||
|
||||
"@vueuse/shared@12.7.0":
|
||||
version "12.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.7.0.tgz#0c573789069818a2e25ddae3ab64b536c614537b"
|
||||
integrity sha512-coLlUw2HHKsm7rPN6WqHJQr18WymN4wkA/3ThFaJ4v4gWGWAQQGK+MJxLuJTBs4mojQiazlVWAKNJNpUWGRkNw==
|
||||
dependencies:
|
||||
vue "^3.5.13"
|
||||
|
||||
"@vueuse/shared@12.8.2", "@vueuse/shared@^12.5.0":
|
||||
version "12.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
|
||||
@@ -2119,6 +2091,11 @@
|
||||
dependencies:
|
||||
vue "^3.5.13"
|
||||
|
||||
"@vueuse/shared@14.1.0":
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-14.1.0.tgz#49b2face86a9c0c52e20eaf4c732a0223276c11f"
|
||||
integrity sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==
|
||||
|
||||
"@yr/monotone-cubic-spline@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
|
||||
@@ -2288,9 +2265,9 @@ base64-js@^1.3.1:
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.9.0:
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz#3b6af0bc032445bca04de58caa9a87cfe921cbb3"
|
||||
integrity sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==
|
||||
version "2.9.17"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz#9d6019766cd7eba738cb5f32c84b9f937cc87780"
|
||||
integrity sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
version "2.3.0"
|
||||
@@ -2328,7 +2305,7 @@ braces@^3.0.3, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
browserslist@^4.19.1, browserslist@^4.24.0, browserslist@^4.28.0:
|
||||
browserslist@^4.19.1, browserslist@^4.24.0, browserslist@^4.28.1:
|
||||
version "4.28.1"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
|
||||
integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
|
||||
@@ -2389,9 +2366,9 @@ camelcase-css@^2.0.1:
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001759:
|
||||
version "1.0.30001764"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz#03206c56469f236103b90f9ae10bcb8b9e1f6005"
|
||||
integrity sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==
|
||||
version "1.0.30001765"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz#4a78d8a797fd4124ebaab2043df942eb091648ee"
|
||||
integrity sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==
|
||||
|
||||
chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
@@ -2506,16 +2483,16 @@ convert-source-map@^2.0.0:
|
||||
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
|
||||
|
||||
core-js-compat@^3.43.0:
|
||||
version "3.47.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.47.0.tgz#698224bbdbb6f2e3f39decdda4147b161e3772a3"
|
||||
integrity sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==
|
||||
version "3.48.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.48.0.tgz#7efbe1fc1cbad44008190462217cc5558adaeaa6"
|
||||
integrity sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==
|
||||
dependencies:
|
||||
browserslist "^4.28.0"
|
||||
browserslist "^4.28.1"
|
||||
|
||||
core-js@^3.1.3, core-js@^3.26.1:
|
||||
version "3.47.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17"
|
||||
integrity sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==
|
||||
version "3.48.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.48.0.tgz#1f813220a47bbf0e667e3885c36cd6f0593bf14d"
|
||||
integrity sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==
|
||||
|
||||
crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6:
|
||||
version "1.0.6"
|
||||
@@ -2689,9 +2666,9 @@ ejs@^3.1.6:
|
||||
jake "^10.8.5"
|
||||
|
||||
electron-to-chromium@^1.5.263:
|
||||
version "1.5.267"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7"
|
||||
integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
|
||||
version "1.5.277"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz#7164191a07bf32a7e646e68334f402dd60629821"
|
||||
integrity sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==
|
||||
|
||||
engine.io-client@~6.5.2:
|
||||
version "6.5.4"
|
||||
@@ -2726,9 +2703,9 @@ entities@^4.4.0:
|
||||
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
|
||||
|
||||
entities@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.0.tgz#2ae4e443f3f17d152d3f5b0f79b932c1e59deb7a"
|
||||
integrity sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
|
||||
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
|
||||
|
||||
es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9:
|
||||
version "1.24.1"
|
||||
@@ -2977,10 +2954,10 @@ fraction.js@^4.1.2:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.256:
|
||||
version "0.1.256"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.256.tgz#c14756eda75ca01ada034559e8bd2f91bcfe6dff"
|
||||
integrity sha512-zj8n6KXpMv/0h1NcaCsjFLP8QBnofDEBJgQa+xECU0/jbq4gSqNhFOkcx788qNL+vmBo9frywTeXwDpl7hUCZA==
|
||||
frappe-ui@^0.1.261:
|
||||
version "0.1.261"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.261.tgz#d6919c713a37ed8a2bdb667707dba9ece4956c6d"
|
||||
integrity sha512-sEdEAgjAkrTERYWk5HBOQuKa7/xuex/X8/Y/hCYFbEThwwy2ZWmQOCsTNyOCjXAn7lyV49Ues/TW01koIq/ysQ==
|
||||
dependencies:
|
||||
"@floating-ui/vue" "^1.1.6"
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
@@ -3613,9 +3590,9 @@ lodash.sortby@^4.7.0:
|
||||
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
|
||||
|
||||
lodash@^4.17.20:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
version "4.17.23"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
|
||||
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
|
||||
|
||||
log-symbols@^4.1.0:
|
||||
version "4.1.0"
|
||||
@@ -4032,9 +4009,9 @@ postcss@^8.4.32, postcss@^8.4.47, postcss@^8.5.6:
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
prettier@^3.3.2:
|
||||
version "3.7.4"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
|
||||
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
||||
|
||||
pretty-bytes@^5.3.0:
|
||||
version "5.6.0"
|
||||
@@ -4115,9 +4092,9 @@ prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3:
|
||||
w3c-keyname "^2.2.0"
|
||||
|
||||
prosemirror-markdown@^1.13.1:
|
||||
version "1.13.2"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz#863eb3fd5f57a444e4378174622b562735b1c503"
|
||||
integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==
|
||||
version "1.13.3"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.3.tgz#cf38e98f10c432b906bfcc7179c2e3ab58f49362"
|
||||
integrity sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==
|
||||
dependencies:
|
||||
"@types/markdown-it" "^14.0.0"
|
||||
markdown-it "^14.0.0"
|
||||
@@ -4185,16 +4162,16 @@ prosemirror-trailing-node@^3.0.0:
|
||||
escape-string-regexp "^4.0.0"
|
||||
|
||||
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.7.3:
|
||||
version "1.10.5"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz#4cf9fe5dcbdbfebd62499f24386e7cec9bc9979b"
|
||||
integrity sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz#f5c5050354423dc83c6b083f6f1959ec86a3f9ba"
|
||||
integrity sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==
|
||||
dependencies:
|
||||
prosemirror-model "^1.21.0"
|
||||
|
||||
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.41.4:
|
||||
version "1.41.4"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.4.tgz#4e1b3e90accc0eebe3bddb497a40ce54e4de722d"
|
||||
integrity sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==
|
||||
version "1.41.5"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.5.tgz#3e152d14af633f2f5a73aba24e6130c63f643b2b"
|
||||
integrity sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==
|
||||
dependencies:
|
||||
prosemirror-model "^1.20.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
@@ -4395,37 +4372,37 @@ rollup@^2.43.1:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
rollup@^4.2.0:
|
||||
version "4.55.1"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144"
|
||||
integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==
|
||||
version "4.56.0"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.56.0.tgz#65959d13cfbd7e48b8868c05165b1738f0143862"
|
||||
integrity sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==
|
||||
dependencies:
|
||||
"@types/estree" "1.0.8"
|
||||
optionalDependencies:
|
||||
"@rollup/rollup-android-arm-eabi" "4.55.1"
|
||||
"@rollup/rollup-android-arm64" "4.55.1"
|
||||
"@rollup/rollup-darwin-arm64" "4.55.1"
|
||||
"@rollup/rollup-darwin-x64" "4.55.1"
|
||||
"@rollup/rollup-freebsd-arm64" "4.55.1"
|
||||
"@rollup/rollup-freebsd-x64" "4.55.1"
|
||||
"@rollup/rollup-linux-arm-gnueabihf" "4.55.1"
|
||||
"@rollup/rollup-linux-arm-musleabihf" "4.55.1"
|
||||
"@rollup/rollup-linux-arm64-gnu" "4.55.1"
|
||||
"@rollup/rollup-linux-arm64-musl" "4.55.1"
|
||||
"@rollup/rollup-linux-loong64-gnu" "4.55.1"
|
||||
"@rollup/rollup-linux-loong64-musl" "4.55.1"
|
||||
"@rollup/rollup-linux-ppc64-gnu" "4.55.1"
|
||||
"@rollup/rollup-linux-ppc64-musl" "4.55.1"
|
||||
"@rollup/rollup-linux-riscv64-gnu" "4.55.1"
|
||||
"@rollup/rollup-linux-riscv64-musl" "4.55.1"
|
||||
"@rollup/rollup-linux-s390x-gnu" "4.55.1"
|
||||
"@rollup/rollup-linux-x64-gnu" "4.55.1"
|
||||
"@rollup/rollup-linux-x64-musl" "4.55.1"
|
||||
"@rollup/rollup-openbsd-x64" "4.55.1"
|
||||
"@rollup/rollup-openharmony-arm64" "4.55.1"
|
||||
"@rollup/rollup-win32-arm64-msvc" "4.55.1"
|
||||
"@rollup/rollup-win32-ia32-msvc" "4.55.1"
|
||||
"@rollup/rollup-win32-x64-gnu" "4.55.1"
|
||||
"@rollup/rollup-win32-x64-msvc" "4.55.1"
|
||||
"@rollup/rollup-android-arm-eabi" "4.56.0"
|
||||
"@rollup/rollup-android-arm64" "4.56.0"
|
||||
"@rollup/rollup-darwin-arm64" "4.56.0"
|
||||
"@rollup/rollup-darwin-x64" "4.56.0"
|
||||
"@rollup/rollup-freebsd-arm64" "4.56.0"
|
||||
"@rollup/rollup-freebsd-x64" "4.56.0"
|
||||
"@rollup/rollup-linux-arm-gnueabihf" "4.56.0"
|
||||
"@rollup/rollup-linux-arm-musleabihf" "4.56.0"
|
||||
"@rollup/rollup-linux-arm64-gnu" "4.56.0"
|
||||
"@rollup/rollup-linux-arm64-musl" "4.56.0"
|
||||
"@rollup/rollup-linux-loong64-gnu" "4.56.0"
|
||||
"@rollup/rollup-linux-loong64-musl" "4.56.0"
|
||||
"@rollup/rollup-linux-ppc64-gnu" "4.56.0"
|
||||
"@rollup/rollup-linux-ppc64-musl" "4.56.0"
|
||||
"@rollup/rollup-linux-riscv64-gnu" "4.56.0"
|
||||
"@rollup/rollup-linux-riscv64-musl" "4.56.0"
|
||||
"@rollup/rollup-linux-s390x-gnu" "4.56.0"
|
||||
"@rollup/rollup-linux-x64-gnu" "4.56.0"
|
||||
"@rollup/rollup-linux-x64-musl" "4.56.0"
|
||||
"@rollup/rollup-openbsd-x64" "4.56.0"
|
||||
"@rollup/rollup-openharmony-arm64" "4.56.0"
|
||||
"@rollup/rollup-win32-arm64-msvc" "4.56.0"
|
||||
"@rollup/rollup-win32-ia32-msvc" "4.56.0"
|
||||
"@rollup/rollup-win32-x64-gnu" "4.56.0"
|
||||
"@rollup/rollup-win32-x64-msvc" "4.56.0"
|
||||
fsevents "~2.3.2"
|
||||
|
||||
rope-sequence@^1.3.0:
|
||||
@@ -4797,9 +4774,9 @@ tempy@^0.6.0:
|
||||
unique-string "^2.0.0"
|
||||
|
||||
terser@^5.0.0:
|
||||
version "5.44.1"
|
||||
resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.1.tgz#e391e92175c299b8c284ad6ded609e37303b0a9c"
|
||||
integrity sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==
|
||||
version "5.46.0"
|
||||
resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695"
|
||||
integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==
|
||||
dependencies:
|
||||
"@jridgewell/source-map" "^0.3.3"
|
||||
acorn "^8.15.0"
|
||||
@@ -4940,9 +4917,9 @@ uc.micro@^2.0.0, uc.micro@^2.1.0:
|
||||
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
|
||||
|
||||
ufo@^1.6.1:
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.2.tgz#aaf4d46b98425b2fb5031abe8d65ca069e93e755"
|
||||
integrity sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==
|
||||
version "1.6.3"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f"
|
||||
integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==
|
||||
|
||||
unbox-primitive@^1.1.0:
|
||||
version "1.1.0"
|
||||
@@ -5169,7 +5146,7 @@ vue-codemirror@6.1.1:
|
||||
"@codemirror/state" "6.x"
|
||||
"@codemirror/view" "6.x"
|
||||
|
||||
vue-demi@*, vue-demi@>=0.13.0, vue-demi@>=0.14.5, vue-demi@>=0.14.8:
|
||||
vue-demi@*, vue-demi@>=0.13.0, vue-demi@>=0.14.8:
|
||||
version "0.14.10"
|
||||
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
|
||||
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
|
||||
@@ -5179,28 +5156,28 @@ vue-draggable-next@2.2.1:
|
||||
resolved "https://registry.yarnpkg.com/vue-draggable-next/-/vue-draggable-next-2.2.1.tgz#adbe98c74610cca8f4eb63f92042681f96920451"
|
||||
integrity sha512-EAMS1IRHF0kZO0o5PMOinsQsXIqsrKT1hKmbICxG3UEtn7zLFkLxlAtajcCcUTisNvQ6TtCB5COjD9a1raNADw==
|
||||
|
||||
vue-router@4.2.2:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.2.2.tgz#b0097b66d89ca81c0986be03da244c7b32a4fd81"
|
||||
integrity sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ==
|
||||
vue-router@^4.6.4:
|
||||
version "4.6.4"
|
||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.6.4.tgz#a0a9cb9ef811a106d249e4bb9313d286718020d8"
|
||||
integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==
|
||||
dependencies:
|
||||
"@vue/devtools-api" "^6.5.0"
|
||||
"@vue/devtools-api" "^6.6.4"
|
||||
|
||||
vue3-apexcharts@1.8.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf"
|
||||
integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==
|
||||
|
||||
vue@^3.5.0, vue@^3.5.13:
|
||||
version "3.5.26"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.26.tgz#03a0b17311e0e593d34b9358fa249b85e3a6d9fb"
|
||||
integrity sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==
|
||||
vue@^3.5.13, vue@^3.5.27:
|
||||
version "3.5.27"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.27.tgz#e55fd941b614459ab2228489bc19d1692e05876c"
|
||||
integrity sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==
|
||||
dependencies:
|
||||
"@vue/compiler-dom" "3.5.26"
|
||||
"@vue/compiler-sfc" "3.5.26"
|
||||
"@vue/runtime-dom" "3.5.26"
|
||||
"@vue/server-renderer" "3.5.26"
|
||||
"@vue/shared" "3.5.26"
|
||||
"@vue/compiler-dom" "3.5.27"
|
||||
"@vue/compiler-sfc" "3.5.27"
|
||||
"@vue/runtime-dom" "3.5.27"
|
||||
"@vue/server-renderer" "3.5.27"
|
||||
"@vue/shared" "3.5.27"
|
||||
|
||||
vuedraggable@4.1.0:
|
||||
version "4.1.0"
|
||||
@@ -5281,9 +5258,9 @@ which-collection@^1.0.2:
|
||||
is-weakset "^2.0.3"
|
||||
|
||||
which-typed-array@^1.1.16, which-typed-array@^1.1.19:
|
||||
version "1.1.19"
|
||||
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956"
|
||||
integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==
|
||||
version "1.1.20"
|
||||
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122"
|
||||
integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==
|
||||
dependencies:
|
||||
available-typed-arrays "^1.0.7"
|
||||
call-bind "^1.0.8"
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
|
||||
ALLOWED_PATHS = [
|
||||
"/api/method/ping",
|
||||
"/api/method/login",
|
||||
"/api/method/logout",
|
||||
"/api/method/frappe.core.doctype.communication.email.mark_email_as_seen",
|
||||
"/api/method/frappe.realtime.get_user_info",
|
||||
"/api/method/frappe.realtime.can_subscribe_doc",
|
||||
"/api/method/frappe.realtime.can_subscribe_doctype",
|
||||
"/api/method/frappe.realtime.has_permission",
|
||||
"/api/method/frappe.integrations.oauth2.authorize",
|
||||
"/api/method/frappe.integrations.oauth2.approve",
|
||||
"/api/method/frappe.integrations.oauth2.get_token",
|
||||
"/api/method/frappe.www.login.login_via_google",
|
||||
"/api/method/frappe.www.login.login_via_github",
|
||||
"/api/method/frappe.www.login.login_via_facebook",
|
||||
"/api/method/frappe.www.login.login_via_frappe",
|
||||
"/api/method/frappe.www.login.login_via_office365",
|
||||
"/api/method/frappe.www.login.login_via_salesforce",
|
||||
"/api/method/frappe.www.login.login_via_fairlogin",
|
||||
"/api/method/frappe.www.login.login_via_keycloak",
|
||||
"/api/method/frappe.www.login.custom",
|
||||
"/api/method/frappe.integrations.oauth2.openid_profile",
|
||||
"/api/method/frappe.website.doctype.web_page_view.web_page_view.make_view_log",
|
||||
"/api/method/upload_file",
|
||||
"/api/method/frappe.search.web_search",
|
||||
"/api/method/frappe.email.queue.unsubscribe",
|
||||
"/api/method/frappe.website.doctype.web_form.web_form.accept",
|
||||
"/api/method/frappe.core.doctype.user.user.test_password_strength",
|
||||
"/api/method/frappe.core.doctype.user.user.update_password",
|
||||
"/api/method/frappe.utils.telemetry.pulse.client.is_enabled",
|
||||
"/api/method/frappe.client.get_value",
|
||||
"/api/method/frappe.client.get_count",
|
||||
"/api/method/frappe.client.get",
|
||||
"/api/method/frappe.client.insert",
|
||||
"/api/method/frappe.client.set_value",
|
||||
"/api/method/frappe.client.delete",
|
||||
"/api/method/frappe.client.get_list",
|
||||
"/api/method/frappe.client.rename_doc",
|
||||
"/api/method/frappe.onboarding.get_onboarding_status",
|
||||
"/api/method/frappe.utils.print_format.download_pdf",
|
||||
"/api/method/frappe.desk.search.search_link",
|
||||
"/api/method/frappe.core.doctype.communication.email.make",
|
||||
"/api/method/frappe.core.doctype.user.user.reset_password",
|
||||
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_as_read",
|
||||
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_all_as_read",
|
||||
]
|
||||
|
||||
|
||||
def authenticate():
|
||||
if not frappe.conf.get("block_endpoints"):
|
||||
return
|
||||
|
||||
if frappe.form_dict.cmd:
|
||||
path = f"/api/method/{frappe.form_dict.cmd}"
|
||||
else:
|
||||
path = frappe.request.path
|
||||
|
||||
user_type = frappe.db.get_value("User", frappe.session.user, "user_type")
|
||||
if user_type == "System User":
|
||||
return
|
||||
|
||||
if not path.startswith("/api/"):
|
||||
return
|
||||
|
||||
if path.startswith("/lms") or path.startswith("/api/method/lms."):
|
||||
return
|
||||
|
||||
if is_server_script_path(path):
|
||||
return
|
||||
|
||||
if is_custom_app_endpoint(path):
|
||||
return
|
||||
|
||||
if path in ALLOWED_PATHS:
|
||||
return
|
||||
frappe.throw(f"Access not allowed for this URL: {path}", frappe.PermissionError)
|
||||
|
||||
|
||||
def is_server_script_path(path):
|
||||
endpoint = path.split("/api/method/")[-1]
|
||||
if frappe.db.exists("Server Script", {"script_type": "API", "api_method": endpoint, "disabled": 0}):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_custom_app_endpoint(path):
|
||||
allowed_custom_endpoints = frappe.conf.get("allowed_custom_endpoints", [])
|
||||
|
||||
if isinstance(allowed_custom_endpoints, str):
|
||||
try:
|
||||
parsed = json.loads(allowed_custom_endpoints)
|
||||
allowed_custom_endpoints = parsed if isinstance(parsed, list) else [allowed_custom_endpoints]
|
||||
except Exception:
|
||||
allowed_custom_endpoints = [allowed_custom_endpoints]
|
||||
|
||||
for endpoint in allowed_custom_endpoints:
|
||||
if endpoint in path:
|
||||
return True
|
||||
return False
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user