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

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

View File

@@ -1,5 +1,5 @@
{ {
"branches": ["develop"], "branches": ["main"],
"plugins": [ "plugins": [
"@semantic-release/commit-analyzer", { "@semantic-release/commit-analyzer", {
"preset": "angular" "preset": "angular"

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]")

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']

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"
}, },

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) => {

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");
}

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">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<a <a
:href="submissionFile.file_url" :href="submissionResource.doc.assignment_attachment"
target="_blank" target="_blank"
class="flex flex-col cursor-pointer !no-underline" class="cursor-pointer !no-underline text-sm leading-5"
> >
<span class="text-sm leading-5"> <div class="flex items-center">
{{ submissionFile.file_name }} <div class="border rounded-md p-2 mr-2">
</span> <FileText class="h-5 w-5 stroke-1.5" />
<span class="text-sm text-ink-gray-5 mt-1"> </div>
{{ getFileSize(submissionFile.file_size) }} <span>
</span> {{
submissionResource.doc.assignment_attachment
.split('/')
.pop()
}}
</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,
isDirty.value = true () => {
if (
props.submissionName == 'new' &&
submissionResource.doc?.assignment_attachment
) {
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(() => {

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
}) })

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',
}, },
{ {

View File

@@ -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,22 +77,19 @@
<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
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions> </ComboboxOptions>
<div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</div> </div>
</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() {

View File

@@ -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',
} }
) )

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>

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'

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'

View File

@@ -23,10 +23,8 @@
(value, close) => { (value, close) => {
close() close()
router.push({ router.push({
name: 'CourseForm', name: 'Courses',
params: { query: { newCourse: '1' },
courseName: 'new',
},
}) })
} }
" "

View File

@@ -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>

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">
{{ __('Not Saved') }} <Badge v-if="isDirty" theme="orange">
</Badge> {{ __('Not Saved') }}
</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
} }
} }

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 [
{ {

View File

@@ -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>

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"

View File

@@ -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>
<Badge <div class="space-x-2">
v-if="isDirty" <Badge
:label="__('Not Saved')" v-if="isDirty"
variant="subtle" :label="__('Not Saved')"
theme="orange" variant="subtle"
/> 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>

View File

@@ -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'))

View File

@@ -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>

View File

@@ -8,22 +8,24 @@
<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 v-for="tab in tabs" :key="tab.label"> <div class="space-y-5">
<div <div v-for="tab in tabs" :key="tab.label">
v-if="!tab.hideLabel" <div
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out" v-if="!tab.hideLabel"
> class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
<span>{{ __(tab.label) }}</span> >
</div> <span>{{ __(tab.label) }}</span>
<nav class="space-y-1">
<div v-for="item in tab.items" @click="activeTab = item">
<SidebarLink
:link="item"
:key="item.label"
:activeTab="activeTab?.label"
/>
</div> </div>
</nav> <nav class="space-y-1">
<div v-for="item in tab.items" @click="activeTab = item">
<SidebarLink
:link="item"
:key="item.label"
:activeTab="activeTab?.label"
/>
</div>
</nav>
</div>
</div> </div>
</div> </div>
<div <div

View File

@@ -1,12 +1,33 @@
<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">
<ChevronLeft <div class="flex items-center space-x-2">
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer" <ChevronLeft
@click="emit('updateStep', 'list')" class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
/> @click="emit('updateStep', 'list')"
<div class="text-xl font-semibold text-ink-gray-9"> />
{{ __('Transaction Details') }} <div class="text-xl font-semibold text-ink-gray-9">
{{ __('Transaction Details') }}
</div>
</div>
<div class="space-x-2">
<Button
v-if="
transactionData?.payment_for_document_type &&
transactionData?.payment_for_document
"
@click="openDetails()"
>
{{ __('Open the ') }}
{{
transactionData.payment_for_document_type == 'LMS Course'
? __('Course')
: __('Batch')
}}
</Button>
<Button variant="solid" @click="saveTransaction()">
{{ __('Save') }}
</Button>
</div> </div>
</div> </div>
<div v-if="transactionData" class="overflow-y-auto"> <div v-if="transactionData" class="overflow-y-auto">
@@ -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>

View File

@@ -1,12 +1,20 @@
<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 class="text-xl font-semibold mb-1 text-ink-gray-9"> <div>
{{ __(label) }} <div class="text-xl font-semibold mb-1 text-ink-gray-9">
</div> {{ __(label) }}
<div class="text-ink-gray-6 leading-5"> </div>
{{ __(description) }} <div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</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>
<div class="flex items-center space-x-5 mb-4"> <div class="flex items-center space-x-5 mb-4">

View File

@@ -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
} }
} }

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')
} }

View File

@@ -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
) )

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>

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';

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

View File

@@ -130,7 +130,6 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
Dropdown, Dropdown,
FormControl, FormControl,
@@ -185,24 +184,27 @@ 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) {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
}) })
const setCategories = (data) => {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}
const updateBatches = () => { const updateBatches = () => {
updateFilters() updateFilters()
batches.update({ batches.update({
filters: filters.value, filters: filters.value,
orderBy: orderBy.value, orderBy: orderBy.value,
}) })
batches.reload() batches.reload().then((data) => {
setCategories(data)
})
} }
const updateFilters = () => { const updateFilters = () => {

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'
} }

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>

View File

@@ -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)

View File

@@ -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>

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>

View File

@@ -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>

View File

@@ -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({
courseResource.reload() doctype: 'LMS Course',
getMetaInfo('courses', props.courseName, meta) name: props.course.data?.name,
auto: true,
})
watch(
() => courseResource.doc,
() => {
check_permission()
getMetaInfo('courses', courseResource.doc?.name, meta)
updateCourseData()
}
)
const updateCourseData = () => {
Object.keys(courseResource.doc).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
courseResource.doc.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (key == 'related_courses') {
related_courses.value = []
courseResource.doc.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
}
})
let checkboxes = [
'published',
'upcoming',
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
'paid_certificate',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
courseResource.doc[key] = courseResource.doc[key] ? true : false
}
}
const submitCourse = () => {
validateFields()
updateCourse()
}
const validateFields = () => {
courseResource.doc.description = sanitizeHTML(courseResource.doc.description)
Object.keys(courseResource.doc).forEach((key) => {
if (key != 'description' && typeof courseResource.doc[key] === 'string') {
courseResource.doc[key] = escapeHTML(courseResource.doc[key])
}
})
}
const updateCourse = () => {
courseResource.setValue.submit(
{
...courseResource.doc,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
},
{
onSuccess() {
updateMetaInfo('courses', courseResource.doc?.name, meta)
toast.success(__('Course updated successfully'))
isDirty.value = false
courseResource.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
} }
const keyboardShortcut = (e) => { 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>

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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>

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>

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>

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
@@ -271,7 +271,7 @@ watch(jobs, () => {
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
{ label: '', value: '' }, { label: ' ', value: ' ' },
{ label: __('Full Time'), value: 'Full Time' }, { label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' }, { label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' }, { label: __('Contract'), value: 'Contract' },
@@ -281,7 +281,7 @@ const jobTypes = computed(() => {
const workModes = computed(() => { const workModes = computed(() => {
return [ return [
{ label: '', value: '' }, { label: ' ', value: ' ' },
{ label: 'On site', value: 'On-site' }, { label: 'On site', value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' }, { label: 'Hybrid', value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' }, { label: 'Remote', value: 'Remote' },

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,

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',
},
}, },
] ]

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
} }

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'
" "

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 = () => {

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 =
isSuccessfullyCompleted.value = true key === 'cmi.completion_status' && value === 'completed'
saveProgress({ const shouldRestart =
is_complete: isSuccessfullyCompleted.value, (key === 'cmi.core.lesson_status' && value === 'failed') ||
scorm_content: '', (key === 'cmi.completion_status' && value === 'incomplete')
})
} else if (value === 'failed' && courseRestartOnFailure) { if (isLessonStatus || isCompletionStatus) {
saveProgress({ isSuccessfullyCompleted.value = true
is_complete: isSuccessfullyCompleted.value, }
scorm_content: '',
}) if (
} isLessonStatus ||
} else if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) { 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,

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',

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
} }

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({

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',

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]'

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 = {

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
lms/auth.py Normal file
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