chore: resolved conflicts

This commit is contained in:
Jannat Patel
2026-01-30 12:30:53 +05:30
158 changed files with 14920 additions and 10459 deletions
+3
View File
@@ -71,6 +71,9 @@ jobs:
- name: setup requirements - name: setup requirements
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench setup requirements --dev 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 - name: allow tests
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench --site frappe.local set-config allow_tests true run: bench --site frappe.local set-config allow_tests true
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"branches": ["develop"], "branches": ["main"],
"plugins": [ "plugins": [
"@semantic-release/commit-analyzer", { "@semantic-release/commit-analyzer", {
"preset": "angular" "preset": "angular"
+20 -17
View File
@@ -11,7 +11,6 @@ describe("Course Creation", () => {
cy.get("button").contains("Create").click(); cy.get("button").contains("Create").click();
cy.get("span").contains("New Course").click(); cy.get("span").contains("New Course").click();
cy.wait(500); cy.wait(500);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course"); cy.get("label").contains("Title").type("Test Course");
cy.get("label") 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 */ /* Instructor */
cy.get("label") cy.get("label")
.contains("Instructors") .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").click();
cy.get("label").contains("Published On").type("2021-01-01"); cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click(); cy.button("Save").click();
// Add Chapter // Add Chapter
cy.wait(1000); cy.wait(1000);
cy.button("Add Chapter").click(); cy.button("Add").click();
cy.wait(1000); cy.wait(1000);
cy.get("[data-dismissable-layer]") cy.get("[data-dismissable-layer]")
+1 -1
View File
@@ -54,7 +54,6 @@ declare module 'vue' {
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default'] CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default'] CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.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'] CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default'] CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.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'] NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
Notes: typeof import('./src/components/Notes/Notes.vue')['default'] Notes: typeof import('./src/components/Notes/Notes.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.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'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default'] PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default'] PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
+4 -5
View File
@@ -25,8 +25,7 @@
"@editorjs/paragraph": "2.11.3", "@editorjs/paragraph": "2.11.3",
"@editorjs/simple-image": "1.6.0", "@editorjs/simple-image": "1.6.0",
"@editorjs/table": "2.4.2", "@editorjs/table": "2.4.2",
"@vueuse/core": "10.4.1", "@vueuse/core": "^14.1.0",
"@vueuse/router": "12.7.0",
"ace-builds": "1.36.2", "ace-builds": "1.36.2",
"apexcharts": "4.3.0", "apexcharts": "4.3.0",
"chart.js": "4.4.1", "chart.js": "4.4.1",
@@ -34,7 +33,7 @@
"dayjs": "1.11.10", "dayjs": "1.11.10",
"dompurify": "3.2.6", "dompurify": "3.2.6",
"feather-icons": "4.28.0", "feather-icons": "4.28.0",
"frappe-ui": "^0.1.256", "frappe-ui": "^0.1.261",
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0", "lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0", "markdown-it": "14.0.0",
@@ -43,11 +42,11 @@
"socket.io-client": "4.7.2", "socket.io-client": "4.7.2",
"thememirror": "2.0.1", "thememirror": "2.0.1",
"typescript": "5.7.2", "typescript": "5.7.2",
"vue": "^3.5.0", "vue": "^3.5.27",
"vue-chartjs": "5.3.0", "vue-chartjs": "5.3.0",
"vue-codemirror": "6.1.1", "vue-codemirror": "6.1.1",
"vue-draggable-next": "2.2.1", "vue-draggable-next": "2.2.1",
"vue-router": "4.2.2", "vue-router": "^4.6.4",
"vue3-apexcharts": "1.8.0", "vue3-apexcharts": "1.8.0",
"vuedraggable": "4.1.0" "vuedraggable": "4.1.0"
}, },
+1 -3
View File
@@ -10,9 +10,8 @@
<script setup> <script setup>
import { FrappeUIProvider } from 'frappe-ui' import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import DesktopLayout from './components/DesktopLayout.vue' import DesktopLayout from './components/DesktopLayout.vue'
@@ -23,7 +22,6 @@ import InstallPrompt from './components/InstallPrompt.vue'
const { isMobile } = useScreenSize() const { isMobile } = useScreenSize()
const router = useRouter() const router = useRouter()
const noSidebar = ref(false) const noSidebar = ref(false)
const { userResource } = usersStore()
const { settings } = useSettings() const { settings } = useSettings()
router.beforeEach((to, from, next) => { 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.
-152
View File
@@ -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");
}
+48 -53
View File
@@ -26,8 +26,8 @@
</div> </div>
<div class="flex flex-col overflow-y-auto"> <div class="flex flex-col overflow-y-auto">
<div class="p-5"> <div class="p-5 space-y-5">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between">
<div class="font-semibold text-ink-gray-9"> <div class="font-semibold text-ink-gray-9">
{{ __('Submission') }} {{ __('Submission') }}
</div> </div>
@@ -53,7 +53,7 @@
!['Pass', 'Fail'].includes(submissionResource.doc?.status) && !['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name 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.") }} {{ __("You've successfully submitted the assignment.") }}
{{ {{
@@ -63,12 +63,17 @@
}} }}
{{ __('Feel free to make edits to your submission if needed.') }} {{ __('Feel free to make edits to your submission if needed.') }}
</div> </div>
<div v-if="showUploader()"> <div v-if="showUploader()" class="border rounded-lg p-3">
<div class="text-xs text-ink-gray-5 mt-1 mb-2"> <div class="font-semibold mb-2">
{{ __('Add your assignment as {0}').format(assignment.data.type) }} {{ __('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> </div>
<FileUploader <FileUploader
v-if="!submissionFile" v-if="!submissionResource.doc?.assignment_attachment"
:fileTypes="getType()" :fileTypes="getType()"
:uploadArgs="{ :uploadArgs="{
private: true, private: true,
@@ -87,21 +92,24 @@
</template> </template>
</FileUploader> </FileUploader>
<div v-else> <div v-else>
<div class="flex text-ink-gray-7"> <div class="flex items-center text-ink-gray-7">
<div class="border self-start rounded-md p-2 mr-2"> <a
:href="submissionResource.doc.assignment_attachment"
target="_blank"
class="cursor-pointer !no-underline text-sm leading-5"
>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" /> <FileText class="h-5 w-5 stroke-1.5" />
</div> </div>
<a <span>
:href="submissionFile.file_url" {{
target="_blank" submissionResource.doc.assignment_attachment
class="flex flex-col cursor-pointer !no-underline" .split('/')
> .pop()
<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> </span>
</div>
</a> </a>
<X <X
v-if="canModifyAssignment" v-if="canModifyAssignment"
@@ -142,13 +150,13 @@
user.data?.name == submissionResource.doc?.owner && user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments 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"> <div class="text-ink-gray-5 mb-4">
{{ __('Comments by Evaluator') }}: {{ __('Comments by Evaluator') }}
</div> </div>
<div <div
class="leading-5 text-ink-gray-9" class="leading-6 text-ink-gray-9"
v-html="submissionResource.doc.comments" v-html="submissionResource.doc.comments"
></div> ></div>
</div> </div>
@@ -204,10 +212,8 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const submissionFile = ref(null)
const answer = ref(null) const answer = ref(null)
const comments = ref(null) const comments = ref(null)
const router = useRouter() const router = useRouter()
@@ -266,9 +272,7 @@ const newSubmission = createResource({
assignment: props.assignmentID, assignment: props.assignmentID,
member: user.data?.name, member: user.data?.name,
} }
if (showUploader()) { if (!showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
doc.answer = answer.value doc.answer = answer.value
} }
return { 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({ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission', doctype: 'LMS Assignment Submission',
name: props.submissionName, name: props.submissionName,
@@ -302,11 +293,6 @@ const submissionResource = createDocumentResource({
watch(submissionResource, () => { watch(submissionResource, () => {
if (submissionResource.doc) { if (submissionResource.doc) {
if (submissionResource.doc.assignment_attachment) {
imageResource.reload({
image: submissionResource.doc.assignment_attachment,
})
}
if (submissionResource.doc.answer) { if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer answer.value = submissionResource.doc.answer
} }
@@ -315,7 +301,10 @@ watch(submissionResource, () => {
} }
if (submissionResource.isDirty) { if (submissionResource.isDirty) {
isDirty.value = true isDirty.value = true
} else if (showUploader() && !submissionFile.value) { } else if (
showUploader() &&
!submissionResource.doc.assignment_attachment
) {
isDirty.value = true isDirty.value = true
} else if (!showUploader() && !answer.value) { } else if (!showUploader() && !answer.value) {
isDirty.value = true isDirty.value = true
@@ -325,11 +314,17 @@ watch(submissionResource, () => {
} }
}) })
watch(submissionFile, () => { watch(
if (props.submissionName == 'new' && submissionFile.value) { () => submissionResource.doc,
() => {
if (
props.submissionName == 'new' &&
submissionResource.doc?.assignment_attachment
) {
isDirty.value = true isDirty.value = true
} }
}) }
)
const submitAssignment = () => { const submitAssignment = () => {
if (props.submissionName != 'new') { if (props.submissionName != 'new') {
@@ -341,13 +336,13 @@ const submitAssignment = () => {
submissionResource.setValue.submit( submissionResource.setValue.submit(
{ {
...submissionResource.doc, ...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator, evaluator: evaluator,
comments: comments.value, comments: comments.value,
answer: answer.value, answer: answer.value,
}, },
{ {
onSuccess(data) { onSuccess(data) {
isDirty.value = false
toast.success(__('Changes saved successfully')) toast.success(__('Changes saved successfully'))
}, },
} }
@@ -388,7 +383,7 @@ const addNewSubmission = () => {
const saveSubmission = (file) => { const saveSubmission = (file) => {
isDirty.value = true isDirty.value = true
submissionFile.value = file submissionResource.doc.assignment_attachment = file.file_url
} }
const markLessonProgress = () => { const markLessonProgress = () => {
@@ -439,7 +434,7 @@ const validateFile = (file) => {
const removeSubmission = () => { const removeSubmission = () => {
isDirty.value = true isDirty.value = true
submissionFile.value = null submissionResource.doc.assignment_attachment = ''
} }
const canGradeSubmission = computed(() => { const canGradeSubmission = computed(() => {
+12 -13
View File
@@ -1,7 +1,7 @@
<template> <template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div <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="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class=" :class="
batch.data.amount || batch.data.courses.length batch.data.amount || batch.data.courses.length
@@ -9,16 +9,16 @@
: 'w-fit mb-4' : 'w-fit mb-4'
" "
> >
{{ seats_left }} {{ batch.data.seats_left }}
<span v-if="seats_left > 1"> <span v-if="batch.data.seats_left > 1">
{{ __('Seats Left') }} {{ __('Seats Left') }}
</span> </span>
<span v-else-if="seats_left == 1"> <span v-else-if="batch.data.seats_left == 1">
{{ __('Seat Left') }} {{ __('Seat Left') }}
</span> </span>
</div> </div>
<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" class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
> >
{{ __('Sold Out') }} {{ __('Sold Out') }}
@@ -54,6 +54,7 @@
{{ batch.data.timezone }} {{ batch.data.timezone }}
</span> </span>
</div> </div>
<div v-if="!readOnlyMode"> <div v-if="!readOnlyMode">
<router-link <router-link
v-if="canAccessBatch" 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(() => { 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(() => { const isModerator = computed(() => {
@@ -218,6 +214,9 @@ const isInstructor = computed(() => {
}) })
const canAccessBatch = computed(() => { const canAccessBatch = computed(() => {
if (!user.data) {
return false
}
return isModerator.value || isStudent.value || isEvaluator.value return isModerator.value || isStudent.value || isEvaluator.value
}) })
+3 -3
View File
@@ -43,7 +43,7 @@
<ListRow <ListRow
:row="row" :row="row"
v-for="row in students.data" v-for="row in students.data"
class="group cursor-pointer" class="group cursor-pointer hover:bg-surface-gray-2 rounded"
@click="openStudentProgressModal(row)" @click="openStudentProgressModal(row)"
> >
<template #default="{ column, item }"> <template #default="{ column, item }">
@@ -88,7 +88,7 @@
</div> </div>
</template> </template>
</ListSelectBanner> </ListSelectBanner>
<div class="mt-4" v-if="students.hasNextPage"> <div class="mt-4 flex justify-center" v-if="students.hasNextPage">
<Button @click="students.next()"> <Button @click="students.next()">
{{ __('Load More') }} {{ __('Load More') }}
</Button> </Button>
@@ -170,7 +170,7 @@ const studentColumns = [
{ {
label: 'Full Name', label: 'Full Name',
key: 'full_name', key: 'full_name',
width: '20rem', width: '25rem',
icon: 'user', icon: 'user',
}, },
{ {
@@ -19,9 +19,16 @@
showOptions = true showOptions = true
} }
" "
@click="
(e) => {
showOptions = true
nextTick(() => {
setFocus()
})
}
"
@focus=" @focus="
() => { () => {
showOptions = true
if (!filterOptions.data || filterOptions.data.length === 0) { if (!filterOptions.data || filterOptions.data.length === 0) {
reload('') reload('')
} }
@@ -33,10 +40,10 @@
<template #body="{ isOpen, close }"> <template #body="{ isOpen, close }">
<div v-show="isOpen"> <div v-show="isOpen">
<div <div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2" class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
> >
<ComboboxOptions <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]'" :class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
static static
> >
@@ -55,7 +62,11 @@
> >
<div class="flex flex-col gap-1 p-1"> <div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8"> <div class="text-base font-medium text-ink-gray-8">
{{ option.description }} {{
option.value == option.label
? option.description
: option.label
}}
</div> </div>
<div class="text-sm text-ink-gray-5"> <div class="text-sm text-ink-gray-5">
{{ option.value }} {{ option.value }}
@@ -66,10 +77,8 @@
<div v-else class="text-ink-gray-7 px-4"> <div v-else class="text-ink-gray-7 px-4">
{{ __('No results found') }} {{ __('No results found') }}
</div> </div>
<div </ComboboxOptions>
v-if="attrs.onCreate" <div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
>
<Button <Button
variant="ghost" variant="ghost"
class="w-full !justify-start" class="w-full !justify-start"
@@ -81,7 +90,6 @@
</template> </template>
</Button> </Button>
</div> </div>
</ComboboxOptions>
</div> </div>
</div> </div>
</template> </template>
@@ -115,7 +123,7 @@ import {
} from '@headlessui/vue' } from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui' import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue' 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' import { X, Plus } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
@@ -149,18 +157,20 @@ const props = defineProps({
const values = defineModel() const values = defineModel()
const attrs = useAttrs() const attrs = useAttrs()
const emails = ref([])
const search = ref(null) const search = ref(null)
const error = ref(null) const error = ref(null)
const query = ref('') const query = ref('')
const text = ref('') const text = ref('')
const showOptions = ref(false) const showOptions = ref(false)
const emit = defineEmits(['update:modelValue'])
const selectedValue = computed({ const selectedValue = computed({
get: () => query.value || '', get: () => query.value || '',
set: (val) => { set: (val) => {
query.value = '' query.value = ''
val?.value && addValue(val.value) val?.value && addValue(val.value)
showOptions.value = false
emit('update:modelValue', values.value)
}, },
}) })
@@ -232,6 +242,7 @@ const addValue = (value) => {
const removeValue = (value) => { const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value) values.value = values.value.filter((v) => v !== value)
emit('update:modelValue', values.value)
} }
function setFocus() { function setFocus() {
@@ -34,7 +34,12 @@
<img <img
v-if="type == 'image'" v-if="type == 'image'"
:src="modelValue" :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"> <video v-else controls class="border rounded-md w-44 h-auto">
<source :src="modelValue" /> <source :src="modelValue" />
@@ -67,11 +72,12 @@ const emit = defineEmits<{
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string modelValue: string | null
label?: string label?: string
description?: string description?: string
type?: 'image' | 'video' type?: 'image' | 'video'
required?: boolean required?: boolean
shape?: 'square' | 'circle'
}>(), }>(),
{ {
modelValue: '', modelValue: '',
@@ -79,6 +85,7 @@ const props = withDefaults(
description: '', description: '',
type: 'image', type: 'image',
required: true, required: true,
shape: 'square',
} }
) )
+10 -46
View File
@@ -37,7 +37,7 @@
<CertificationLinks :courseName="course.data.name" class="w-full" /> <CertificationLinks :courseName="course.data.name" class="w-full" />
</div> </div>
<router-link <router-link
v-else-if="course.data.paid_course" v-else-if="course.data.paid_course && !isAdmin"
:to="{ :to="{
name: 'Billing', name: 'Billing',
params: { params: {
@@ -56,14 +56,15 @@
</Button> </Button>
</router-link> </router-link>
<Badge <Badge
v-else-if="course.data.disable_self_learning" v-else-if="course.data.disable_self_learning && !isAdmin"
theme="blue" theme="blue"
size="lg" size="lg"
class="mb-4"
> >
{{ __('Contact the Administrator to enroll for this course.') }} {{ __('Contact the Administrator to enroll for this course') }}
</Badge> </Badge>
<Button <Button
v-else-if="!user.data?.is_moderator && !is_instructor()" v-else-if="!isAdmin"
@click="enrollStudent()" @click="enrollStudent()"
variant="solid" variant="solid"
class="w-full" class="w-full"
@@ -88,40 +89,11 @@
</template> </template>
{{ __('Get Certificate') }} {{ __('Get Certificate') }}
</Button> </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>
<div class="space-y-4"> <div class="space-y-4">
<div <div
class="font-medium text-ink-gray-9" class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }" :class="{ 'mt-8': course.data.membership && !readOnlyMode }"
> >
{{ __('This course has:') }} {{ __('This course has:') }}
</div> </div>
@@ -168,12 +140,6 @@
</div> </div>
</div> </div>
</div> </div>
<CourseProgressSummary
v-if="user.data?.is_moderator || is_instructor()"
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template> </template>
<script setup> <script setup>
import { import {
@@ -191,12 +157,10 @@ import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/' import { formatAmount } from '@/utils/'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
import { useTelemetry } from 'frappe-ui/frappe' import { useTelemetry } from 'frappe-ui/frappe'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry() const { capture } = useTelemetry()
@@ -216,7 +180,7 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { 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(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 500) }, 500)
@@ -295,7 +259,7 @@ const fetchCertificate = () => {
}) })
} }
const showProgressSummary = () => { const isAdmin = computed(() => {
showProgressModal.value = true return user.data?.is_moderator || is_instructor()
} })
</script> </script>
+5 -1
View File
@@ -15,7 +15,10 @@
{{ __(title) }} {{ __(title) }}
</div> </div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }} <template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button> </Button>
</div> </div>
<div <div
@@ -174,6 +177,7 @@ import {
FilePenLine, FilePenLine,
HelpCircle, HelpCircle,
MonitorPlay, MonitorPlay,
Plus,
Trash2, Trash2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
+5 -1
View File
@@ -107,7 +107,11 @@
v-model:reloadLiveClasses="liveClasses" v-model:reloadLiveClasses="liveClasses"
/> />
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" /> <LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template> </template>
<script setup> <script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui' import { createListResource, Button, Tooltip } from 'frappe-ui'
@@ -23,10 +23,8 @@
(value, close) => { (value, close) => {
close() close()
router.push({ router.push({
name: 'CourseForm', name: 'Courses',
params: { query: { newCourse: '1' },
courseName: 'new',
},
}) })
} }
" "
@@ -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>
+23 -90
View File
@@ -1,17 +1,25 @@
<template> <template>
<Dialog <Dialog
v-model="show"
:options="{ :options="{
size: '3xl', size: '3xl',
}" }"
> >
<template #body-header> <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"> <div class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Edit Profile') }} {{ __('Edit Profile') }}
</div> </div>
<Badge v-if="isDirty" class="ml-4" theme="orange"> <div class="space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }} {{ __('Not Saved') }}
</Badge> </Badge>
<div class="pb-5 float-right">
<Button variant="solid" @click="saveProfile()">
{{ __('Save') }}
</Button>
</div>
</div>
</div> </div>
</template> </template>
<template #body-content> <template #body-content>
@@ -19,52 +27,13 @@
<div class="grid grid-cols-2 gap-10"> <div class="grid grid-cols-2 gap-10">
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-4"> <div class="space-y-4">
<div> <Uploader
<div class="text-xs text-ink-gray-5 mb-1"> v-model="profile.image"
{{ __('Profile Image') }} :label="__('Profile Image')"
</div> :required="true"
<FileUploader shape="circle"
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"
/> />
<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 <FormControl
v-model="profile.first_name" v-model="profile.first_name"
:label="__('First Name')" :label="__('First Name')"
@@ -90,7 +59,7 @@
<FormControl <FormControl
v-model="profile.open_to" v-model="profile.open_to"
type="select" type="select"
:options="[' ', 'Opportunities', 'Hiring']" :options="[' ', 'Work', 'Hiring']"
:label="__('Open to')" :label="__('Open to')"
:placeholder="__('Looking for new work or hiring talent?')" :placeholder="__('Looking for new work or hiring talent?')"
/> />
@@ -115,13 +84,6 @@
</div> </div>
</div> </div>
</template> </template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<Button variant="solid" @click="saveProfile(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
@@ -131,15 +93,14 @@ import {
createResource, createResource,
Dialog, Dialog,
FormControl, FormControl,
FileUploader,
TextEditor, TextEditor,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch } from 'vue'
import { X } from 'lucide-vue-next' import { sanitizeHTML } from '@/utils'
import { getFileSize, sanitizeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const reloadProfile = defineModel('reloadProfile') const reloadProfile = defineModel('reloadProfile')
const hasLanguageChanged = ref(false) const hasLanguageChanged = ref(false)
const isDirty = ref(false) const isDirty = ref(false)
@@ -163,19 +124,6 @@ const profile = reactive({
twitter: '', 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({ const updateProfile = createResource({
url: 'frappe.client.set_value', url: 'frappe.client.set_value',
makeParams(values) { makeParams(values) {
@@ -183,7 +131,7 @@ const updateProfile = createResource({
doctype: 'User', doctype: 'User',
name: props.profile.data.name, name: props.profile.data.name,
fieldname: { fieldname: {
user_image: profile.image?.file_url || null, user_image: profile.image || null,
...profile, ...profile,
}, },
} }
@@ -193,13 +141,13 @@ const updateProfile = createResource({
}, },
}) })
const saveProfile = (close) => { const saveProfile = () => {
profile.bio = sanitizeHTML(profile.bio) profile.bio = sanitizeHTML(profile.bio)
updateProfile.submit( updateProfile.submit(
{}, {},
{ {
onSuccess() { onSuccess() {
close() show.value = false
reloadProfile.value.reload() reloadProfile.value.reload()
if (hasLanguageChanged.value) { if (hasLanguageChanged.value) {
hasLanguageChanged.value = false 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( watch(
() => profile, () => profile,
(newVal) => { (newVal) => {
@@ -240,7 +173,7 @@ watch(
return return
} }
} }
if (profile.image?.file_url !== props.profile.data.user_image) { if (profile.image !== props.profile.data.user_image) {
isDirty.value = true isDirty.value = true
return return
} }
@@ -262,7 +195,7 @@ watch(
profile.linkedin = newVal.linkedin profile.linkedin = newVal.linkedin
profile.github = newVal.github profile.github = newVal.github
profile.twitter = newVal.twitter profile.twitter = newVal.twitter
if (newVal.user_image) imageResource.submit({ image: newVal.user_image }) profile.image = newVal.user_image
isDirty.value = false isDirty.value = false
} }
} }
+19 -3
View File
@@ -22,7 +22,10 @@
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Course')"> <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" /> <BookOpen class="h-4 w-4 stroke-1.5" />
<span> <span>
{{ event.course_title }} {{ event.course_title }}
@@ -30,7 +33,10 @@
</div> </div>
</Tooltip> </Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')"> <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" /> <Users class="h-4 w-4 stroke-1.5" />
<span> <span>
{{ event.batch_title }} {{ event.batch_title }}
@@ -334,7 +340,7 @@ const certificateDetails = createResource({
} }
}, },
onError(err) { onError(err) {
certificate.template = defaultTemplate.data.value certificate.template = defaultTemplate.data?.value
}, },
auto: false, 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(() => { const statusOptions = computed(() => {
return [ 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>
+4 -1
View File
@@ -1,6 +1,9 @@
<template> <template>
<Tooltip :text="`${props.progress}%`"> <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 <div
class="bg-surface-gray-7 rounded-full" class="bg-surface-gray-7 rounded-full"
:class="progressBarHeight" :class="progressBarHeight"
@@ -1,16 +1,25 @@
<template> <template>
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col h-full">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9"> <div class="font-semibold mb-1 text-ink-gray-9">
{{ __(label) }} {{ __(label) }}
</div> </div>
<div class="space-x-2">
<Badge <Badge
v-if="isDirty" v-if="isDirty"
:label="__('Not Saved')" :label="__('Not Saved')"
variant="subtle" variant="subtle"
theme="orange" theme="orange"
/> />
<Button
variant="solid"
:loading="saveSettings.loading"
@click="update"
>
{{ __('Update') }}
</Button>
</div>
</div> </div>
<div class="text-xs text-ink-gray-5"> <div class="text-xs text-ink-gray-5">
{{ __(description) }} {{ __(description) }}
@@ -19,11 +28,6 @@
<div class="overflow-y-auto"> <div class="overflow-y-auto">
<SettingFields :sections="sections" :data="branding.data" /> <SettingFields :sections="sections" :data="branding.data" />
</div> </div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -186,8 +186,9 @@ const openProfile = (username: string) => {
} }
const deleteEvaluator = (evaluator: string) => { const deleteEvaluator = (evaluator: string) => {
call('lms.lms.api.delete_evaluator', { call('frappe.client.delete', {
evaluator: evaluator, doctype: 'Course Evaluator',
name: evaluator,
}) })
.then(() => { .then(() => {
toast.success(__('Evaluator deleted successfully')) toast.success(__('Evaluator deleted successfully'))
@@ -2,21 +2,25 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title:
gatewayID === 'new'
? __('New Payment Gateway')
: __('Edit Payment Gateway'),
size: '3xl', size: '3xl',
}" }"
> >
<template #body-header>
<div class="text-lg font-semibold">
{{
gatewayID === 'new'
? __('New Payment Gateway')
: __('Edit Payment Gateway')
}}
</div>
</template>
<template #body-content> <template #body-content>
<SettingFields <SettingFields
v-if="gatewayID != 'new' && paymentGateway.data" v-if="gatewayID != 'new' && paymentGateway.data"
:fields="paymentGateway.data.fields" :sections="paymentGateway.data.sections"
:data="paymentGateway.data.data" :data="paymentGateway.data.data"
class="pt-5 my-0"
/> />
<div v-else> <div v-else class="mt-5">
<FormControl <FormControl
v-model="newGateway" v-model="newGateway"
:label="__('Select Payment Gateway')" :label="__('Select Payment Gateway')"
@@ -26,9 +30,8 @@
/> />
<SettingFields <SettingFields
v-if="newGateway" v-if="newGateway"
:fields="newGatewayFields" :sections="newGatewayFields"
:data="newGatewayData" :data="newGatewayData"
class="pt-5 my-0"
/> />
</div> </div>
</template> </template>
@@ -56,7 +59,7 @@ import SettingFields from '@/components/Settings/SettingFields.vue'
const show = defineModel<boolean>({ required: true, default: false }) const show = defineModel<boolean>({ required: true, default: false })
const paymentGateways = defineModel<any>('paymentGateways') const paymentGateways = defineModel<any>('paymentGateways')
const newGateway = ref(null) const newGateway = ref(null)
const newGatewayFields = ref([]) const newGatewayFields = ref<{ columns: { fields: any[] }[] }[]>([])
const newGatewayData = ref<Record<string, any>>({}) const newGatewayData = ref<Record<string, any>>({})
const props = defineProps<{ const props = defineProps<{
@@ -72,6 +75,7 @@ const paymentGateway = createResource({
}, },
transform(data: any) { transform(data: any) {
arrangeFields(data.fields) arrangeFields(data.fields)
data.sections = makeSections(data.fields)
return data return data
}, },
}) })
@@ -102,10 +106,6 @@ const arrangeFields = (fields: any[]) => {
} }
return 0 return 0
}) })
fields.splice(3, 0, {
type: 'Column Break',
})
} }
watch( watch(
@@ -130,7 +130,7 @@ watch(newGateway, () => {
gatewayFields.reload({ doctype: gatewayDoc.name }).then(() => { gatewayFields.reload({ doctype: gatewayDoc.name }).then(() => {
let fields = gatewayFields.data || [] let fields = gatewayFields.data || []
arrangeFields(fields) arrangeFields(fields)
newGatewayFields.value = fields newGatewayFields.value = makeSections(fields)
prepareGatewayData() 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(() => { const allGatewayOptions = computed(() => {
let options: string[] = [] let options: string[] = []
let gatewayList = allGateways.data?.map((gateway: any) => gateway.name) || [] 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> </script>
@@ -8,6 +8,7 @@
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9"> <h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }} {{ __('Settings') }}
</h1> </h1>
<div class="space-y-5">
<div v-for="tab in tabs" :key="tab.label"> <div v-for="tab in tabs" :key="tab.label">
<div <div
v-if="!tab.hideLabel" v-if="!tab.hideLabel"
@@ -26,6 +27,7 @@
</nav> </nav>
</div> </div>
</div> </div>
</div>
<div <div
v-if="activeTab && data.doc" v-if="activeTab && data.doc"
:key="activeTab.label" :key="activeTab.label"
@@ -1,6 +1,7 @@
<template> <template>
<div class="flex flex-col h-full text-base"> <div class="flex flex-col h-full text-base">
<div class="flex items-center space-x-2 mb-8 -ml-1.5"> <div class="flex items-center justify-between mb-10 -ml-1.5">
<div class="flex items-center space-x-2">
<ChevronLeft <ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer" class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="emit('updateStep', 'list')" @click="emit('updateStep', 'list')"
@@ -9,6 +10,26 @@
{{ __('Transaction Details') }} {{ __('Transaction Details') }}
</div> </div>
</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"> <div v-if="transactionData" class="overflow-y-auto">
<div class="grid grid-cols-3 gap-5"> <div class="grid grid-cols-3 gap-5">
<FormControl <FormControl
@@ -21,6 +42,12 @@
type="checkbox" type="checkbox"
v-model="transactionData.payment_for_certificate" v-model="transactionData.payment_for_certificate"
/> />
<FormControl
:label="__('Member Consent')"
type="checkbox"
v-model="transactionData.member_consent"
:disabled="true"
/>
</div> </div>
<div class="grid grid-cols-3 gap-5 mt-5"> <div class="grid grid-cols-3 gap-5 mt-5">
@@ -28,22 +55,27 @@
:label="__('Member')" :label="__('Member')"
doctype="User" doctype="User"
v-model="transactionData.member" v-model="transactionData.member"
:required="true"
/> />
<FormControl <FormControl
:label="__('Billing Name')" :label="__('Billing Name')"
v-model="transactionData.billing_name" v-model="transactionData.billing_name"
:required="true"
/> />
<Link <Link
:label="__('Source')" :label="__('Source')"
v-model="transactionData.source" v-model="transactionData.source"
doctype="LMS Source" doctype="LMS Source"
/> />
<Link <FormControl
type="select"
:options="documentTypeOptions"
:label="__('Payment For Document Type')" :label="__('Payment For Document Type')"
v-model="transactionData.payment_for_document_type" v-model="transactionData.payment_for_document_type"
doctype="DocType" doctype="DocType"
/> />
<Link <Link
v-if="transactionData.payment_for_document_type"
:label="__('Payment For Document')" :label="__('Payment For Document')"
v-model="transactionData.payment_for_document" v-model="transactionData.payment_for_document"
:doctype="transactionData.payment_for_document_type" :doctype="transactionData.payment_for_document_type"
@@ -58,8 +90,13 @@
:label="__('Currency')" :label="__('Currency')"
v-model="transactionData.currency" v-model="transactionData.currency"
doctype="Currency" doctype="Currency"
:required="true"
/>
<FormControl
:label="__('Amount')"
v-model="transactionData.amount"
:required="true"
/> />
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
<FormControl <FormControl
v-if="transactionData.amount_with_gst" v-if="transactionData.amount_with_gst"
:label="__('Amount with GST')" :label="__('Amount with GST')"
@@ -103,6 +140,7 @@
:label="__('Address')" :label="__('Address')"
v-model="transactionData.address" v-model="transactionData.address"
doctype="Address" doctype="Address"
:required="true"
/> />
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" /> <FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
<FormControl :label="__('PAN')" v-model="transactionData.pan" /> <FormControl :label="__('PAN')" v-model="transactionData.pan" />
@@ -116,25 +154,12 @@
/> />
</div> </div>
</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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Button, FormControl } from 'frappe-ui' import { Button, FormControl, toast } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ChevronLeft } from 'lucide-vue-next' import { ChevronLeft } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
@@ -148,21 +173,40 @@ const props = defineProps<{
data: any data: any
}>() }>()
watch( const saveTransaction = () => {
() => props.data, if (props.data?.name) {
(newVal) => { updateTransaction()
transactionData.value = newVal ? { ...newVal } : null } else {
}, createTransaction()
{ immediate: true } }
) }
const saveTransaction = (close: () => void) => { const createTransaction = () => {
props.transactions.value.setValue console.log(props.transactions)
props.transactions.insert
.submit({ .submit({
...transactionData.value, ...transactionData.value,
}) })
.then(() => { .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 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> </script>
@@ -1,6 +1,7 @@
<template> <template>
<div class="flex min-h-0 flex-col text-base"> <div class="flex min-h-0 flex-col text-base">
<div class="mb-5"> <div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9"> <div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }} {{ __(label) }}
</div> </div>
@@ -8,6 +9,13 @@
{{ __(description) }} {{ __(description) }}
</div> </div>
</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"> <div class="flex items-center space-x-5 mb-4">
<FormControl <FormControl
@@ -1,6 +1,13 @@
<template> <template>
<TransactionDetails
v-if="step == 'new'"
:transactions="transactions"
:data="data"
v-model:show="show"
@updateStep="updateStep"
/>
<TransactionList <TransactionList
v-if="step === 'list'" v-else-if="step === 'list'"
:label="props.label" :label="props.label"
:description="props.description" :description="props.description"
:transactions="transactions" :transactions="transactions"
@@ -33,6 +40,8 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
step.value = newStep step.value = newStep
if (newData) { if (newData) {
data.value = newData data.value = newData
} else {
data.value = null
} }
} }
+21 -7
View File
@@ -269,12 +269,13 @@ const iconProps = {
onMounted(() => { onMounted(() => {
setUpOnboarding() setUpOnboarding()
addKeyboardShortcut() addKeyboardShortcut()
updateSidebarLinks()
socket.on('publish_lms_notifications', (data) => { socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload() unreadNotifications.reload()
}) })
}) })
const setSidebarLinks = () => { const updateSidebarLinksVisibility = () => {
sidebarSettings.reload( sidebarSettings.reload(
{}, {},
{ {
@@ -405,9 +406,13 @@ const steps = reactive([
minimize.value = true minimize.value = true
let course = await getFirstCourse() let course = await getFirstCourse()
if (course) { if (course) {
router.push({ name: 'CourseForm', params: { courseName: course } }) router.push({
name: 'CourseDetail',
params: { courseName: course },
hash: '#settings',
})
} else { } else {
router.push({ name: 'CourseForm' }) router.push({ name: 'Courses', query: { newCourse: '1' } })
} }
}, },
}, },
@@ -422,11 +427,12 @@ const steps = reactive([
let course = await getFirstCourse() let course = await getFirstCourse()
if (course) { if (course) {
router.push({ router.push({
name: 'CourseForm', name: 'CourseDetail',
params: { courseName: course }, params: { courseName: course },
hash: '#settings',
}) })
} else { } else {
router.push({ name: 'Courses' }) router.push({ name: 'Courses', query: { newCourse: '1' } })
} }
}, },
}, },
@@ -591,10 +597,18 @@ watch(userResource, async () => {
await programs.reload() await programs.reload()
setUpOnboarding() setUpOnboarding()
} }
sidebarLinks.value = getSidebarLinks() updateSidebarLinks()
setSidebarLinks()
}) })
watch(settingsStore.settings, () => {
updateSidebarLinks()
})
const updateSidebarLinks = () => {
sidebarLinks.value = getSidebarLinks()
updateSidebarLinksVisibility()
}
const redirectToWebsite = () => { const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank') window.open('https://frappe.io/learning', '_blank')
} }
@@ -190,7 +190,7 @@ const evaluationCourses = computed(() => {
const canScheduleEvals = computed(() => { const canScheduleEvals = computed(() => {
return ( return (
upcoming_evals.data?.length != evaluationCourses.length && upcoming_evals.data?.length != evaluationCourses.value?.length &&
!props.forHome && !props.forHome &&
!endDateHasPassed.value !endDateHasPassed.value
) )
+2 -2
View File
@@ -7,8 +7,8 @@
:size="size" :size="size"
v-bind="$attrs" v-bind="$attrs"
> >
<template v-if="user.open_to === 'Opportunities'" #indicator> <template v-if="user.open_to === 'Work'" #indicator>
<Tooltip :text="__('Open to Opportunities')" placement="right"> <Tooltip :text="__('Open to Work')" placement="right">
<div class="rounded-full bg-surface-green-3 w-fit"> <div class="rounded-full bg-surface-green-3 w-fit">
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" /> <BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
</div> </div>
-1
View File
@@ -1,3 +1,2 @@
@import './assets/Inter/inter.css';
@import 'frappe-ui/style.css'; @import 'frappe-ui/style.css';
@import './styles/codemirror.css'; @import './styles/codemirror.css';
+7 -2
View File
@@ -1,5 +1,5 @@
import './index.css' import './index.css'
import { createApp } from 'vue' import { createApp, watch } from 'vue'
import router from './router' import router from './router'
import App from './App.vue' import App from './App.vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
@@ -19,7 +19,6 @@ app.use(FrappeUI)
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
app.use(translationPlugin) app.use(translationPlugin)
app.use(telemetryPlugin, { app_name: 'lms' })
app.use(pageMetaPlugin) app.use(pageMetaPlugin)
app.provide('$dayjs', dayjs) app.provide('$dayjs', dayjs)
app.provide('$socket', initSocket()) app.provide('$socket', initSocket())
@@ -29,5 +28,11 @@ const { userResource, allUsers } = usersStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.provide('$allUsers', allUsers) app.provide('$allUsers', allUsers)
watch(userResource, () => {
if (userResource.data) {
app.use(telemetryPlugin, { app_name: 'lms' })
}
})
app.config.globalProperties.$user = userResource app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog app.config.globalProperties.$dialog = createDialog
+7 -5
View File
@@ -130,7 +130,6 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
Dropdown, Dropdown,
FormControl, FormControl,
@@ -185,7 +184,9 @@ const batches = createListResource({
cache: ['batches', user.data?.name], cache: ['batches', user.data?.name],
pageLength: pageLength.value, pageLength: pageLength.value,
start: start.value, start: start.value,
onSuccess(data) { })
const setCategories = (data) => {
let allCategories = data.map((batch) => batch.category) let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter( allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category (category, index) => allCategories.indexOf(category) === index && category
@@ -193,8 +194,7 @@ const batches = createListResource({
if (categories.value.length <= allCategories.length) { if (categories.value.length <= allCategories.length) {
updateCategories(data) updateCategories(data)
} }
}, }
})
const updateBatches = () => { const updateBatches = () => {
updateFilters() updateFilters()
@@ -202,7 +202,9 @@ const updateBatches = () => {
filters: filters.value, filters: filters.value,
orderBy: orderBy.value, orderBy: orderBy.value,
}) })
batches.reload() batches.reload().then((data) => {
setCategories(data)
})
} }
const updateFilters = () => { const updateFilters = () => {
+16 -9
View File
@@ -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" 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" /> <Breadcrumbs :items="breadcrumbs" />
<router-link :to="{ name: 'Batches', query: { certification: true } }"> <router-link :to="{ name: 'Courses', query: { certification: true } }">
<Button> <Button>
<template #prefix> <template #prefix>
<GraduationCap class="h-4 w-4 stroke-1.5" /> <GraduationCap class="h-4 w-4 stroke-1.5" />
@@ -42,8 +42,8 @@
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<FormControl <FormControl
v-model="openToOpportunities" v-model="openToWork"
:label="__('Open to Opportunities')" :label="__('Open to Work')"
type="checkbox" type="checkbox"
@change="updateParticipants()" @change="updateParticipants()"
/> />
@@ -134,19 +134,26 @@ import {
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { GraduationCap } from 'lucide-vue-next' import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { useRouter } from 'vue-router'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
const filters = ref({}) const filters = ref({})
const currentCategory = ref('') const currentCategory = ref('')
const nameFilter = ref('') const nameFilter = ref('')
const openToOpportunities = ref(false) const openToWork = ref(false)
const hiring = ref(false) const hiring = ref(false)
const { brand } = sessionStore() const { brand } = sessionStore()
const memberCount = ref(0) const memberCount = ref(0)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const user = inject('$user')
const router = useRouter()
onMounted(() => { onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
return
}
setFiltersFromQuery() setFiltersFromQuery()
updateParticipants() updateParticipants()
}) })
@@ -171,7 +178,7 @@ const categories = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories', url: 'lms.lms.api.get_certification_categories',
cache: ['certification_categories'], cache: ['certification_categories'],
auto: true, auto: user.data ? true : false,
transform(data) { transform(data) {
data.unshift({ label: __(' '), value: ' ' }) data.unshift({ label: __(' '), value: ' ' })
return data return data
@@ -197,8 +204,8 @@ const updateFilters = () => {
...(nameFilter.value && { ...(nameFilter.value && {
member_name: ['like', `%${nameFilter.value}%`], member_name: ['like', `%${nameFilter.value}%`],
}), }),
...(openToOpportunities.value && { ...(openToWork.value && {
open_to_opportunities: true, open_to_work: true,
}), }),
...(hiring.value && { ...(hiring.value && {
hiring: true, hiring: true,
@@ -211,7 +218,7 @@ const setQueryParams = () => {
let filterKeys = { let filterKeys = {
category: currentCategory.value, category: currentCategory.value,
name: nameFilter.value, name: nameFilter.value,
'open-to-opportunities': openToOpportunities.value, 'open-to-work': openToWork.value,
hiring: hiring.value, hiring: hiring.value,
} }
@@ -240,7 +247,7 @@ const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
nameFilter.value = queries.get('name') || '' nameFilter.value = queries.get('name') || ''
currentCategory.value = queries.get('category') || '' 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' hiring.value = queries.get('hiring') === 'true'
} }
-194
View File
@@ -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"
>&middot;</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"
>&middot;</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>
@@ -38,7 +38,7 @@
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui' import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session' import { sessionStore } from '../../stores/session'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue' import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const courseTitle = ref(null) 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>
+167
View File
@@ -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> <template>
<div class="h-full"> <div class="pl-5">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] h-full"> <div class="grid grid-cols-1 md:grid-cols-[70%,30%] overflow-hidden">
<div> <div v-if="courseResource.doc" class="h-[88vh] overflow-y-auto">
<header <div class="my-5">
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" <div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
>
<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="text-lg font-semibold mb-4 text-ink-gray-9"> <div class="text-lg font-semibold mb-4 text-ink-gray-9">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl <FormControl
v-model="course.title" v-model="courseResource.doc.title"
:label="__('Title')" :label="__('Title')"
:required="true" :required="true"
@input="makeFormDirty()"
/> />
<Link <Link
doctype="LMS Category" doctype="LMS Category"
v-model="course.category" v-model="courseResource.doc.category"
:label="__('Category')" :label="__('Category')"
:onCreate="(value, close) => openSettings('Categories', close)" :onCreate="(value, close) => openSettings('Categories', close)"
@update:modelValue="makeFormDirty()"
/> />
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
@@ -45,6 +30,7 @@
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
:onCreate="(close) => openSettings('Members', close)" :onCreate="(close) => openSettings('Members', close)"
:required="true" :required="true"
@update:modelValue="makeFormDirty()"
/> />
<div> <div>
<div class="text-xs text-ink-gray-5"> <div class="text-xs text-ink-gray-5">
@@ -60,8 +46,8 @@
<div> <div>
<div class="flex items-center flex-wrap gap-2"> <div class="flex items-center flex-wrap gap-2">
<div <div
v-if="course.tags" v-if="courseResource.doc.tags"
v-for="tag in course.tags?.split(', ')" v-for="tag in courseResource.doc.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md" class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
> >
{{ tag }} {{ tag }}
@@ -76,21 +62,23 @@
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<Uploader <Uploader
v-model="course.image" v-model="courseResource.doc.image"
:label="__('Course Image')" :label="__('Course Image')"
:required="false" :required="false"
@update:modelValue="makeFormDirty()"
/> />
<ColorSwatches <ColorSwatches
v-model="course.card_gradient" v-model="courseResource.doc.card_gradient"
:label="__('Color')" :label="__('Color')"
:description="__('Choose a color for the course card')" :description="__('Choose a color for the course card')"
class="w-full" class="w-full"
@update:modelValue="makeFormDirty()"
/> />
</div> </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"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
@@ -101,41 +89,46 @@
> >
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.published" v-model="courseResource.doc.published"
:label="__('Published')" :label="__('Published')"
@change="makeFormDirty()"
/> />
<FormControl <FormControl
v-model="course.published_on" v-model="courseResource.doc.published_on"
:label="__('Published On')" :label="__('Published On')"
type="date" type="date"
@change="makeFormDirty()"
/> />
</div> </div>
<div class="flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.upcoming" v-model="courseResource.doc.upcoming"
:label="__('Upcoming')" :label="__('Upcoming')"
@change="makeFormDirty()"
/> />
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.featured" v-model="courseResource.doc.featured"
:label="__('Featured')" :label="__('Featured')"
@change="makeFormDirty()"
/> />
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.disable_self_learning" v-model="courseResource.doc.disable_self_learning"
:label="__('Disable Self Enrollment')" :label="__('Disable Self Enrollment')"
@change="makeFormDirty()"
/> />
</div> </div>
</div> </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"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('About the Course') }} {{ __('About the Course') }}
</div> </div>
<FormControl <FormControl
v-model="course.short_introduction" v-model="courseResource.doc.short_introduction"
type="textarea" type="textarea"
:rows="5" :rows="5"
:label="__('Short Introduction')" :label="__('Short Introduction')"
@@ -145,6 +138,7 @@
) )
" "
:required="true" :required="true"
@change="makeFormDirty()"
/> />
<div class=""> <div class="">
<div class="mb-1.5 text-sm text-ink-gray-5"> <div class="mb-1.5 text-sm text-ink-gray-5">
@@ -152,8 +146,13 @@
<span class="text-ink-red-3">*</span> <span class="text-ink-red-3">*</span>
</div> </div>
<TextEditor <TextEditor
:content="course.description" :content="courseResource.doc.description"
@change="(val) => (course.description = val)" @change="
(val) => {
courseResource.doc.description = val
makeFormDirty()
}
"
:editable="true" :editable="true"
:fixedMenu="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]" 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> </div>
<FormControl <FormControl
v-model="course.video_link" v-model="courseResource.doc.video_link"
:label="__('Preview Video')" :label="__('Preview Video')"
:placeholder=" :placeholder="
__( __(
'Paste the youtube link of a short video introducing the course' 'Paste the youtube link of a short video introducing the course'
) )
" "
@input="makeFormDirty()"
/> />
<MultiSelect <MultiSelect
v-model="related_courses" v-model="related_courses"
doctype="LMS Course" doctype="LMS Course"
:label="__('Related Courses')" :label="__('Related Courses')"
:filters="{ name: ['!=', courseResource.data?.name] }" :filters="{ name: ['!=', courseResource.doc?.name] }"
:onCreate=" :onCreate="
(close) => { (close) => {
router.push({ router.push({
name: 'CourseForm', name: 'Courses',
params: { courseName: 'new' }, query: { newCourse: '1' },
}) })
} }
" "
@update:modelValue="makeFormDirty()"
/> />
</div> </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"> <div class="text-lg font-semibold mt-5 text-ink-gray-9">
{{ __('Pricing and Certification') }} {{ __('Pricing and Certification') }}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5"> <div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.paid_course" v-model="courseResource.doc.paid_course"
:label="__('Paid Course')" :label="__('Paid Course')"
@change="makeFormDirty()"
/> />
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.enable_certification" v-model="courseResource.doc.enable_certification"
:label="__('Completion Certificate')" :label="__('Completion Certificate')"
@change="makeFormDirty()"
/> />
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.paid_certificate" v-model="courseResource.doc.paid_certificate"
:label="__('Paid Certificate')" :label="__('Paid Certificate')"
@change="makeFormDirty()"
/> />
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5"> <div class="space-y-5">
<FormControl <FormControl
v-if="course.paid_course || course.paid_certificate" v-if="
v-model="course.course_price" courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
v-model="courseResource.doc.course_price"
:label="__('Amount')" :label="__('Amount')"
:required="course.paid_course || course.paid_certificate" :required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@input="makeFormDirty()"
/> />
<Link <Link
v-if="course.paid_certificate" v-if="courseResource.doc.paid_certificate"
doctype="Course Evaluator" doctype="Course Evaluator"
v-model="course.evaluator" v-model="courseResource.doc.evaluator"
:label="__('Evaluator')" :label="__('Evaluator')"
:required="course.paid_certificate" :required="courseResource.doc.paid_certificate"
:onCreate=" :onCreate="
(value, close) => openSettings('Evaluators', close) (value, close) => openSettings('Evaluators', close)
" "
@update:modelValue="makeFormDirty()"
/> />
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<Link <Link
v-if="course.paid_course || course.paid_certificate" v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
doctype="Currency" doctype="Currency"
v-model="course.currency" v-model="courseResource.doc.currency"
:filters="{ enabled: 1 }" :filters="{ enabled: 1 }"
:label="__('Currency')" :label="__('Currency')"
:required="course.paid_course || course.paid_certificate" :required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@update:modelValue="makeFormDirty()"
/> />
<FormControl <FormControl
v-if="course.paid_certificate" v-if="courseResource.doc.paid_certificate"
v-model="course.timezone" v-model="courseResource.doc.timezone"
:label="__('Timezone')" :label="__('Timezone')"
:required="course.paid_certificate" :required="courseResource.doc.paid_certificate"
:placeholder="__('e.g. IST, UTC, GMT...')" :placeholder="__('e.g. IST, UTC, GMT...')"
@input="makeFormDirty()"
/> />
</div> </div>
</div> </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"> <div class="text-lg font-semibold mt-5 text-ink-gray-9">
{{ __('Meta Tags') }} {{ __('Meta Tags') }}
</div> </div>
@@ -256,6 +276,7 @@
:label="__('Meta Description')" :label="__('Meta Description')"
type="textarea" type="textarea"
:rows="7" :rows="7"
@input="makeFormDirty()"
/> />
<FormControl <FormControl
v-model="meta.keywords" v-model="meta.keywords"
@@ -263,16 +284,17 @@
type="textarea" type="textarea"
:rows="7" :rows="7"
:placeholder="__('Comma separated keywords for SEO')" :placeholder="__('Comma separated keywords for SEO')"
@input="makeFormDirty()"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="border-l"> <div class="border-l h-[88vh] overflow-y-auto">
<CourseOutline <CourseOutline
v-if="courseResource.data" v-if="courseResource.doc"
:courseName="courseResource.data.name" :courseName="courseResource.doc.name"
:title="__('Course Outline')" :title="__('Chapters')"
:allowEdit="true" :allowEdit="true"
/> />
</div> </div>
@@ -281,10 +303,10 @@
</template> </template>
<script setup> <script setup>
import { import {
Breadcrumbs,
TextEditor, TextEditor,
Button, Button,
createResource, createResource,
createDocumentResource,
FormControl, FormControl,
usePageMeta, usePageMeta,
toast, toast,
@@ -293,7 +315,6 @@ import {
inject, inject,
onMounted, onMounted,
onBeforeUnmount, onBeforeUnmount,
computed,
ref, ref,
reactive, reactive,
watch, watch,
@@ -308,8 +329,7 @@ import {
} from '@/utils' } from '@/utils'
import { Trash2, X } from 'lucide-vue-next' import { Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' 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 Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -323,39 +343,15 @@ const router = useRouter()
const instructors = ref([]) const instructors = ref([])
const related_courses = ref([]) const related_courses = ref([])
const app = getCurrentInstance() const app = getCurrentInstance()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const props = defineProps({ const props = defineProps({
courseName: { course: {
type: String, 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({ const meta = reactive({
description: '', description: '',
keywords: '', keywords: '',
@@ -365,18 +361,92 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
} }
if (props.courseName !== 'new') {
fetchCourseInfo()
} else {
capture('course_form_opened')
}
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
}) })
const fetchCourseInfo = () => { 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() courseResource.reload()
getMetaInfo('courses', props.courseName, meta) },
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
} }
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
@@ -394,151 +464,11 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut) 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({ const deleteCourse = createResource({
url: 'lms.lms.api.delete_course', url: 'lms.lms.api.delete_course',
makeParams(values) { makeParams(values) {
return { return {
course: props.courseName, course: courseResource.doc?.name,
} }
}, },
onSuccess() { onSuccess() {
@@ -567,28 +497,23 @@ const trashCourse = () => {
}) })
} }
watch(
() => props.courseName !== 'new',
(newVal) => {
if (newVal) {
fetchCourseInfo()
}
}
)
const updateTags = () => { const updateTags = () => {
if (newTag.value) { 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 = '' newTag.value = ''
makeFormDirty()
} }
} }
const removeTag = (tag) => { const removeTag = (tag) => {
course.tags = course.tags courseResource.doc.tags = courseResource.doc.tags
?.split(', ') ?.split(', ')
.filter((t) => t !== tag) .filter((t) => t !== tag)
.join(', ') .join(', ')
newTag.value = '' newTag.value = ''
makeFormDirty()
} }
const check_permission = () => { const check_permission = () => {
@@ -606,30 +531,20 @@ const check_permission = () => {
} }
} }
const breadcrumbs = computed(() => { const makeFormDirty = () => {
let crumbs = [ isDirty.value = true
{
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
})
usePageMeta(() => { usePageMeta(() => {
return { return {
title: courseResource.data?.title || __('New Course'), title: courseResource.doc?.title,
icon: brand.favicon, icon: brand.favicon,
} }
}) })
defineExpose({
submitCourse,
trashCourse,
isDirty,
})
</script> </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"
>&middot;</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">&middot;</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" /> <Breadcrumbs :items="breadcrumbs" />
<Dropdown <Dropdown
placement="start" placement="right"
side="bottom" side="bottom"
v-if="canCreateCourse()" v-if="canCreateCourse()"
:options="[ :options="[
@@ -13,10 +13,7 @@
label: __('New Course'), label: __('New Course'),
icon: 'book-open', icon: 'book-open',
onClick() { onClick() {
router.push({ showCourseModal = true
name: 'CourseForm',
params: { courseName: 'new' },
})
}, },
}, },
{ {
@@ -109,6 +106,11 @@
</Button> </Button>
</div> </div>
</div> </div>
<NewCourseModal
v-if="showCourseModal"
v-model="showCourseModal"
:courses="courses"
/>
</template> </template>
<script setup> <script setup>
import { import {
@@ -128,13 +130,19 @@ import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils' import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.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 user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const start = ref(0) const start = ref(0)
const pageLength = ref(30) const pageLength = ref(30)
const categories = ref([]) const categories = ref([
{
label: '',
value: null,
},
])
const currentCategory = ref(null) const currentCategory = ref(null)
const title = ref('') const title = ref('')
const certification = ref(false) const certification = ref(false)
@@ -142,17 +150,13 @@ const filters = ref({})
const currentTab = ref('Live') const currentTab = ref('Live')
const { brand } = sessionStore() const { brand } = sessionStore()
const courseCount = ref(0) const courseCount = ref(0)
const router = useRouter()
const showCourseModal = ref(false)
onMounted(() => { onMounted(() => {
setFiltersFromQuery() setFiltersFromQuery()
updateCourses() updateCourses()
getCourseCount() getCourseCount()
categories.value = [
{
label: '',
value: null,
},
]
}) })
const setFiltersFromQuery = () => { const setFiltersFromQuery = () => {
@@ -160,6 +164,9 @@ const setFiltersFromQuery = () => {
title.value = queries.get('title') || '' title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false certification.value = queries.get('certification') || false
if (queries.get('newCourse') == '1') {
showCourseModal.value = true
}
} }
const courses = createListResource({ const courses = createListResource({
@@ -168,9 +175,6 @@ const courses = createListResource({
cache: ['courses', user.data?.name], cache: ['courses', user.data?.name],
pageLength: pageLength.value, pageLength: pageLength.value,
start: start.value, start: start.value,
onSuccess(data) {
setCategories(data)
},
}) })
const setCategories = (data) => { const setCategories = (data) => {
@@ -205,7 +209,7 @@ const identifyUserPersona = async () => {
const getCourseCount = () => { const getCourseCount = () => {
if (!user.data) return if (!user.data) return
if (!user.data.is_moderator) return
call('frappe.client.get_count', { call('frappe.client.get_count', {
doctype: 'LMS Course', doctype: 'LMS Course',
}).then((data) => { }).then((data) => {
@@ -219,7 +223,9 @@ const updateCourses = () => {
courses.update({ courses.update({
filters: filters.value, filters: filters.value,
}) })
courses.reload() courses.reload().then((data) => {
setCategories(data)
})
} }
const updateFilters = () => { 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>
+1 -1
View File
@@ -74,7 +74,7 @@
}} }}
</div> </div>
<router-link <router-link
:to="{ name: 'CourseForm', params: { courseName: 'new' } }" :to="{ name: 'Courses', query: { newCourse: '1' } }"
class="mt-4" class="mt-4"
> >
<Button> <Button>
+1 -1
View File
@@ -86,7 +86,7 @@
<Uploader <Uploader
v-model="job.company_logo" v-model="job.company_logo"
:label="__('Company Logo')" :label="__('Company Logo')"
:required="false" :required="true"
/> />
</div> </div>
</div> </div>
+8 -8
View File
@@ -51,12 +51,12 @@
class="hidden lg:block" class="hidden lg:block"
@change="updateJobs" @change="updateJobs"
/> />
<div class="grid grid-cols-2 gap-4"> <div class="flex items-center space-x-4">
<FormControl <FormControl
type="text" type="text"
:placeholder="__('Search')" :placeholder="__('Search')"
v-model="searchQuery" v-model="searchQuery"
class="w-full max-w-40" class="w-full"
@input="updateJobs" @input="updateJobs"
> >
<template #prefix> <template #prefix>
@@ -79,17 +79,17 @@
v-model="jobType" v-model="jobType"
type="select" type="select"
:options="jobTypes" :options="jobTypes"
class="w-full" class="w-full min-w-32"
:placeholder="__('Type')" :placeholder="__('Type')"
@change="updateJobs" @update:modelValue="updateJobs"
/> />
<FormControl <FormControl
v-model="workMode" v-model="workMode"
type="select" type="select"
:options="workModes" :options="workModes"
class="w-full" class="w-full min-w-32"
:placeholder="__('Work Mode')" :placeholder="__('Work Mode')"
@change="updateJobs" @update:modelValue="updateJobs"
/> />
</div> </div>
</div> </div>
@@ -218,13 +218,13 @@ const updateJobs = () => {
const updateFilters = () => { const updateFilters = () => {
filters.value.status = 'Open' filters.value.status = 'Open'
if (jobType.value) { if (jobType.value && jobType.value !== ' ') {
filters.value.type = jobType.value filters.value.type = jobType.value
} else { } else {
delete filters.value.type delete filters.value.type
} }
if (workMode.value) { if (workMode.value && workMode.value !== ' ') {
filters.value.work_mode = workMode.value filters.value.work_mode = workMode.value
} else { } else {
delete filters.value.work_mode delete filters.value.work_mode
+2
View File
@@ -326,6 +326,7 @@
@updateNotes="updateNotes" @updateNotes="updateNotes"
/> />
<VideoStatistics <VideoStatistics
v-if="showStatsDialog"
v-model="showStatsDialog" v-model="showStatsDialog"
:lessonName="lesson.data?.name" :lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title" :lessonTitle="lesson.data?.title"
@@ -871,6 +872,7 @@ const scrollDiscussionsIntoView = () => {
} }
const updateNotes = () => { const updateNotes = () => {
if (!user.data) return
notes.update({ notes.update({
filters: { filters: {
lesson: lesson.data?.name, lesson: lesson.data?.name,
+5 -1
View File
@@ -471,7 +471,11 @@ const breadcrumbs = computed(() => {
}, },
{ {
label: lessonDetails.data?.course_title, label: lessonDetails.data?.course_title,
route: { name: 'CourseForm', params: { courseName: props.courseName } }, route: {
name: 'CourseDetail',
params: { courseName: props.courseName },
hash: '#settings',
},
}, },
] ]
+9 -11
View File
@@ -26,7 +26,7 @@
class="flex space-x-2 px-2 py-4" class="flex space-x-2 px-2 py-4"
:class="{ :class="{
'cursor-pointer': log.link, 'cursor-pointer': log.link,
'items-center': !showDetails(log) && !isMention(log), 'items-center': !showDetails(log) && !isMentionOrComment(log),
}" }"
@click="navigateToPage(log)" @click="navigateToPage(log)"
> >
@@ -56,9 +56,9 @@
</div> </div>
</div> </div>
<div <div
v-if="isMention(log)" v-if="isMentionOrComment(log)"
v-html="log.email_content" 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>
<div <div
v-else-if="showDetails(log)" v-else-if="showDetails(log)"
@@ -185,10 +185,9 @@ const unReadNotifications = createListResource({
doctype: 'Notification Log', doctype: 'Notification Log',
url: 'lms.lms.api.get_notifications', url: 'lms.lms.api.get_notifications',
filters: { filters: {
for_user: user.data?.name,
read: 0, read: 0,
}, },
auto: true, auto: user.data ? true : false,
cache: 'Unread Notifications', cache: 'Unread Notifications',
}) })
@@ -196,18 +195,17 @@ const readNotifications = createListResource({
doctype: 'Notification Log', doctype: 'Notification Log',
url: 'lms.lms.api.get_notifications', url: 'lms.lms.api.get_notifications',
filters: { filters: {
for_user: user.data?.name,
read: 1, read: 1,
}, },
auto: true, auto: user.data ? true : false,
cache: 'Read Notifications', cache: 'Read Notifications',
}) })
const markAsRead = createResource({ const markAsRead = createResource({
url: 'lms.lms.api.mark_as_read', url: 'frappe.desk.doctype.notification_log.notification_log.mark_as_read',
makeParams(values) { makeParams(values) {
return { return {
name: values.name, docname: values.name,
} }
}, },
onSuccess(data) { onSuccess(data) {
@@ -217,7 +215,7 @@ const markAsRead = createResource({
}) })
const markAllAsRead = 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) { onSuccess(data) {
unReadNotifications.reload() unReadNotifications.reload()
readNotifications.reload() readNotifications.reload()
@@ -260,7 +258,7 @@ const navigateToPage = (log) => {
} }
} }
const isMention = (log) => { const isMentionOrComment = (log) => {
if (log.type == 'Mention') { if (log.type == 'Mention') {
return true return true
} }
+3 -3
View File
@@ -65,8 +65,8 @@
<Tooltip <Tooltip
v-if="profile.data.open_to" v-if="profile.data.open_to"
:text=" :text="
profile.data.open_to === 'Opportunities' profile.data.open_to === 'Work'
? __('Open to Opportunities') ? __('Open to Work')
: __('Hiring') : __('Hiring')
" "
placement="right" placement="right"
@@ -77,7 +77,7 @@
<div <div
class="rounded-full w-fit" class="rounded-full w-fit"
:class=" :class="
profile.data.open_to === 'Opportunities' profile.data.open_to === 'Work'
? 'bg-surface-green-3' ? 'bg-surface-green-3'
: 'bg-purple-500' : 'bg-purple-500'
" "
+3 -16
View File
@@ -226,7 +226,6 @@ import {
onMounted, onMounted,
inject, inject,
onBeforeUnmount, onBeforeUnmount,
watch,
} from 'vue' } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next' 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(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
@@ -273,24 +274,10 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut) window.removeEventListener('keydown', keyboardShortcut)
}) })
watch(
() => props.quizID !== 'new',
(newVal) => {
if (newVal) {
quizDetails.reload()
}
}
)
const quizDetails = createDocumentResource({ const quizDetails = createDocumentResource({
doctype: 'LMS Quiz', doctype: 'LMS Quiz',
name: props.quizID, name: props.quizID,
auto: false, auto: false,
onSuccess(doc) {
if (doc.questions && doc.questions.length > 0) {
questions.value = doc.questions.map((question) => question)
}
},
}) })
const validateTitle = () => { const validateTitle = () => {
+22 -12
View File
@@ -116,20 +116,30 @@ const debouncedSaveProgress = (scormDetails) => {
} }
const saveDataToLMS = (key, value) => { const saveDataToLMS = (key, value) => {
if (key === 'cmi.core.lesson_status') { const isLessonStatus = key === 'cmi.core.lesson_status' && value === 'passed'
if (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 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) {
if (
isLessonStatus ||
isCompletionStatus ||
(shouldRestart && courseRestartOnFailure)
) {
saveProgress({
is_complete: isSuccessfullyCompleted.value,
scorm_content: '',
})
return
}
if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
debouncedSaveProgress({ debouncedSaveProgress({
is_complete: false, is_complete: false,
scorm_content: value, scorm_content: value,
+3 -9
View File
@@ -13,12 +13,12 @@ const routes = [
{ {
path: '/courses', path: '/courses',
name: 'Courses', name: 'Courses',
component: () => import('@/pages/Courses.vue'), component: () => import('@/pages/Courses/Courses.vue'),
}, },
{ {
path: '/courses/:courseName', path: '/courses/:courseName',
name: 'CourseDetail', name: 'CourseDetail',
component: () => import('@/pages/CourseDetail.vue'), component: () => import('@/pages/Courses/CourseDetail.vue'),
props: true, props: true,
}, },
{ {
@@ -30,7 +30,7 @@ const routes = [
{ {
path: '/courses/:courseName/certification', path: '/courses/:courseName/certification',
name: 'CourseCertification', name: 'CourseCertification',
component: () => import('@/pages/CourseCertification.vue'), component: () => import('@/pages/Courses/CourseCertification.vue'),
props: true, props: true,
}, },
{ {
@@ -119,12 +119,6 @@ const routes = [
component: () => import('@/pages/JobApplications.vue'), component: () => import('@/pages/JobApplications.vue'),
props: true, props: true,
}, },
{
path: '/courses/:courseName/edit',
name: 'CourseForm',
component: () => import('@/pages/CourseForm.vue'),
props: true,
},
{ {
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit', path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
name: 'LessonForm', name: 'LessonForm',
+2
View File
@@ -13,6 +13,8 @@ export const sessionStore = defineStore('lms-session', () => {
let _sessionUser = cookies.get('user_id') let _sessionUser = cookies.get('user_id')
if (_sessionUser === 'Guest') { if (_sessionUser === 'Guest') {
_sessionUser = null _sessionUser = null
} else {
userResource.reload()
} }
return _sessionUser return _sessionUser
} }
-1
View File
@@ -9,7 +9,6 @@ export const usersStore = defineStore('lms-users', () => {
window.location.href = '/login' window.location.href = '/login'
} }
}, },
auto: true,
}) })
const allUsers = createResource({ const allUsers = createResource({
+3 -1
View File
@@ -465,7 +465,6 @@ const getSidebarItems = () => {
'Courses', 'Courses',
'CourseDetail', 'CourseDetail',
'Lesson', 'Lesson',
'CourseForm',
'LessonForm', 'LessonForm',
], ],
}, },
@@ -490,6 +489,9 @@ const getSidebarItems = () => {
icon: 'GraduationCap', icon: 'GraduationCap',
to: 'CertifiedParticipants', to: 'CertifiedParticipants',
activeFor: ['CertifiedParticipants'], activeFor: ['CertifiedParticipants'],
condition: () => {
return userResource?.data
},
}, },
{ {
label: 'Jobs', label: 'Jobs',
+269 -1
View File
@@ -34,7 +34,9 @@ export class Markdown {
} }
static get pasteConfig() { static get pasteConfig() {
return { tags: ['P'] } return {
tags: ['P'],
}
} }
render() { render() {
@@ -52,11 +54,277 @@ export class Markdown {
this._togglePlaceholder() this._togglePlaceholder()
) )
this.wrapper.addEventListener('keydown', (e) => this._onKeyDown(e)) this.wrapper.addEventListener('keydown', (e) => this._onKeyDown(e))
this.wrapper.addEventListener(
'paste',
(e) => this._onNativePaste(e),
true
)
} }
return this.wrapper 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
_togglePlaceholder() { _togglePlaceholder() {
const blocks = document.querySelectorAll( const blocks = document.querySelectorAll(
'.cdx-block.ce-paragraph[data-placeholder]' '.cdx-block.ce-paragraph[data-placeholder]'
+1
View File
@@ -5,6 +5,7 @@ import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(async ({ mode }) => { export default defineConfig(async ({ mode }) => {
const isDev = mode === 'development' const isDev = mode === 'development'
console.log(mode, isDev)
const frappeui = await importFrappeUIPlugin(isDev) const frappeui = await importFrappeUIPlugin(isDev)
const config = { const config = {
+272 -295
View File
@@ -920,16 +920,16 @@
crelt "^1.0.5" crelt "^1.0.5"
"@codemirror/state@6.x", "@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0": "@codemirror/state@6.x", "@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
version "6.5.3" version "6.5.4"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.3.tgz#256e256d466f49ed0879d462031de8bd541e1403" resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19"
integrity sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A== integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==
dependencies: dependencies:
"@marijn/find-cluster-break" "^1.0.0" "@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": "@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" version "6.39.11"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.10.tgz#ae0dfcb635fd307aa3b800e305c9f46152503dba" resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.11.tgz#200aebef2074bfbbb7a3d5f0644c1b560d876b39"
integrity sha512-QfT/PXhiiP76PxMnX0RQVPDQrqfRt9wr9QhInNHnEUu4PWoNS8QwwcIDEneXFChJv22y+Yu/Cz5lFMTPz+h16w== integrity sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==
dependencies: dependencies:
"@codemirror/state" "^6.5.0" "@codemirror/state" "^6.5.0"
crelt "^1.0.6" crelt "^1.0.6"
@@ -1400,130 +1400,130 @@
estree-walker "^1.0.1" estree-walker "^1.0.1"
picomatch "^2.2.2" picomatch "^2.2.2"
"@rollup/rollup-android-arm-eabi@4.55.1": "@rollup/rollup-android-arm-eabi@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz#067cfcd81f1c1bfd92aefe3ad5ef1523549d5052"
integrity sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg== integrity sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==
"@rollup/rollup-android-arm64@4.55.1": "@rollup/rollup-android-arm64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz#d3cfc675a40bbdec97bda6d7fe3b3b05f0e1cd93" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz#85e39a44034d7d4e4fee2a1616f0bddb85a80517"
integrity sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg== integrity sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==
"@rollup/rollup-darwin-arm64@4.55.1": "@rollup/rollup-darwin-arm64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz#eb912b8f59dd47c77b3c50a78489013b1d6772b4" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz#17d92fe98f2cc277b91101eb1528b7c0b6c00c54"
integrity sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg== integrity sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==
"@rollup/rollup-darwin-x64@4.55.1": "@rollup/rollup-darwin-x64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz#e7d0839fdfd1276a1d34bc5ebbbd0dfd7d0b81a0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz#89ae6c66b1451609bd1f297da9384463f628437d"
integrity sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ== integrity sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==
"@rollup/rollup-freebsd-arm64@4.55.1": "@rollup/rollup-freebsd-arm64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz#7ff8118760f7351e48fd0cd3717ff80543d6aac8" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz#cdbdb9947b26e76c188a31238c10639347413628"
integrity sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg== integrity sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==
"@rollup/rollup-freebsd-x64@4.55.1": "@rollup/rollup-freebsd-x64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz#49d330dadbda1d4e9b86b4a3951b59928a9489a9" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz#9b1458d07b6e040be16ee36d308a2c9520f7f7cc"
integrity sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw== integrity sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==
"@rollup/rollup-linux-arm-gnueabihf@4.55.1": "@rollup/rollup-linux-arm-gnueabihf@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz#98c5f1f8b9776b4a36e466e2a1c9ed1ba52ef1b6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz#1d50ded7c965d5f125f5832c971ad5b287befef7"
integrity sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ== integrity sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==
"@rollup/rollup-linux-arm-musleabihf@4.55.1": "@rollup/rollup-linux-arm-musleabihf@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz#b9acecd3672e742f70b0c8a94075c816a91ff040" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz#53597e319b7e65990d3bc2a5048097384814c179"
integrity sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg== integrity sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==
"@rollup/rollup-linux-arm64-gnu@4.55.1": "@rollup/rollup-linux-arm64-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz#7a6ab06651bc29e18b09a50ed1a02bc972977c9b" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz#597002909dec198ca4bdccb25f043d32db3d6283"
integrity sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ== integrity sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==
"@rollup/rollup-linux-arm64-musl@4.55.1": "@rollup/rollup-linux-arm64-musl@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz#3c8c9072ba4a4d4ef1156b85ab9a2cbb57c1fad0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz#286f0e0f799545ce288bdc5a7c777261fcba3d54"
integrity sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA== integrity sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==
"@rollup/rollup-linux-loong64-gnu@4.55.1": "@rollup/rollup-linux-loong64-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz#17a7af13530f4e4a7b12cd26276c54307a84a8b0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz#1fab07fa1a4f8d3697735b996517f1bae0ba101b"
integrity sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g== integrity sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==
"@rollup/rollup-linux-loong64-musl@4.55.1": "@rollup/rollup-linux-loong64-musl@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz#5cd7a900fd7b077ecd753e34a9b7ff1157fe70c1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz#efc2cb143d6c067f95205482afb177f78ed9ea3d"
integrity sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw== integrity sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==
"@rollup/rollup-linux-ppc64-gnu@4.55.1": "@rollup/rollup-linux-ppc64-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz#03a097e70243ddf1c07b59d3c20f38e6f6800539" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz#e8de8bd3463f96b92b7dfb7f151fd80ffe8a937c"
integrity sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw== integrity sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==
"@rollup/rollup-linux-ppc64-musl@4.55.1": "@rollup/rollup-linux-ppc64-musl@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz#a5389873039d4650f35b4fa060d286392eb21a94" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz#8c508fe28a239da83b3a9da75bcf093186e064b4"
integrity sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw== integrity sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==
"@rollup/rollup-linux-riscv64-gnu@4.55.1": "@rollup/rollup-linux-riscv64-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz#789e60e7d6e2b76132d001ffb24ba80007fb17d0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz#ff6d51976e0830732880770a9e18553136b8d92b"
integrity sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw== integrity sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==
"@rollup/rollup-linux-riscv64-musl@4.55.1": "@rollup/rollup-linux-riscv64-musl@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz#3556fa88d139282e9a73c337c9a170f3c5fe7aa4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz#325fb35eefc7e81d75478318f0deee1e4a111493"
integrity sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg== integrity sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==
"@rollup/rollup-linux-s390x-gnu@4.55.1": "@rollup/rollup-linux-s390x-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz#c085995b10143c16747a67f1a5487512b2ff04b2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz#37410fabb5d3ba4ad34abcfbe9ba9b6288413f30"
integrity sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg== integrity sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==
"@rollup/rollup-linux-x64-gnu@4.55.1": "@rollup/rollup-linux-x64-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz#9563a5419dd2604841bad31a39ccfdd2891690fb" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz#8ef907a53b2042068fc03fcc6a641e2b02276eca"
integrity sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg== integrity sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==
"@rollup/rollup-linux-x64-musl@4.55.1": "@rollup/rollup-linux-x64-musl@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz#691bb06e6269a8959c13476b0cd2aa7458facb31" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz#61b9ba09ea219e0174b3f35a6ad2afc94bdd5662"
integrity sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w== integrity sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==
"@rollup/rollup-openbsd-x64@4.55.1": "@rollup/rollup-openbsd-x64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz#223e71224746a59ce6d955bbc403577bb5a8be9d" resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz#fc4e54133134c1787d0b016ffdd5aeb22a5effd3"
integrity sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg== integrity sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==
"@rollup/rollup-openharmony-arm64@4.55.1": "@rollup/rollup-openharmony-arm64@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz#0817e5d8ecbfeb8b7939bf58f8ce3c9dd67fce77" resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz#959ae225b1eeea0cc5b7c9f88e4834330fb6cd09"
integrity sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw== integrity sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==
"@rollup/rollup-win32-arm64-msvc@4.55.1": "@rollup/rollup-win32-arm64-msvc@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz#de56d8f2013c84570ef5fb917aae034abda93e4a" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz#842acd38869fa1cbdbc240c76c67a86f93444c27"
integrity sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g== integrity sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==
"@rollup/rollup-win32-ia32-msvc@4.55.1": "@rollup/rollup-win32-ia32-msvc@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz#659aff5244312475aeea2c9479a6c7d397b517bf" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz#7ab654def4042df44cb29f8ed9d5044e850c66d5"
integrity sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA== integrity sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==
"@rollup/rollup-win32-x64-gnu@4.55.1": "@rollup/rollup-win32-x64-gnu@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz#2cb09549cbb66c1b979f9238db6dd454cac14a88" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz#7426cdec1b01d2382ffd5cda83cbdd1c8efb3ca6"
integrity sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg== integrity sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==
"@rollup/rollup-win32-x64-msvc@4.55.1": "@rollup/rollup-win32-x64-msvc@4.56.0":
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz#9eec0212732a432c71bde0350bc40b673d15b2db"
integrity sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw== integrity sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==
"@socket.io/component-emitter@~3.1.0": "@socket.io/component-emitter@~3.1.0":
version "3.1.2" version "3.1.2"
@@ -1896,9 +1896,9 @@
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/node@*": "@types/node@*":
version "25.0.8" version "25.0.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.8.tgz#e54e00f94fe1db2497b3e42d292b8376a2678c8d" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.10.tgz#4864459c3c9459376b8b75fd051315071c8213e7"
integrity sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg== integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==
dependencies: dependencies:
undici-types "~7.16.0" undici-types "~7.16.0"
@@ -1919,11 +1919,6 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== 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": "@types/web-bluetooth@^0.0.20":
version "0.0.20" version "0.0.20"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" 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" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.3.tgz#164b36653910d27c130cf6c945b4bd9bde5bcbee"
integrity sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA== integrity sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA==
"@vue/compiler-core@3.5.26": "@vue/compiler-core@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz#1a91ea90980528bedff7b1c292690bfb30612485" resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz#ce4402428e26095586eb889c41f6e172eb3960bd"
integrity sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w== integrity sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==
dependencies: dependencies:
"@babel/parser" "^7.28.5" "@babel/parser" "^7.28.5"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
entities "^7.0.0" entities "^7.0.0"
estree-walker "^2.0.2" estree-walker "^2.0.2"
source-map-js "^1.2.1" source-map-js "^1.2.1"
"@vue/compiler-dom@3.5.26": "@vue/compiler-dom@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz#66c36b6ed8bdf43236d7188ea332bc9d078eb286" resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz#32b2bc87f0a652c253986796ace0ed6213093af8"
integrity sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A== integrity sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==
dependencies: dependencies:
"@vue/compiler-core" "3.5.26" "@vue/compiler-core" "3.5.27"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
"@vue/compiler-sfc@3.5.26": "@vue/compiler-sfc@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz#fb1c6c4bf9a9e22bb169e039e19437cb6995917a" resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz#84651b8816bf8e7d6e62fddd14db86efd6d6f1b6"
integrity sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA== integrity sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==
dependencies: dependencies:
"@babel/parser" "^7.28.5" "@babel/parser" "^7.28.5"
"@vue/compiler-core" "3.5.26" "@vue/compiler-core" "3.5.27"
"@vue/compiler-dom" "3.5.26" "@vue/compiler-dom" "3.5.27"
"@vue/compiler-ssr" "3.5.26" "@vue/compiler-ssr" "3.5.27"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
estree-walker "^2.0.2" estree-walker "^2.0.2"
magic-string "^0.30.21" magic-string "^0.30.21"
postcss "^8.5.6" postcss "^8.5.6"
source-map-js "^1.2.1" source-map-js "^1.2.1"
"@vue/compiler-ssr@3.5.26": "@vue/compiler-ssr@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz#f6e94bccbb5339180779036ddfb614f998a197ea" resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz#b480cad09dacf8f3d9c82b9843402f1a803baee7"
integrity sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw== integrity sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==
dependencies: dependencies:
"@vue/compiler-dom" "3.5.26" "@vue/compiler-dom" "3.5.27"
"@vue/shared" "3.5.26" "@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" version "6.6.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/reactivity@3.5.26": "@vue/reactivity@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.26.tgz#59a1edf566dc80133c1c26c93711c877e8602c48" resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.27.tgz#d870557de1389a27b8abcb7cbfa30978dc69a000"
integrity sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ== integrity sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==
dependencies: dependencies:
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
"@vue/runtime-core@3.5.26": "@vue/runtime-core@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz#3f2c040bcf8018c03a1ab5adb0d788c13c986f0e" resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.27.tgz#bb43744ed070166c7d581b849ac22b71a9ccf127"
integrity sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q== integrity sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==
dependencies: dependencies:
"@vue/reactivity" "3.5.26" "@vue/reactivity" "3.5.27"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
"@vue/runtime-dom@3.5.26": "@vue/runtime-dom@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz#5954848614883948ecc1f631a67b32cc32f81936" resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz#392513252c7ca7e5277240fdc70b8093449127f5"
integrity sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ== integrity sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==
dependencies: dependencies:
"@vue/reactivity" "3.5.26" "@vue/reactivity" "3.5.27"
"@vue/runtime-core" "3.5.26" "@vue/runtime-core" "3.5.27"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
csstype "^3.2.3" csstype "^3.2.3"
"@vue/server-renderer@3.5.26": "@vue/server-renderer@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz#269055497fcc75b3984063f866f17c748b565ef4" resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.27.tgz#8137d0d7ec3b59d5992bb04c553775d209dddba7"
integrity sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA== integrity sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==
dependencies: dependencies:
"@vue/compiler-ssr" "3.5.26" "@vue/compiler-ssr" "3.5.27"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
"@vue/shared@3.5.26": "@vue/shared@3.5.27":
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.26.tgz#1e02ef2d64aced818cd31d81ce5175711dc90a9f" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.27.tgz#33a63143d8fb9ca1b3efbc7ecf9bd0ab05f7e06e"
integrity sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A== integrity sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==
"@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"
"@vueuse/core@^10.11.0", "@vueuse/core@^10.4.1": "@vueuse/core@^10.11.0", "@vueuse/core@^10.4.1":
version "10.11.1" version "10.11.1"
@@ -2068,28 +2053,29 @@
"@vueuse/shared" "12.8.2" "@vueuse/shared" "12.8.2"
vue "^3.5.13" 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": "@vueuse/metadata@10.11.1":
version "10.11.1" version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7" resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw== 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": "@vueuse/metadata@12.8.2":
version "12.8.2" version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3" resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A== integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
"@vueuse/router@12.7.0": "@vueuse/metadata@14.1.0":
version "12.7.0" version "14.1.0"
resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-12.7.0.tgz#b349b66e337057bb489b6d64d2dab044d41ca74d" resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.1.0.tgz#70fc2e94775e4a07369f11f86f6f0a465b04a381"
integrity sha512-Jp6dIel54oc2nh++zqjY06ipCcTT6YWDCNQ8dSSnqRwx90wIl7w7MQP7Wpp1wrDwXEoqhelfeZf2gjfrkAhq3g== integrity sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==
dependencies:
"@vueuse/shared" "12.7.0"
vue "^3.5.13"
"@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0": "@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0":
version "10.11.1" version "10.11.1"
@@ -2098,20 +2084,6 @@
dependencies: dependencies:
vue-demi ">=0.14.8" 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": "@vueuse/shared@12.8.2", "@vueuse/shared@^12.5.0":
version "12.8.2" version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930" resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
@@ -2119,6 +2091,11 @@
dependencies: dependencies:
vue "^3.5.13" 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": "@yr/monotone-cubic-spline@^1.0.3":
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9" 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== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.9.0: baseline-browser-mapping@^2.9.0:
version "2.9.14" version "2.9.17"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz#3b6af0bc032445bca04de58caa9a87cfe921cbb3" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz#9d6019766cd7eba738cb5f32c84b9f937cc87780"
integrity sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg== integrity sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==
binary-extensions@^2.0.0: binary-extensions@^2.0.0:
version "2.3.0" version "2.3.0"
@@ -2328,7 +2305,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.1.1" 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" version "4.28.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
@@ -2389,9 +2366,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001759: caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001759:
version "1.0.30001764" version "1.0.30001765"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz#03206c56469f236103b90f9ae10bcb8b9e1f6005" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz#4a78d8a797fd4124ebaab2043df942eb091648ee"
integrity sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g== integrity sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==
chalk@^4.1.0: chalk@^4.1.0:
version "4.1.2" version "4.1.2"
@@ -2506,16 +2483,16 @@ convert-source-map@^2.0.0:
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
core-js-compat@^3.43.0: core-js-compat@^3.43.0:
version "3.47.0" version "3.48.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.47.0.tgz#698224bbdbb6f2e3f39decdda4147b161e3772a3" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.48.0.tgz#7efbe1fc1cbad44008190462217cc5558adaeaa6"
integrity sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ== integrity sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==
dependencies: dependencies:
browserslist "^4.28.0" browserslist "^4.28.1"
core-js@^3.1.3, core-js@^3.26.1: core-js@^3.1.3, core-js@^3.26.1:
version "3.47.0" version "3.48.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.48.0.tgz#1f813220a47bbf0e667e3885c36cd6f0593bf14d"
integrity sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg== integrity sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==
crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6: crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6:
version "1.0.6" version "1.0.6"
@@ -2689,9 +2666,9 @@ ejs@^3.1.6:
jake "^10.8.5" jake "^10.8.5"
electron-to-chromium@^1.5.263: electron-to-chromium@^1.5.263:
version "1.5.267" version "1.5.277"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz#7164191a07bf32a7e646e68334f402dd60629821"
integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== integrity sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==
engine.io-client@~6.5.2: engine.io-client@~6.5.2:
version "6.5.4" version "6.5.4"
@@ -2726,9 +2703,9 @@ entities@^4.4.0:
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
entities@^7.0.0: entities@^7.0.0:
version "7.0.0" version "7.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.0.tgz#2ae4e443f3f17d152d3f5b0f79b932c1e59deb7a" resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
integrity sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ== integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9: es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9:
version "1.24.1" 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" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.256: frappe-ui@^0.1.261:
version "0.1.256" version "0.1.261"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.256.tgz#c14756eda75ca01ada034559e8bd2f91bcfe6dff" resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.261.tgz#d6919c713a37ed8a2bdb667707dba9ece4956c6d"
integrity sha512-zj8n6KXpMv/0h1NcaCsjFLP8QBnofDEBJgQa+xECU0/jbq4gSqNhFOkcx788qNL+vmBo9frywTeXwDpl7hUCZA== integrity sha512-sEdEAgjAkrTERYWk5HBOQuKa7/xuex/X8/Y/hCYFbEThwwy2ZWmQOCsTNyOCjXAn7lyV49Ues/TW01koIq/ysQ==
dependencies: dependencies:
"@floating-ui/vue" "^1.1.6" "@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14" "@headlessui/vue" "^1.7.14"
@@ -3613,9 +3590,9 @@ lodash.sortby@^4.7.0:
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash@^4.17.20: lodash@^4.17.20:
version "4.17.21" version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
log-symbols@^4.1.0: log-symbols@^4.1.0:
version "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" source-map-js "^1.2.1"
prettier@^3.3.2: prettier@^3.3.2:
version "3.7.4" version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
pretty-bytes@^5.3.0: pretty-bytes@^5.3.0:
version "5.6.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" w3c-keyname "^2.2.0"
prosemirror-markdown@^1.13.1: prosemirror-markdown@^1.13.1:
version "1.13.2" version "1.13.3"
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz#863eb3fd5f57a444e4378174622b562735b1c503" resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.3.tgz#cf38e98f10c432b906bfcc7179c2e3ab58f49362"
integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g== integrity sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==
dependencies: dependencies:
"@types/markdown-it" "^14.0.0" "@types/markdown-it" "^14.0.0"
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" 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: 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" version "1.11.0"
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz#4cf9fe5dcbdbfebd62499f24386e7cec9bc9979b" resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz#f5c5050354423dc83c6b083f6f1959ec86a3f9ba"
integrity sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw== integrity sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==
dependencies: dependencies:
prosemirror-model "^1.21.0" 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: 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" version "1.41.5"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.4.tgz#4e1b3e90accc0eebe3bddb497a40ce54e4de722d" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.5.tgz#3e152d14af633f2f5a73aba24e6130c63f643b2b"
integrity sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA== integrity sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==
dependencies: dependencies:
prosemirror-model "^1.20.0" prosemirror-model "^1.20.0"
prosemirror-state "^1.0.0" prosemirror-state "^1.0.0"
@@ -4395,37 +4372,37 @@ rollup@^2.43.1:
fsevents "~2.3.2" fsevents "~2.3.2"
rollup@^4.2.0: rollup@^4.2.0:
version "4.55.1" version "4.56.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.56.0.tgz#65959d13cfbd7e48b8868c05165b1738f0143862"
integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A== integrity sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==
dependencies: dependencies:
"@types/estree" "1.0.8" "@types/estree" "1.0.8"
optionalDependencies: optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.55.1" "@rollup/rollup-android-arm-eabi" "4.56.0"
"@rollup/rollup-android-arm64" "4.55.1" "@rollup/rollup-android-arm64" "4.56.0"
"@rollup/rollup-darwin-arm64" "4.55.1" "@rollup/rollup-darwin-arm64" "4.56.0"
"@rollup/rollup-darwin-x64" "4.55.1" "@rollup/rollup-darwin-x64" "4.56.0"
"@rollup/rollup-freebsd-arm64" "4.55.1" "@rollup/rollup-freebsd-arm64" "4.56.0"
"@rollup/rollup-freebsd-x64" "4.55.1" "@rollup/rollup-freebsd-x64" "4.56.0"
"@rollup/rollup-linux-arm-gnueabihf" "4.55.1" "@rollup/rollup-linux-arm-gnueabihf" "4.56.0"
"@rollup/rollup-linux-arm-musleabihf" "4.55.1" "@rollup/rollup-linux-arm-musleabihf" "4.56.0"
"@rollup/rollup-linux-arm64-gnu" "4.55.1" "@rollup/rollup-linux-arm64-gnu" "4.56.0"
"@rollup/rollup-linux-arm64-musl" "4.55.1" "@rollup/rollup-linux-arm64-musl" "4.56.0"
"@rollup/rollup-linux-loong64-gnu" "4.55.1" "@rollup/rollup-linux-loong64-gnu" "4.56.0"
"@rollup/rollup-linux-loong64-musl" "4.55.1" "@rollup/rollup-linux-loong64-musl" "4.56.0"
"@rollup/rollup-linux-ppc64-gnu" "4.55.1" "@rollup/rollup-linux-ppc64-gnu" "4.56.0"
"@rollup/rollup-linux-ppc64-musl" "4.55.1" "@rollup/rollup-linux-ppc64-musl" "4.56.0"
"@rollup/rollup-linux-riscv64-gnu" "4.55.1" "@rollup/rollup-linux-riscv64-gnu" "4.56.0"
"@rollup/rollup-linux-riscv64-musl" "4.55.1" "@rollup/rollup-linux-riscv64-musl" "4.56.0"
"@rollup/rollup-linux-s390x-gnu" "4.55.1" "@rollup/rollup-linux-s390x-gnu" "4.56.0"
"@rollup/rollup-linux-x64-gnu" "4.55.1" "@rollup/rollup-linux-x64-gnu" "4.56.0"
"@rollup/rollup-linux-x64-musl" "4.55.1" "@rollup/rollup-linux-x64-musl" "4.56.0"
"@rollup/rollup-openbsd-x64" "4.55.1" "@rollup/rollup-openbsd-x64" "4.56.0"
"@rollup/rollup-openharmony-arm64" "4.55.1" "@rollup/rollup-openharmony-arm64" "4.56.0"
"@rollup/rollup-win32-arm64-msvc" "4.55.1" "@rollup/rollup-win32-arm64-msvc" "4.56.0"
"@rollup/rollup-win32-ia32-msvc" "4.55.1" "@rollup/rollup-win32-ia32-msvc" "4.56.0"
"@rollup/rollup-win32-x64-gnu" "4.55.1" "@rollup/rollup-win32-x64-gnu" "4.56.0"
"@rollup/rollup-win32-x64-msvc" "4.55.1" "@rollup/rollup-win32-x64-msvc" "4.56.0"
fsevents "~2.3.2" fsevents "~2.3.2"
rope-sequence@^1.3.0: rope-sequence@^1.3.0:
@@ -4797,9 +4774,9 @@ tempy@^0.6.0:
unique-string "^2.0.0" unique-string "^2.0.0"
terser@^5.0.0: terser@^5.0.0:
version "5.44.1" version "5.46.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.1.tgz#e391e92175c299b8c284ad6ded609e37303b0a9c" resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695"
integrity sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw== integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==
dependencies: dependencies:
"@jridgewell/source-map" "^0.3.3" "@jridgewell/source-map" "^0.3.3"
acorn "^8.15.0" 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== integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
ufo@^1.6.1: ufo@^1.6.1:
version "1.6.2" version "1.6.3"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.2.tgz#aaf4d46b98425b2fb5031abe8d65ca069e93e755" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f"
integrity sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q== integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==
unbox-primitive@^1.1.0: unbox-primitive@^1.1.0:
version "1.1.0" version "1.1.0"
@@ -5169,7 +5146,7 @@ vue-codemirror@6.1.1:
"@codemirror/state" "6.x" "@codemirror/state" "6.x"
"@codemirror/view" "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" version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== 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" resolved "https://registry.yarnpkg.com/vue-draggable-next/-/vue-draggable-next-2.2.1.tgz#adbe98c74610cca8f4eb63f92042681f96920451"
integrity sha512-EAMS1IRHF0kZO0o5PMOinsQsXIqsrKT1hKmbICxG3UEtn7zLFkLxlAtajcCcUTisNvQ6TtCB5COjD9a1raNADw== integrity sha512-EAMS1IRHF0kZO0o5PMOinsQsXIqsrKT1hKmbICxG3UEtn7zLFkLxlAtajcCcUTisNvQ6TtCB5COjD9a1raNADw==
vue-router@4.2.2: vue-router@^4.6.4:
version "4.2.2" version "4.6.4"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.2.2.tgz#b0097b66d89ca81c0986be03da244c7b32a4fd81" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.6.4.tgz#a0a9cb9ef811a106d249e4bb9313d286718020d8"
integrity sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ== integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==
dependencies: dependencies:
"@vue/devtools-api" "^6.5.0" "@vue/devtools-api" "^6.6.4"
vue3-apexcharts@1.8.0: vue3-apexcharts@1.8.0:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf" resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf"
integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA== integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==
vue@^3.5.0, vue@^3.5.13: vue@^3.5.13, vue@^3.5.27:
version "3.5.26" version "3.5.27"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.26.tgz#03a0b17311e0e593d34b9358fa249b85e3a6d9fb" resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.27.tgz#e55fd941b614459ab2228489bc19d1692e05876c"
integrity sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA== integrity sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==
dependencies: dependencies:
"@vue/compiler-dom" "3.5.26" "@vue/compiler-dom" "3.5.27"
"@vue/compiler-sfc" "3.5.26" "@vue/compiler-sfc" "3.5.27"
"@vue/runtime-dom" "3.5.26" "@vue/runtime-dom" "3.5.27"
"@vue/server-renderer" "3.5.26" "@vue/server-renderer" "3.5.27"
"@vue/shared" "3.5.26" "@vue/shared" "3.5.27"
vuedraggable@4.1.0: vuedraggable@4.1.0:
version "4.1.0" version "4.1.0"
@@ -5281,9 +5258,9 @@ which-collection@^1.0.2:
is-weakset "^2.0.3" is-weakset "^2.0.3"
which-typed-array@^1.1.16, which-typed-array@^1.1.19: which-typed-array@^1.1.16, which-typed-array@^1.1.19:
version "1.1.19" version "1.1.20"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122"
integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==
dependencies: dependencies:
available-typed-arrays "^1.0.7" available-typed-arrays "^1.0.7"
call-bind "^1.0.8" call-bind "^1.0.8"
+103
View File
@@ -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