Compare commits

..

12 Commits

Author SHA1 Message Date
Jannat Patel
7cdd155f75 Revert "chore: merge 'develop' into 'main'" 2024-09-11 11:10:34 +05:30
Jannat Patel
de8c907c51 Merge pull request #1013 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-11 11:09:55 +05:30
Jannat Patel
0fd1cabd60 Merge pull request #1003 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-04 10:36:05 +05:30
Jannat Patel
8dd480735c Merge pull request #996 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-28 11:24:59 +05:30
Jannat Patel
676f1a1f0e Merge pull request #984 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-21 10:48:23 +05:30
Jannat Patel
ce75422126 Merge pull request #966 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-14 11:24:10 +05:30
Jannat Patel
3a097d6b15 Merge pull request #956 from frappe/develop
chore: Merge develop into main
2024-08-06 11:27:00 +05:30
Jannat Patel
9de1bf1020 Merge pull request #954 from frappe/develop
chore: Merge develop into main
2024-08-05 14:47:45 +05:30
Jannat Patel
93e5cf1c25 Merge pull request #952 from frappe/develop
chore: Merge develop to main
2024-08-05 12:22:05 +05:30
Jannat Patel
6e2376570b Merge pull request #949 from frappe/develop
chore: Merge develop to main
2024-08-01 17:16:22 +05:30
Jannat Patel
b20c4bf197 Merge pull request #948 from frappe/develop
chore: Merge develop to main
2024-07-31 16:33:43 +05:30
Jannat Patel
6ae1d92033 Merge pull request #925 from frappe/develop
chore: merge `develop` into `main`
2024-07-11 09:11:50 +05:30
189 changed files with 12191 additions and 83974 deletions

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "frappe-ui"] [submodule "frappe-ui"]
path = frappe-ui path = frappe-ui
url = https://github.com/frappe/frappe-ui url = https://github.com/pateljannat/frappe-ui

View File

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

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Create a course // Create a course
cy.get("header").children().last().children().last().click(); cy.get("a").contains("New Course").click();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
@@ -31,35 +31,12 @@ describe("Course Creation", () => {
.contains("Preview Video") .contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c"); .type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}"); cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label") cy.get(".search-input").click().type("frappe");
.contains("Category") cy.wait(1000);
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-") cy.get("[id^=headlessui-combobox-option-")
.should("be.visible") .should("be.visible")
.first() .first()
.click(); .click();
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("frappe");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").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();
@@ -73,7 +50,7 @@ describe("Course Creation", () => {
.should("be.visible") .should("be.visible")
.within(() => { .within(() => {
cy.get("label").contains("Title").type("Test Chapter"); cy.get("label").contains("Title").type("Test Chapter");
cy.button("Create").click(); cy.button("Add Chapter").click();
}); });
// Add Lesson // Add Lesson
@@ -84,7 +61,21 @@ describe("Course Creation", () => {
cy.wait(1000); cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson"); cy.get("label").contains("Title").type("Test Lesson");
/* cy.get("#content .ce-block")
.click()
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
/* cy.get("#content .ce-block")
.click()
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "Youtube.mov",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("#content .ce-block").type( cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now." "This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
); );
@@ -128,6 +119,12 @@ describe("Course Creation", () => {
cy.url().should("include", "/learn/1-1"); cy.url().should("include", "/learn/1-1");
cy.get("div").contains("Test Lesson"); cy.get("div").contains("Test Lesson");
cy.get("video")
.should("be.visible")
.children("source")
.invoke("attr", "src")
.should("include", "/files/Youtube");
cy.get("div").contains( cy.get("div").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now." "This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
); );

Submodule frappe-ui deleted from 8cd9b06a5e

View File

@@ -18,12 +18,10 @@
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "^1.6.0",
"ace-builds": "^1.36.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.72", "frappe-ui": "^0.1.56",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",

Binary file not shown.

Binary file not shown.

BIN
frontend/public/Youtube.mov Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -14,10 +14,8 @@ import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue' import MobileLayout from './components/MobileLayout.vue'
import { stopSession } from '@/telemetry' import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry' import { init as initTelemetry } from '@/telemetry'
import { usersStore } from '@/stores/user'
const screenSize = useScreenSize() const screenSize = useScreenSize()
let { userResource } = usersStore()
const Layout = computed(() => { const Layout = computed(() => {
if (screenSize.width < 640) { if (screenSize.width < 640) {
@@ -28,7 +26,6 @@ const Layout = computed(() => {
}) })
onMounted(async () => { onMounted(async () => {
if (!userResource.data) return
await initTelemetry() await initTelemetry()
}) })

View File

@@ -25,7 +25,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Avatar } from 'frappe-ui' import { createListResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
const props = defineProps({ const props = defineProps({
@@ -35,15 +35,24 @@ const props = defineProps({
}, },
}) })
const communications = createResource({ const communications = createListResource({
url: 'lms.lms.api.get_announcements', doctype: 'Communication',
makeParams(value) { fields: [
return { 'subject',
batch: props.batch, 'content',
} 'recipients',
'cc',
'communication_date',
'sender',
'sender_full_name',
],
filters: {
reference_doctype: 'LMS Batch',
reference_name: props.batch,
}, },
orderBy: 'communication_date desc',
auto: true, auto: true,
cache: ['announcement', props.batch], cache: ['batch', props.batch],
}) })
</script> </script>
<style> <style>

View File

@@ -107,7 +107,6 @@ const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks()) const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false) const showPageModal = ref(false)
const isModerator = ref(false) const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null) const pageToEdit = ref(null)
const showWebPages = ref(false) const showWebPages = ref(false)
@@ -168,17 +167,6 @@ const addNotifications = () => {
} }
} }
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'],
})
}
}
const openPageModal = (link) => { const openPageModal = (link) => {
showPageModal.value = true showPageModal.value = true
pageToEdit.value = link pageToEdit.value = link
@@ -209,8 +197,6 @@ const getSidebarFromStorage = () => {
watch(userResource, () => { watch(userResource, () => {
if (userResource.data) { if (userResource.data) {
isModerator.value = userResource.data.is_moderator isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addQuizzes()
} }
}) })

View File

@@ -1,92 +1,49 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="assessments.data?.length"> <div v-if="assessments.data?.length">
<ListView <ListView
:columns="getAssessmentColumns()" :columns="getAssessmentColumns()"
:rows="assessments.data" :rows="assessments.data"
row-key="name" row-key="name"
:options="{ :options="{
selectable: false,
showTooltip: false, showTooltip: false,
getRowRoute: (row) => getRowRoute(row), getRowRoute: (row) => {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
},
}" }"
> >
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView> </ListView>
</div> </div>
<div v-else class="text-sm italic text-gray-600"> <div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }} {{ __('No Assessments') }}
</div> </div>
</div> </div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template> </template>
<script setup> <script setup>
import { import { ListView, createResource } from 'frappe-ui'
ListView, import { inject } from 'vue'
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const showModal = ref(false)
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -117,61 +74,6 @@ const assessments = createResource({
auto: true, auto: true,
}) })
const deleteAssessments = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Assessment',
documents: values.assessments,
}
},
})
const removeAssessments = (selections, unselectAll) => {
deleteAssessments.submit(
{ assessments: Array.from(selections) },
{
onSuccess(data) {
assessments.reload()
unselectAll()
},
}
)
}
const getRowRoute = (row) => {
if (row.assessment_type == 'LMS Assignment') {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
params: {
quizID: row.assessment_name,
},
}
}
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => { const getAssessmentColumns = () => {
let columns = [ let columns = [
{ {

View File

@@ -56,6 +56,7 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
audio.value = document.querySelector('audio') audio.value = document.querySelector('audio')
console.log(audio.value)
audio.value.onloadedmetadata = () => { audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration duration.value = audio.value.duration
} }

View File

@@ -4,11 +4,15 @@
<div class="text-xl font-semibold"> <div class="text-xl font-semibold">
{{ __('Courses') }} {{ __('Courses') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()"> <Button
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('Add') }} {{ __('Add Course') }}
</Button> </Button>
</div> </div>
<div v-if="courses.data?.length"> <div v-if="courses.data?.length">
@@ -84,7 +88,6 @@ import {
ListRowItem, ListRowItem,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -129,32 +132,23 @@ const getCoursesColumns = () => {
] ]
} }
const deleteCourses = createResource({ const removeCourse = createResource({
url: 'lms.lms.api.delete_documents', url: 'frappe.client.delete',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Course', doctype: 'Batch Course',
documents: values.courses, name: values.course,
} }
}, },
}) })
const removeCourses = (selections, unselectAll) => { const removeCourses = (selections, unselectAll) => {
deleteCourses.submit( selections.forEach(async (course) => {
{ removeCourse.submit({ course })
courses: Array.from(selections), })
}, setTimeout(() => {
{
onSuccess(data) {
courses.reload() courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
unselectAll() unselectAll()
}, }, 1000)
}
)
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -75,7 +75,6 @@
variant="solid" variant="solid"
class="w-full mt-2" class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left" v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
@click="enrollInBatch()"
> >
{{ __('Enroll Now') }} {{ __('Enroll Now') }}
</Button> </Button>
@@ -98,13 +97,11 @@
</template> </template>
<script setup> <script setup>
import { inject, computed } from 'vue' import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui' import { Badge, Button } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next' import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils' import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user') const user = inject('$user')
const props = defineProps({ const props = defineProps({
@@ -114,39 +111,6 @@ const props = defineProps({
}, },
}) })
const enroll = createResource({
url: 'lms.lms.utils.enroll_in_batch',
makeParams(values) {
return {
batch: props.batch.data.name,
}
},
})
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
}
enroll.submit(
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const seats_left = computed(() => { const seats_left = computed(() => {
if (props.batch.data?.seat_count) { if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length return props.batch.data?.seat_count - props.batch.data?.students?.length

View File

@@ -1,9 +1,9 @@
<template> <template>
<Button class="float-right mb-3" @click="openStudentModal()"> <Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('Add') }} {{ __('Add Student') }}
</Button> </Button>
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Students') }} {{ __('Students') }}
@@ -88,7 +88,6 @@ import {
import { Trash2, Plus } from 'lucide-vue-next' import { Trash2, Plus } from 'lucide-vue-next'
import { ref } from 'vue' import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
const showStudentModal = ref(false) const showStudentModal = ref(false)
@@ -136,28 +135,23 @@ const openStudentModal = () => {
showStudentModal.value = true showStudentModal.value = true
} }
const deleteStudents = createResource({ const removeStudent = createResource({
url: 'lms.lms.api.delete_documents', url: 'frappe.client.delete',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Student', doctype: 'Batch Student',
documents: values.students, name: values.student,
} }
}, },
}) })
const removeStudents = (selections, unselectAll) => { const removeStudents = (selections, unselectAll) => {
deleteStudents.submit( selections.forEach(async (student) => {
{ removeStudent.submit({ student })
students: Array.from(selections), })
}, setTimeout(() => {
{
onSuccess(data) {
students.reload() students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
unselectAll() unselectAll()
}, }, 500)
}
)
} }
</script> </script>

View File

@@ -1,92 +0,0 @@
<template>
<div class="flex flex-col justify-between min-h-0">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1">
{{ __(label) }}
</div>
<Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<div class="text-xs text-gray-600">
{{ __(description) }}
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import { watch, ref } from 'vue'
const isDirty = ref(false)
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
},
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Website Settings',
name: 'Website Settings',
fieldname: values.fields,
}
},
})
const update = () => {
let fieldsToSave = {}
let imageFields = ['favicon', 'banner_image', 'footer_logo']
props.fields.forEach((f) => {
if (imageFields.includes(f.name)) {
fieldsToSave[f.name] = f.value ? f.value.file_url : null
} else {
fieldsToSave[f.name] = f.value
}
})
saveSettings.submit(
{
fields: fieldsToSave,
},
{
onSuccess(data) {
isDirty.value = false
},
}
)
}
watch(props.data, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}
})
</script>

View File

@@ -1,151 +0,0 @@
<template>
<div class="flex flex-col min-h-0">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<Button @click="() => showCategoryForm()">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
<div
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<FormControl
ref="categoryInput"
v-model="category"
:placeholder="__('Category Name')"
class="flex-1"
/>
<Button @click="addCategory()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="text-base divide-y">
<FormControl
:value="cat.category"
type="text"
v-for="cat in categories.data"
class="form-control"
@change.stop="(e) => update(cat.name, e.target.value)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
createListResource,
createResource,
debounce,
} from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { ref } from 'vue'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const newCategory = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Category',
category: category.value,
},
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{
onSuccess(data) {
categories.reload()
category.value = null
},
}
)
}
const showCategoryForm = () => {
showForm.value = !showForm.value
setTimeout(() => {
categoryInput.value.$el.querySelector('input').focus()
}, 0)
}
const updateCategory = createResource({
url: 'frappe.client.rename_doc',
makeParams(values) {
return {
doctype: 'LMS Category',
old_name: values.name,
new_name: values.category,
}
},
})
const update = (name, value) => {
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
categories.reload()
},
}
)
}
</script>
<style>
.form-control input {
padding: 1.25rem 0;
border-color: transparent;
background: white;
}
.form-control input:focus {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
.form-control input:hover {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
</style>

View File

@@ -1,204 +0,0 @@
<template>
<div
class="editor flex flex-col gap-1"
:style="{
height: height,
}"
>
<span class="text-xs" v-if="label">
{{ label }}
</span>
<div
ref="editor"
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
/>
<span
class="mt-1 text-xs text-gray-600"
v-show="description"
v-html="description"
></span>
<Button
v-if="showSaveButton"
@click="emit('save', aceEditor?.getValue())"
class="mt-3"
>
{{ __('Save') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useDark } from '@vueuse/core'
import ace from 'ace-builds'
import 'ace-builds/src-min-noconflict/ext-searchbox'
import 'ace-builds/src-min-noconflict/theme-chrome'
import 'ace-builds/src-min-noconflict/theme-twilight'
import { PropType, onMounted, ref, watch } from 'vue'
import { Button } from 'frappe-ui'
const isDark = useDark({
attribute: 'data-theme',
})
const props = defineProps({
modelValue: {
type: [Object, String, Array],
},
type: {
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
default: 'JSON',
},
label: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
height: {
type: String,
default: '250px',
},
showLineNumbers: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: true,
},
showSaveButton: {
type: Boolean,
default: false,
},
description: {
type: String,
default: '',
},
})
const emit = defineEmits(['save', 'update:modelValue'])
const editor = ref<HTMLElement | null>(null)
let aceEditor = null as ace.Ace.Editor | null
onMounted(() => {
setupEditor()
})
const setupEditor = () => {
aceEditor = ace.edit(editor.value as HTMLElement)
resetEditor(props.modelValue as string, true)
aceEditor.setReadOnly(props.readonly)
aceEditor.setOptions({
fontSize: '12px',
useWorker: false,
showGutter: props.showLineNumbers,
wrap: props.showLineNumbers,
})
if (props.type === 'CSS') {
import('ace-builds/src-noconflict/mode-css').then(() => {
aceEditor?.session.setMode('ace/mode/css')
})
} else if (props.type === 'JavaScript') {
import('ace-builds/src-noconflict/mode-javascript').then(() => {
aceEditor?.session.setMode('ace/mode/javascript')
})
} else if (props.type === 'Python') {
import('ace-builds/src-noconflict/mode-python').then(() => {
aceEditor?.session.setMode('ace/mode/python')
})
} else if (props.type === 'JSON') {
import('ace-builds/src-noconflict/mode-json').then(() => {
aceEditor?.session.setMode('ace/mode/json')
})
} else {
import('ace-builds/src-noconflict/mode-html').then(() => {
aceEditor?.session.setMode('ace/mode/html')
})
}
aceEditor.on('blur', () => {
try {
let value = aceEditor?.getValue() || ''
if (props.type === 'JSON') {
value = JSON.parse(value)
}
if (value === props.modelValue) return
if (!props.showSaveButton && !props.readonly) {
emit('update:modelValue', value)
}
} catch (e) {
// do nothing
}
})
}
const getModelValue = () => {
let value = props.modelValue || ''
try {
if (props.type === 'JSON' || typeof value === 'object') {
value = JSON.stringify(value, null, 2)
}
} catch (e) {
// do nothing
}
return value as string
}
function resetEditor(value: string, resetHistory = false) {
value = getModelValue()
aceEditor?.setValue(value)
aceEditor?.clearSelection()
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
props.autofocus && aceEditor?.focus()
if (resetHistory) {
aceEditor?.session.getUndoManager().reset()
}
}
watch(isDark, () => {
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
})
watch(
() => props.type,
() => {
setupEditor()
}
)
watch(
() => props.modelValue,
() => {
resetEditor(props.modelValue as string)
}
)
defineExpose({ resetEditor })
</script>
<style scoped>
.editor .ace_editor {
height: 100%;
width: 100%;
border-radius: 5px;
overscroll-behavior: none;
}
.editor :deep(.ace_scrollbar-h) {
display: none;
}
.editor :deep(.ace_search) {
@apply dark:bg-gray-800 dark:text-gray-200;
@apply dark:border-gray-800;
}
.editor :deep(.ace_searchbtn) {
@apply dark:bg-gray-800 dark:text-gray-200;
@apply dark:border-gray-800;
}
.editor :deep(.ace_button) {
@apply dark:bg-gray-800 dark:text-gray-200;
}
.editor :deep(.ace_search_field) {
@apply dark:bg-gray-900 dark:text-gray-200;
@apply dark:border-gray-800;
}
</style>

View File

@@ -2,7 +2,6 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="block" :class="labelClasses" v-if="attrs.label"> <label class="block" :class="labelClasses" v-if="attrs.label">
{{ attrs.label }} {{ attrs.label }}
<span class="text-red-500" v-if="attrs.required">*</span>
</label> </label>
<Autocomplete <Autocomplete
ref="autocomplete" ref="autocomplete"
@@ -109,7 +108,6 @@ const options = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value], cache: [props.doctype, text.value],
method: 'POST', method: 'POST',
auto: true,
params: { params: {
txt: text.value, txt: text.value,
doctype: props.doctype, doctype: props.doctype,

View File

@@ -2,7 +2,6 @@
<div> <div>
<label class="block mb-1" :class="labelClasses" v-if="label"> <label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }} {{ label }}
<span class="text-red-500" v-if="required">*</span>
</label> </label>
<div class="grid grid-cols-3 gap-1"> <div class="grid grid-cols-3 gap-1">
<Button <Button
@@ -116,9 +115,6 @@ const props = defineProps({
type: Function, type: Function,
default: (value) => `${value} is an Invalid value`, default: (value) => `${value} is an Invalid value`,
}, },
required: {
type: Boolean,
},
}) })
const values = defineModel() const values = defineModel()
@@ -156,11 +152,24 @@ const filterOptions = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
method: 'POST', method: 'POST',
cache: [text.value, props.doctype], cache: [text.value, props.doctype],
auto: true,
params: { params: {
txt: text.value, txt: text.value,
doctype: props.doctype, doctype: props.doctype,
}, },
/* transform: (data) => {
let allData = data
.filter((c) => {
return c.description.split(', ')[1]
})
.map((option) => {
let email = option.description.split(', ')[1]
return {
label: option.label || email,
value: email,
}
})
return allData
}, */
}) })
const options = computed(() => { const options = computed(() => {

View File

@@ -1,27 +1,18 @@
<template> <template>
<div class="space-y-1">
<label class="block text-xs text-gray-600" v-if="props.label">
{{ props.label }}
</label>
<div class="flex text-center"> <div class="flex text-center">
<div <div v-for="index in 5">
v-for="index in 5"
@mouseover="hoveredRating = index"
@mouseleave="hoveredRating = 0"
>
<Star <Star
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer" :class="index <= rating ? 'fill-orange-500' : ''"
:class="iconClasses(index)" class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
@click="markRating(index)" @click="markRating(index)"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { ref, watch } from 'vue' import { ref } from 'vue'
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -32,36 +23,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
label: {
type: String,
default: '',
},
size: {
type: String,
default: 'md',
},
}) })
const iconClasses = (index) => {
let classes = [
{
sm: 'size-4',
md: 'size-5',
lg: 'size-6',
xl: 'size-7',
}[props.size],
]
if (index <= hoveredRating.value && index > rating.value) {
classes.push('fill-yellow-200')
} else if (index <= rating.value) {
classes.push('fill-yellow-500')
}
return classes.join(' ')
}
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const rating = ref(props.modelValue) let rating = ref(props.modelValue)
const hoveredRating = ref(0)
let emitChange = (value) => { let emitChange = (value) => {
emit('update:modelValue', value) emit('update:modelValue', value)
@@ -71,11 +36,4 @@ function markRating(index) {
emitChange(index) emitChange(index)
rating.value = index rating.value = index
} }
watch(
() => props.modelValue,
(newVal) => {
rating.value = newVal
}
)
</script> </script>

View File

@@ -10,13 +10,13 @@
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }" :style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
> >
<div <div
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit" class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
> >
<Badge v-if="course.featured" variant="subtle" theme="green" size="md"> <Badge v-if="course.featured" variant="subtle" theme="green" size="md">
{{ __('Featured') }} {{ __('Featured') }}
</Badge> </Badge>
<Badge <Badge
variant="subtle" variant="outline"
theme="gray" theme="gray"
size="md" size="md"
v-for="tag in course.tags" v-for="tag in course.tags"
@@ -30,29 +30,29 @@
</div> </div>
<div class="flex flex-col flex-auto p-4"> <div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div v-if="course.lessons"> <div v-if="course.lesson_count">
<Tooltip :text="__('Lessons')"> <Tooltip :text="__('Lessons')">
<span class="flex items-center"> <span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.lessons }} {{ course.lesson_count }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.enrollments"> <div v-if="course.enrollment_count">
<Tooltip :text="__('Enrolled Students')"> <Tooltip :text="__('Enrolled Students')">
<span class="flex items-center"> <span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.enrollments }} {{ course.enrollment_count }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.rating"> <div v-if="course.avg_rating">
<Tooltip :text="__('Average Rating')"> <Tooltip :text="__('Average Rating')">
<span class="flex items-center"> <span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.rating }} {{ course.avg_rating }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>

View File

@@ -93,19 +93,21 @@
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" /> <BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2"> <span class="ml-2">
{{ course.data.lessons }} {{ __('Lessons') }} {{ course.data.lesson_count }} {{ __('Lessons') }}
</span> </span>
</div> </div>
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<Users class="h-5 w-5 stroke-1.5 text-gray-600" /> <Users class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2"> <span class="ml-2">
{{ formatAmount(course.data.enrollments) }} {{ course.data.enrollment_count_formatted }}
{{ __('Enrolled Students') }} {{ __('Enrolled Students') }}
</span> </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" /> <Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span> <span class="ml-2">
{{ course.data.avg_rating }} {{ __('Rating') }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -114,8 +116,7 @@
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/' import { createToast } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
@@ -137,11 +138,11 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
showToast( createToast({
__('Please Login'), title: 'Please Login',
__('You need to login first to enroll for this course'), icon: 'alert-circle',
'circle-warn' iconClasses: 'text-yellow-600 bg-yellow-100',
) })
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000) }, 2000)
@@ -154,14 +155,11 @@ function enrollStudent() {
course: props.course.data.name, course: props.course.data.name,
}) })
.then(() => { .then(() => {
capture('enrolled_in_course', { createToast({
course: props.course.data.name, title: 'Enrolled Successfully',
icon: 'check',
iconClasses: 'text-green-600 bg-green-100',
}) })
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
setTimeout(() => { setTimeout(() => {
router.push({ router.push({
name: 'Lesson', name: 'Lesson',
@@ -171,7 +169,7 @@ function enrollStudent() {
lessonNumber: 1, lessonNumber: 1,
}, },
}) })
}, 2000) }, 3000)
}) })
} }
} }
@@ -204,6 +202,7 @@ const certificate = createResource({
} }
}, },
onSuccess(data) { onSuccess(data) {
console.log(data)
window.open( window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${ `/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name data.name

View File

@@ -4,7 +4,7 @@
v-if="title && (outline.data?.length || allowEdit)" v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2" class="grid grid-cols-[70%,30%] mb-4 px-2"
> >
<div class="font-semibold text-lg leading-5"> <div class="font-semibold text-lg">
{{ __(title) }} {{ __(title) }}
</div> </div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
@@ -76,7 +76,7 @@
<Trash2 <Trash2
v-if="allowEdit" v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)" @click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible" class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
/> />
<Check <Check
v-if="lesson.is_complete" v-if="lesson.is_complete"
@@ -119,7 +119,7 @@
</template> </template>
<script setup> <script setup>
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { ref, getCurrentInstance } from 'vue' import { ref } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
@@ -138,8 +138,6 @@ const route = useRoute()
const expandAll = ref(true) const expandAll = ref(true)
const showChapterModal = ref(false) const showChapterModal = ref(false)
const currentChapter = ref(null) const currentChapter = ref(null)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -204,24 +202,10 @@ const updateLessonIndex = createResource({
}) })
const trashLesson = (lessonName, chapterName) => { const trashLesson = (lessonName, chapterName) => {
$dialog({
title: __('Delete Lesson'),
message: __('Are you sure you want to delete this lesson?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteLesson.submit({ deleteLesson.submit({
lesson: lessonName, lesson: lessonName,
chapter: chapterName, chapter: chapterName,
}) })
close()
},
},
],
})
} }
const openChapterDetail = (index) => { const openChapterDetail = (index) => {

View File

@@ -76,7 +76,7 @@ const props = defineProps({
required: true, required: true,
}, },
avg_rating: { avg_rating: {
type: String, type: Number,
required: true, required: true,
}, },
membership: { membership: {

View File

@@ -37,7 +37,7 @@
<iframe <iframe
:src="getPDFSource(block)" :src="getPDFSource(block)"
width="100%" width="100%"
height="700px" height="400"
frameborder="0" frameborder="0"
allowfullscreen allowfullscreen
></iframe> ></iframe>

View File

@@ -1,89 +0,0 @@
<template>
<div class="space-y-5">
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('quiz')"
>
<span>
{{ __('How to add a Quiz?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __('How to upload content from your system?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __('How to add a YouTube Video?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
</div>
</div>
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
</div>
<ExplanationVideos v-model="showExplanation" :type="type" />
</template>
<script setup>
import { Info } from 'lucide-vue-next'
import { ref } from 'vue'
import ExplanationVideos from '@/components/Modals/ExplanationVideos.vue'
const showExplanation = ref(false)
const type = ref(null)
const openHelpDialog = (contentType) => {
type.value = contentType
showExplanation.value = true
}
</script>

View File

@@ -0,0 +1,174 @@
<template>
<div class="text-lg font-semibold">
{{ __('Components') }}
</div>
<div class="mt-5 space-y-4">
<Tooltip
:text="
__(
'Content such as quiz, video and image will be added in the editor you select.'
)
"
placement="bottom"
>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{ __('Select an Editor') }}
</div>
<Select v-model="currentEditor" :options="getEditorOptions()" />
</div>
</Tooltip>
<div class="flex">
<Link
:value="quiz"
class="flex-1"
doctype="LMS Quiz"
:label="__('Add an existing quiz')"
@change="(option) => addQuiz(option)"
/>
<router-link
:to="{
name: 'QuizCreation',
params: {
quizID: 'new',
},
}"
class="self-end ml-2"
>
<Button>
<template #icon>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</router-link>
</div>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{ __('Add an image, video, pdf or audio.') }}
</div>
<div class="flex">
<FileUploader
v-if="!file"
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload a File')
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xs">
{{ file.file_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{
__(
'To add a YouTube video, paste the URL of the video in the editor.'
)
}}
</div>
<YouTubeExplanation>
<template v-slot="{ togglePopover }">
<div
@click="togglePopover()"
class="flex items-center text-sm underline cursor-pointer"
>
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
{{ __('Learn More') }}
</div>
</template>
</YouTubeExplanation>
</div>
</div>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
import { Plus, FileText, Info } from 'lucide-vue-next'
import { ref, watch } from 'vue'
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
const quiz = ref(null)
const file = ref(null)
const lessonEditor = ref(null)
const instructorEditor = ref(null)
const currentEditor = ref('Lesson Content')
const props = defineProps({
editor: {
required: true,
},
notesEditor: {
required: true,
},
})
const addQuiz = (value) => {
getCurrentEditor().caret.setToLastBlock('end', 0)
if (value) {
getCurrentEditor().blocks.insert('quiz', {
quiz: value,
})
quiz.value = null
}
}
const addFile = (data) => {
getCurrentEditor().caret.setToLastBlock('end', 0)
getCurrentEditor().blocks.insert('upload', data)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const getEditorOptions = () => {
return [
{
label: 'Lesson Content',
value: 'Lesson Content',
},
{
label: 'Instructor Content',
value: 'Instructor Content',
},
]
}
const getCurrentEditor = () => {
return currentEditor.value == 'Lesson Content'
? lessonEditor.value
: instructorEditor.value
}
watch(
() => [props.editor, props.notesEditor],
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
lessonEditor.value = newEditor
instructorEditor.value = newNotesEditor
}
)
</script>

View File

@@ -1,30 +1,33 @@
<template> <template>
<div class="flex items-center justify-between mb-5"> <Button
<div class="text-lg font-semibold"> v-if="user.data.is_moderator"
{{ __('Live Class') }} variant="solid"
</div> class="float-right mb-5"
<Button v-if="user.data.is_moderator" @click="openLiveClassModal"> @click="openLiveClassModal"
>
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
<span> <span>
{{ __('Add') }} {{ __('Add Live Class') }}
</span> </span>
</Button> </Button>
<div class="text-lg font-semibold mb-5">
{{ __('Live Class') }}
</div> </div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5"> <div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div <div
v-for="cls in liveClasses.data" v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3" class="flex flex-col border rounded-md h-full p-3"
> >
<div class="font-semibold text-gray-900 text-lg mb-4"> <div class="font-semibold text-lg mb-4">
{{ cls.title }} {{ cls.title }}
</div> </div>
<div class="leading-5 text-gray-700 text-sm mb-4"> <div class="mb-4">
{{ cls.description }} {{ cls.description }}
</div> </div>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" /> <Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2"> <span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }} {{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span> </span>
@@ -35,9 +38,8 @@
{{ formatTime(cls.time) }} {{ formatTime(cls.time) }}
</span> </span>
</div> </div>
<div class="flex items-center space-x-2 text-gray-900 mt-auto"> <div class="flex items-center space-x-2 mt-auto">
<a <a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url" :href="cls.start_url"
target="_blank" target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded" class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
@@ -46,10 +48,9 @@
{{ __('Start') }} {{ __('Start') }}
</a> </a>
<a <a
v-if="cls.date <= dayjs().format('YYYY-MM-DD')"
:href="cls.join_url" :href="cls.join_url"
target="_blank" target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded" class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
> >
<Video class="h-4 w-4 stroke-1.5" /> <Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }} {{ __('Join') }}
@@ -89,6 +90,7 @@ const liveClasses = createListResource({
doctype: 'LMS Live Class', doctype: 'LMS Live Class',
filters: { filters: {
batch_name: props.batch, batch_name: props.batch,
date: ['>=', new Date()],
}, },
fields: [ fields: [
'title', 'title',

View File

@@ -1,13 +1,13 @@
<template> <template>
<div class="flex min-h-0 flex-col text-base"> <div class="text-base p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-xl font-semibold mb-1"> <div class="font-semibold mb-1">
{{ __(label) }} {{ __(label) }}
</div> </div>
<!-- <div class="text-xs text-gray-600"> <div class="text-xs text-gray-600">
{{ __(description) }} {{ __(description) }}
</div> --> </div>
</div> </div>
<div class="flex item-center space-x-2"> <div class="flex item-center space-x-2">
<FormControl <FormControl
@@ -16,17 +16,16 @@
type="text" type="text"
:debounce="300" :debounce="300"
/> />
<Button @click="() => (showForm = !showForm)"> <Button @click="() => (showForm = true)">
<template #icon> <template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" /> <Plus class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template> </template>
</Button> </Button>
</div> </div>
</div> </div>
<div class="my-4">
<!-- Form to add new member --> <!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4"> <div v-if="showForm" class="flex items-center space-x-2 mb-4">
<FormControl <FormControl
v-model="member.email" v-model="member.email"
:placeholder="__('Email')" :placeholder="__('Email')"
@@ -44,51 +43,34 @@
</Button> </Button>
</div> </div>
<div class="mt-2 pb-10 overflow-auto">
<!-- Member list --> <!-- Member list -->
<div class="overflow-y-scroll"> <div
<ul class="divide-y">
<li
v-for="member in memberList" v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer" class="grid grid-cols-5 grid-flow-row py-2 cursor-pointer"
> >
<div <div
@click="openProfile(member.username)" @click="openProfile(member.username)"
class="flex items-center space-x-3 col-span-2" class="flex items-center space-x-2 col-span-2"
> >
<Avatar <Avatar
:image="member.user_image" :image="member.user_image"
:label="member.full_name" :label="member.full_name"
size="lg" size="sm"
/> />
<div class="space-y-1"> <div>
<div class="flex">
<div class="text-gray-900">
{{ member.full_name }} {{ member.full_name }}
</div> </div>
<div v-if="getRole(member)">
{{ getRole(member) }}
</div> </div>
</div> <div class="text-sm text-gray-700 col-span-2">
<div class="text-sm text-gray-700">
{{ member.name }} {{ member.name }}
</div> </div>
<div class="text-sm text-gray-700 justify-self-end">
{{ getRole(member.role) }}
</div> </div>
</div> </div>
<div class="flex items-center justify-center text-gray-700 text-sm">
<div v-if="member.last_active">
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
</div> </div>
<div v-else>-</div> <div v-if="hasNextPage" class="flex justify-center">
</div> <Button variant="solid" @click="members.reload()">
</li>
</ul>
</div>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix> <template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" /> <RefreshCw class="h-3 w-3 stroke-1.5" />
</template> </template>
@@ -96,13 +78,12 @@
</Button> </Button>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { createResource, Avatar, Button, FormControl } from 'frappe-ui' import { createResource, Avatar, Button, FormControl } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue' import { ref, watch, reactive } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next' import { RefreshCw, Plus } from 'lucide-vue-next'
const router = useRouter() const router = useRouter()
const show = defineModel('show') const show = defineModel('show')
@@ -111,7 +92,6 @@ const start = ref(0)
const memberList = ref([]) const memberList = ref([])
const hasNextPage = ref(false) const hasNextPage = ref(false)
const showForm = ref(false) const showForm = ref(false)
const dayjs = inject('$dayjs')
const member = reactive({ const member = reactive({
email: '', email: '',

View File

@@ -5,11 +5,9 @@
</div> </div>
<div <div
v-if="sidebarSettings.data" v-if="sidebarSettings.data"
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4" class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
:style="{ :style="{
gridTemplateColumns: `repeat(${ gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}" }"
> >
<button <button
@@ -25,46 +23,15 @@
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']" :class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/> />
</button> </button>
<Popover
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
>
<template #target>
<component
:is="icons['List']"
class="h-6 w-6 stroke-1.5 text-gray-600"
/>
</template>
<template #body-main>
<div class="text-base p-5 space-y-4">
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-gray-600"
/>
<div>
{{ link.label }}
</div>
</div>
</div>
</template>
</Popover>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next' import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore() const { logout, user, sidebarSettings } = sessionStore()
@@ -72,7 +39,6 @@ let { isLoggedIn } = sessionStore()
const router = useRouter() const router = useRouter()
let { userResource } = usersStore() let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks()) const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
onMounted(() => { onMounted(() => {
sidebarSettings.reload( sidebarSettings.reload(
@@ -86,53 +52,37 @@ onMounted(() => {
) )
} }
}) })
addAccessLinks()
addOtherLinks()
}, },
} }
) )
}) })
const addOtherLinks = () => { const addAccessLinks = () => {
if (user) { if (user) {
otherLinks.value.push({ sidebarLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile', label: 'Profile',
icon: 'UserRound', icon: 'UserRound',
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
}) })
otherLinks.value.push({ sidebarLinks.value.push({
label: 'Log out', label: 'Log out',
icon: 'LogOut', icon: 'LogOut',
}) })
} else { } else {
otherLinks.value.push({ sidebarLinks.value.push({
label: 'Log in', label: 'Log in',
icon: 'LogIn', icon: 'LogIn',
}) })
} }
} }
watch(userResource, () => {
if (
userResource.data &&
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
}
})
const addQuizzes = () => {
otherLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
})
}
let isActive = (tab) => { let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name) return tab.activeFor?.includes(router.currentRoute.value.name)
} }

View File

@@ -18,7 +18,6 @@
<div class=""> <div class="">
<div class="mb-1.5 text-sm text-gray-600"> <div class="mb-1.5 text-sm text-gray-600">
{{ __('Subject') }} {{ __('Subject') }}
<span class="text-red-500">*</span>
</div> </div>
<Input type="text" v-model="announcement.subject" /> <Input type="text" v-model="announcement.subject" />
</div> </div>
@@ -45,7 +44,7 @@
<script setup> <script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui' import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { showToast } from '@/utils/' import { createToast } from '@/utils/'
const show = defineModel() const show = defineModel()
@@ -95,14 +94,22 @@ const makeAnnouncement = (close) => {
}, },
onSuccess() { onSuccess() {
close() close()
showToast( createToast({
__('Success'), title: 'Success',
__('Announcement has been sent successfully'), text: 'Announcement has been sent successfully',
'check' icon: 'Check',
) iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'check') createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}, },
} }
) )

View File

@@ -1,86 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add an assessment'),
size: 'sm',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => addAssessment(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="select"
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
/>
<Link
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assessmentType = ref(null)
const assessment = ref(null)
const assessments = defineModel('assessments')
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const assessmentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assessment',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'assessment',
assessment_type: assessmentType.value,
assessment_name: assessment.value,
},
}
},
})
const addAssessment = (close) => {
assessmentResource.submit(
{},
{
onSuccess(data) {
assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check')
close()
},
}
)
}
const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
]
})
</script>

View File

@@ -14,12 +14,7 @@
}" }"
> >
<template #body-content> <template #body-content>
<Link <Link doctype="LMS Course" v-model="course" :label="__('Course')" />
doctype="LMS Course"
v-model="course"
:label="__('Course')"
:required="true"
/>
<Link <Link
doctype="Course Evaluator" doctype="Course Evaluator"
v-model="evaluator" v-model="evaluator"

View File

@@ -2,11 +2,11 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'), title: __('Add Chapter'),
size: 'lg', size: 'lg',
actions: [ actions: [
{ {
label: chapterDetail ? __('Edit') : __('Create'), label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
variant: 'solid', variant: 'solid',
onClick: (close) => onClick: (close) =>
chapterDetail ? editChapter(close) : addChapter(close), chapterDetail ? editChapter(close) : addChapter(close),
@@ -15,25 +15,18 @@
}" }"
> >
<template #body-content> <template #body-content>
<FormControl <FormControl label="Title" v-model="chapter.title" class="mb-4" />
ref="chapterInput"
label="Title"
v-model="chapter.title"
class="mb-4"
:required="true"
/>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui' import { Dialog, FormControl, createResource } from 'frappe-ui'
import { defineModel, reactive, watch, ref } from 'vue' import { defineModel, reactive, watch } from 'vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
const show = defineModel() const show = defineModel()
const outline = defineModel('outline') const outline = defineModel('outline')
const chapterInput = ref(null)
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -44,7 +37,6 @@ const props = defineProps({
type: Object, type: Object,
}, },
}) })
const chapter = reactive({ const chapter = reactive({
title: '', title: '',
}) })
@@ -105,7 +97,6 @@ const addChapter = (close) => {
{ name: data.name }, { name: data.name },
{ {
onSuccess(data) { onSuccess(data) {
chapter.title = ''
outline.value.reload() outline.value.reload()
createToast({ createToast({
text: 'Chapter added successfully', text: 'Chapter added successfully',
@@ -169,12 +160,4 @@ watch(
chapter.title = newChapter?.title chapter.title = newChapter?.title
} }
) )
watch(show, () => {
if (show.value) {
setTimeout(() => {
chapterInput.value.$el.querySelector('input').focus()
}, 100)
}
})
</script> </script>

View File

@@ -69,18 +69,7 @@
:label="__('Headline')" :label="__('Headline')"
class="mb-4" class="mb-4"
/> />
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Bio') }}
</div>
<TextEditor
:fixedMenu="true"
@change="(val) => (profile.bio = val)"
:content="profile.bio"
editorClass="prose-sm py-2 px-2 min-h-[200px] border-gray-300 hover:border-gray-400 rounded-md bg-gray-200"
/>
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
@@ -92,7 +81,6 @@ import {
FileUploader, FileUploader,
Button, Button,
createResource, createResource,
TextEditor,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, watch, defineModel } from 'vue' import { reactive, watch, defineModel } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'

View File

@@ -131,16 +131,10 @@ function submitEvaluation(close) {
}, },
onError(err) { onError(err) {
let message = err.messages?.[0] || err let message = err.messages?.[0] || err
let unavailabilityMessage let unavailabilityMessage = message.includes('unavailable')
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
createToast({ createToast({
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '', title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
text: message, text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x', icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-white rounded-md p-px', iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
@@ -154,13 +148,11 @@ function submitEvaluation(close) {
const getCourses = () => { const getCourses = () => {
let courses = [] let courses = []
for (const course of props.courses) { for (const course of props.courses) {
if (course.evaluator) {
courses.push({ courses.push({
label: course.title, label: course.title,
value: course.course, value: course.course,
}) })
} }
}
return courses return courses
} }

View File

@@ -1,378 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: '2xl',
}"
>
<template #body>
<div class="flex text-base">
<div class="flex flex-col w-1/2 p-5">
<div class="text-lg font-semibold mb-4">
{{ event.title }}
</div>
<div class="flex flex-col space-y-4 text-sm text-gray-800">
<Tooltip :text="__('Email ID')">
<div class="flex items-center space-x-2 w-fit">
<User class="h-4 w-4 stroke-1.5" />
<span>
{{ event.member }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Course')">
<div class="flex items-center space-x-2 w-fit">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span>
{{ event.course_title }}
</span>
</div>
</Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')">
<div class="flex items-center space-x-2 w-fit">
<Users class="h-4 w-4 stroke-1.5" />
<span>
{{ event.batch_title }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Date')">
<div class="flex items-center space-x-2 w-fit">
<Calendar class="h-4 w-4 stroke-1.5" />
<span>
{{ dayjs(event.date).format('DD MMM YYYY') }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Time')">
<div class="flex items-center space-x-2 w-fit">
<Clock class="h-4 w-4 stroke-1.5" />
<span>
{{ formatTime(event.start_time) }} -
{{ formatTime(event.end_time) }}
</span>
</div>
</Tooltip>
</div>
<div class="flex items-center space-x-2 mt-auto">
<Button
v-if="certificate.name"
@click="openCertificate(certificate)"
class="w-full"
>
<template #prefix>
<FileText class="h-4 w-4 stroke-1.5" />
</template>
{{ __('View Certificate') }}
</Button>
<Button v-else @click="openCallLink(event.venue)" class="w-full">
<template #prefix>
<Video class="h-4 w-4 stroke-1.5" />
</template>
<span>
{{ __('Join Meeting') }}
</span>
</Button>
</div>
</div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5"
>
<div class="flex items-center justify-between">
<Rating v-model="evaluation.rating" :label="__('Rating')" />
<FormControl
type="select"
:options="statusOptions"
v-model="evaluation.status"
:label="__('Status')"
class="w-1/2"
/>
</div>
<Textarea
v-model="evaluation.summary"
:label="__('Summary')"
:rows="7"
/>
<Button variant="solid" @click="saveEvaluation()">
{{ __('Save') }}
</Button>
</div>
<div v-else class="flex flex-col space-y-4 p-5">
<FormControl
type="checkbox"
v-model="certificate.published"
:label="__('Published')"
/>
<Link
v-model="certificate.template"
:label="__('Template')"
doctype="Print Format"
:filters="{
doc_type: 'LMS Certificate',
}"
/>
<FormControl
type="date"
v-model="certificate.issue_date"
:label="__('Issue Date')"
/>
<FormControl
type="date"
v-model="certificate.expiry_date"
:label="__('Expiry Date')"
/>
<Button variant="solid" @click="saveCertificate()">
{{ __('Save') }}
</Button>
</div>
</template>
</Tabs>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Dialog,
Button,
FormControl,
createResource,
Tabs,
Tooltip,
Textarea,
} from 'frappe-ui'
import {
User,
Calendar,
Clock,
Video,
BookOpen,
FileText,
GraduationCap,
Users,
ClipboardList,
} from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils'
import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const dayjs = inject('$dayjs')
const tabIndex = ref(0)
const showCertification = ref(false)
const props = defineProps({
event: {
type: [Object, null],
required: true,
},
})
const evaluation = reactive({})
const certificate = reactive({})
const defaultTemplate = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'Property Setter',
fieldname: 'value',
filters: {
doc_type: 'LMS Certificate',
property: 'default_print_format',
},
}
},
auto: true,
onSuccess(data) {
certificate.template = data.value
},
})
const openCallLink = (link) => {
window.open(link, '_blank')
}
const evaluationResource = createResource({
url: 'lms.lms.api.save_evaluation_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,
rating: evaluation.rating,
summary: evaluation.summary,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
evaluation.name = data.name
},
})
const evaluationDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate Evaluation',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in evaluation) evaluation[key] = data[key]
if (key == 'rating') evaluation.rating = data.rating * 5
if (evaluation.status == 'Pass') showCertification.value = true
}
},
auto: false,
})
const saveEvaluation = () => {
evaluationResource.submit(
{},
{
onSuccess: () => {
if (evaluation.status == 'Pass') {
showCertification.value = true
} else {
show.value = false
}
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
},
}
)
}
const certificateResource = createResource({
url: 'lms.lms.api.save_certificate_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
published: certificate.published,
issue_date: certificate.issue_date,
expiry_date: certificate.expiry_date,
template: certificate.template,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
certificate.name = data
},
})
const certificateDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in certificate) certificate[key] = data[key]
certificate.name = data.name
showCertification.value = true
}
},
onError(err) {
certificate.template = defaultTemplate.data.value
},
auto: false,
})
const saveCertificate = () => {
certificateResource.submit(
{},
{
onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check')
},
}
)
}
watch(show, () => {
if (show.value) {
evaluation.rating = 0
evaluation.status = 'Pending'
evaluation.summary = ''
evaluationDetails.reload()
certificate.published = true
certificate.issue_date = dayjs().format('YYYY-MM-DD')
certificate.expiry_date = null
certificate.template = null
certificate.name = null
certificateDetails.reload()
}
})
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`
)
}
const statusOptions = computed(() => {
return [
{
value: 'Pending',
label: __('Pending'),
},
{
value: 'In Progress',
label: __('In Progress'),
},
{
value: 'Pass',
label: __('Pass'),
},
{
value: 'Fail',
label: __('Fail'),
},
]
})
const tabs = computed(() => {
const tabsArray = [
{
label: __('Evaluation'),
icon: ClipboardList,
},
]
if (showCertification.value) {
tabsArray.push({
label: __('Certification'),
icon: GraduationCap,
})
}
return tabsArray
})
</script>

View File

@@ -1,34 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-4">
<VideoBlock :file="file" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog } from 'frappe-ui'
import { computed } from 'vue'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel()
const props = defineProps({
type: {
type: [String, null],
required: true,
},
})
const file = computed(() => {
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
})
</script>

View File

@@ -22,7 +22,6 @@
v-model="liveClass.title" v-model="liveClass.title"
:label="__('Title')" :label="__('Title')"
class="mb-4" class="mb-4"
:required="true"
/> />
<Tooltip <Tooltip
:text=" :text="
@@ -36,7 +35,6 @@
type="time" type="time"
:label="__('Time')" :label="__('Time')"
class="mb-4" class="mb-4"
:required="true"
/> />
</Tooltip> </Tooltip>
<FormControl <FormControl
@@ -44,7 +42,6 @@
type="select" type="select"
:options="getTimezoneOptions()" :options="getTimezoneOptions()"
:label="__('Timezone')" :label="__('Timezone')"
:required="true"
/> />
</div> </div>
<div> <div>
@@ -53,7 +50,6 @@
type="date" type="date"
class="mb-4" class="mb-4"
:label="__('Date')" :label="__('Date')"
:required="true"
/> />
<Tooltip :text="__('Duration of the live class in minutes')"> <Tooltip :text="__('Duration of the live class in minutes')">
<FormControl <FormControl
@@ -61,7 +57,6 @@
v-model="liveClass.duration" v-model="liveClass.duration"
:label="__('Duration')" :label="__('Duration')"
class="mb-4" class="mb-4"
:required="true"
/> />
</Tooltip> </Tooltip>
<FormControl <FormControl

View File

@@ -54,7 +54,7 @@
:label="__('Type')" :label="__('Type')"
v-model="question.type" v-model="question.type"
type="select" type="select"
:options="['Choices', 'User Input', 'Open Ended']" :options="['Choices', 'User Input']"
class="pb-2" class="pb-2"
/> />
<div v-if="question.type == 'Choices'" class="divide-y border-t"> <div v-if="question.type == 'Choices'" class="divide-y border-t">
@@ -74,11 +74,7 @@
/> />
</div> </div>
</div> </div>
<div <div v-else v-for="n in 4" class="space-y-2">
v-else-if="question.type == 'User Input'"
v-for="n in 4"
class="space-y-2"
>
<FormControl <FormControl
:label="__('Possibility') + ' ' + n" :label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]" v-model="question[`possibility_${n}`]"
@@ -216,7 +212,7 @@ const questionCreation = createResource({
}) })
const submitQuestion = (close) => { const submitQuestion = (close) => {
if (props.questionDetail?.question) updateQuestion(close) if (questionData.data?.name) updateQuestion(close)
else addQuestion(close) else addQuestion(close)
} }
@@ -243,7 +239,7 @@ const addQuestion = (close) => {
) )
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') showToast(__('Error'), __(err.message?.[0] || err), 'x')
}, },
} }
) )
@@ -263,7 +259,7 @@ const addQuestionRow = (question, close) => {
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') showToast(__('Error'), __(err.message?.[0] || err), 'x')
close() close()
}, },
} }
@@ -316,12 +312,13 @@ const updateQuestion = (close) => {
quiz.value.reload() quiz.value.reload()
close() close()
}, },
onError(err) {
showToast(__('Error'), __(err.message?.[0] || err), 'x')
close()
},
} }
) )
}, },
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
} }
) )
} }

View File

@@ -1,12 +1,12 @@
<template> <template>
<Dialog v-model="show" :options="{ size: '4xl' }"> <Dialog v-model="show" :options="{ size: '3xl' }">
<template #body> <template #body>
<div class="flex h-[calc(100vh_-_8rem)]"> <div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2"> <div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold"> <h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
{{ __('Settings') }} {{ __('Settings') }}
</h1> </h1>
<div v-for="tab in tabs" :key="tab.label"> <div v-for="tab in tabs">
<div <div
v-if="!tab.hideLabel" v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out" class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
@@ -17,7 +17,6 @@
<SidebarLink <SidebarLink
v-for="item in tab.items" v-for="item in tab.items"
:link="item" :link="item"
:key="item.label"
class="w-full" class="w-full"
:class=" :class="
activeTab?.label == item.label activeTab?.label == item.label
@@ -31,8 +30,7 @@
</div> </div>
<div <div
v-if="activeTab && data.doc" v-if="activeTab && data.doc"
:key="activeTab.label" class="flex flex-1 flex-col overflow-y-auto"
class="flex flex-1 flex-col px-10 py-8"
> >
<Members <Members
v-if="activeTab.label === 'Members'" v-if="activeTab.label === 'Members'"
@@ -40,25 +38,6 @@
:description="activeTab.description" :description="activeTab.description"
v-model:show="show" v-model:show="show"
/> />
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails <SettingDetails
v-else v-else
:fields="activeTab.fields" :fields="activeTab.fields"
@@ -72,20 +51,15 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createDocumentResource, createResource } from 'frappe-ui' import { Dialog, createDocumentResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue' import SettingDetails from '../SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue' import Members from '@/components/Members.vue'
import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
const show = defineModel() const show = defineModel()
const doctype = ref('LMS Settings') const doctype = ref('LMS Settings')
const activeTab = ref(null) const activeTab = ref(null)
const settingsStore = useSettings()
const data = createDocumentResource({ const data = createDocumentResource({
doctype: doctype.value, doctype: doctype.value,
@@ -95,14 +69,8 @@ const data = createDocumentResource({
auto: true, auto: true,
}) })
const branding = createResource({ const tabs = computed(() => {
url: 'lms.lms.api.get_branding', let _tabs = [
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{ {
label: 'Settings', label: 'Settings',
hideLabel: true, hideLabel: true,
@@ -112,12 +80,6 @@ const tabsStructure = computed(() => {
description: 'Manage the members of your learning system', description: 'Manage the members of your learning system',
icon: 'UserRoundPlus', icon: 'UserRoundPlus',
}, },
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{ {
label: 'Payment Gateway', label: 'Payment Gateway',
icon: 'DollarSign', icon: 'DollarSign',
@@ -125,10 +87,14 @@ const tabsStructure = computed(() => {
'Configure the payment gateway and other payment related settings', 'Configure the payment gateway and other payment related settings',
fields: [ fields: [
{ {
label: 'Payment Gateway', label: 'Razorpay Key',
name: 'payment_gateway', name: 'razorpay_key',
type: 'Link', type: 'text',
doctype: 'Payment Gateway', },
{
label: 'Razorpay Secret',
name: 'razorpay_secret',
type: 'password',
}, },
{ {
label: 'Default Currency', label: 'Default Currency',
@@ -136,6 +102,9 @@ const tabsStructure = computed(() => {
type: 'Link', type: 'Link',
doctype: 'Currency', doctype: 'Currency',
}, },
{
type: 'Column Break',
},
{ {
label: 'Apply GST for India', label: 'Apply GST for India',
name: 'apply_gst', name: 'apply_gst',
@@ -159,64 +128,10 @@ const tabsStructure = computed(() => {
label: 'Settings', label: 'Settings',
hideLabel: true, hideLabel: true,
items: [ items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{
label: 'Customise',
hideLabel: false,
items: [
{
label: 'Branding',
icon: 'Blocks',
fields: [
{
label: 'Brand Name',
name: 'app_name',
type: 'text',
},
{
label: 'Logo',
name: 'banner_image',
type: 'Upload',
},
{
label: 'Favicon',
name: 'favicon',
type: 'Upload',
},
{
label: 'Footer Logo',
name: 'footer_logo',
type: 'Upload',
},
{
label: 'Address',
name: 'address',
type: 'textarea',
rows: 2,
},
{
label: 'Footer "Powered By"',
name: 'footer_powered',
type: 'textarea',
rows: 4,
},
{
label: 'Copyright',
name: 'copyright',
type: 'text',
},
],
},
{ {
label: 'Sidebar', label: 'Sidebar',
icon: 'PanelLeftIcon', icon: 'PanelLeftIcon',
description: 'Choose the items you want to show in the sidebar', description: 'Customize the sidebar as per your needs',
fields: [ fields: [
{ {
label: 'Courses', label: 'Courses',
@@ -253,9 +168,16 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{ {
label: 'Email Templates', label: 'Email Templates',
icon: 'MailPlus', icon: 'MailPlus',
description: 'Create email templates with the content you want',
fields: [ fields: [
{ {
label: 'Batch Confirmation Template', label: 'Batch Confirmation Template',
@@ -277,51 +199,81 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{ {
label: 'Signup', label: 'Signup',
icon: 'LogIn', icon: 'LogIn',
description:
'Customize the signup page to inform users about your terms and policies',
fields: [ fields: [
{ {
label: 'Custom Content', label: 'Show terms of use on signup',
name: 'custom_signup_content', name: 'terms_of_use',
type: 'Code', type: 'checkbox',
mode: 'htmlmixed',
rows: 10,
}, },
{ {
label: 'Ask for Occupation', label: 'Terms of Use Page',
name: 'terms_page',
type: 'Link',
doctype: 'Web Page',
},
{
label: 'Show privacy policy on signup',
name: 'privacy_policy',
type: 'checkbox',
},
{
label: 'Privacy Policy Page',
name: 'privacy_policy_page',
type: 'Link',
doctype: 'Web Page',
},
{
type: 'Column Break',
},
{
label: 'Show cookie policy on signup',
name: 'cookie_policy',
type: 'checkbox',
},
{
label: 'Cookie Policy Page',
name: 'cookie_policy_page',
type: 'Link',
doctype: 'Web Page',
},
{
label: 'Ask user category during signup',
name: 'user_category', name: 'user_category',
type: 'checkbox', type: 'checkbox',
description:
'Enable this option to ask users to select their occupation during the signup process.',
}, },
], ],
}, },
], ],
}, },
] ]
})
const tabs = computed(() => { return _tabs.map((tab) => {
return tabsStructure.value.map((tab) => { tab.items = tab.items.filter((item) => {
return { if (item.condition) {
...tab, return item.condition()
items: tab.items.filter((item) => {
return !item.condition || item.condition()
}),
} }
return true
})
return tab
}) })
}) })
watch(show, async () => { watch(show, () => {
if (show.value) { if (show.value) {
const currentTab = await tabs.value activeTab.value = tabs.value[0].items[0]
.flatMap((tab) => tab.items)
.find((item) => item.label === settingsStore.activeTab)
activeTab.value = currentTab || tabs.value[0].items[0]
} else { } else {
activeTab.value = null activeTab.value = null
settingsStore.isSettingsOpen = false
} }
}) })
</script> </script>

View File

@@ -0,0 +1,31 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-0 mt-3 w-[35rem] max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<video
controls
autoplay
muted
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
class="rounded-sm"
>
<source src="/Youtube.mov" type="video/mp4" />
</video>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover } from 'frappe-ui'
</script>

View File

@@ -24,7 +24,7 @@
<div> <div>
{{ __('Please login to access this page.') }} {{ __('Please login to access this page.') }}
</div> </div>
<Button @click="redirectToLogin()" class="mt-4"> <Button variant="solid" @click="redirectToLogin()" class="mt-2">
{{ __('Login') }} {{ __('Login') }}
</Button> </Button>
</div> </div>

View File

@@ -1,109 +0,0 @@
<template>
<div class="flex flex-col h-full">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<!-- <Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/> -->
</div>
<div class="overflow-y-scroll">
<div class="flex space-x-4">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
<SettingFields
v-if="paymentGateway.data"
:fields="paymentGateway.data.fields"
:data="paymentGateway.data.data"
class="w-1/2"
/>
</div>
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import SettingFields from '@/components/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
})
const paymentGateway = createResource({
url: 'lms.lms.api.get_payment_gateway_details',
makeParams(values) {
return {
payment_gateway: props.data.doc.payment_gateway,
}
},
auto: true,
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
let fields = {}
Object.keys(paymentGateway.data.data).forEach((key) => {
if (
paymentGateway.data.data[key] &&
typeof paymentGateway.data.data[key] === 'object'
) {
fields[key] = paymentGateway.data.data[key].file_url
} else {
fields[key] = paymentGateway.data.data[key]
}
})
return {
doctype: paymentGateway.data.doctype,
name: paymentGateway.data.docname,
fieldname: fields,
}
},
auto: false,
onSuccess(data) {
paymentGateway.reload()
},
})
const update = () => {
props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit()
saveSettings.submit()
}
watch(
() => props.data.doc.payment_gateway,
() => {
paymentGateway.reload()
}
)
</script>

View File

@@ -1,27 +1,11 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800" <div class="leading-relaxed">
>
<div class="leading-5">
{{ {{
__('This quiz consists of {0} questions.').format(questions.length) __('This quiz consists of {0} questions.').format(questions.length)
}} }}
</div> </div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed"> <div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{ {{
__( __(
@@ -38,16 +22,14 @@
) )
}} }}
</div> </div>
<div v-if="quiz.data.time" class="leading-relaxed">
{{
__(
'The quiz has a time limit. For each question you will be given {0} seconds.'
).format(quiz.data.time)
}}
</div> </div>
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
<ProgressBar :progress="timerProgress" />
<span class="font-semibold">
{{ formatTimer(timer) }}
</span>
</div> </div>
<div v-if="activeQuestion == 0"> <div v-if="activeQuestion == 0">
<div class="border text-center p-20 rounded-md"> <div class="border text-center p-20 rounded-md">
<div class="font-semibold text-lg"> <div class="font-semibold text-lg">
@@ -81,12 +63,19 @@
class="border rounded-md p-5" class="border rounded-md p-5"
> >
<div class="flex justify-between"> <div class="flex justify-between">
<div class="text-sm text-gray-600"> <div class="text-sm">
<span class="mr-2"> <span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}: {{ __('Question {0}').format(activeQuestion) }}:
</span> </span>
<span> <span v-if="questionDetails.data.type == 'User Input'">
{{ getInstructions(questionDetails.data) }} {{ __('Type your answer') }}
</span>
<span v-else>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
</span> </span>
</div> </div>
<div class="text-gray-900 text-sm font-semibold item-left"> <div class="text-gray-900 text-sm font-semibold item-left">
@@ -95,7 +84,7 @@
</div> </div>
</div> </div>
<div <div
class="text-gray-900 font-semibold mt-2 leading-5" class="text-gray-900 font-semibold mt-2"
v-html="questionDetails.data.question" v-html="questionDetails.data.question"
></div> ></div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4"> <div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
@@ -150,7 +139,7 @@
{{ questionDetails.data[`explanation_${index}`] }} {{ questionDetails.data[`explanation_${index}`] }}
</div> </div>
</div> </div>
<div v-else-if="questionDetails.data.type == 'User Input'"> <div v-else>
<FormControl <FormControl
v-model="possibleAnswer" v-model="possibleAnswer"
type="textarea" type="textarea"
@@ -170,18 +159,8 @@
</Badge> </Badge>
</div> </div>
</div> </div>
<div v-else> <div class="flex items-center justify-between mt-5">
<TextEditor <div>
class="mt-4"
:content="possibleAnswer"
@change="(val) => (possibleAnswer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600">
{{ {{
__('Question {0} of {1}').format( __('Question {0} of {1}').format(
activeQuestion, activeQuestion,
@@ -190,11 +169,7 @@
}} }}
</div> </div>
<Button <Button
v-if=" v-if="quiz.data.show_answers && !showAnswers.length"
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()" @click="checkAnswer()"
> >
<span> <span>
@@ -218,18 +193,11 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else class="border rounded-md p-20 text-center space-y-4"> <div v-else class="border rounded-md p-20 text-center">
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
{{ __('Quiz Summary') }} {{ __('Quiz Summary') }}
</div> </div>
<div v-if="quizSubmission.data.is_open_ended"> <div>
{{
__(
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
)
}}
</div>
<div v-else>
{{ {{
__( __(
'You got {0}% correct answers with a score of {1} out of {2}' 'You got {0}% correct answers with a score of {1} out of {2}'
@@ -268,29 +236,20 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { import { Badge, Button, createResource, ListView } from 'frappe-ui'
Badge, import { ref, watch, reactive, inject } from 'vue'
Button,
createResource,
ListView,
TextEditor,
FormControl,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue' import FormControl from 'frappe-ui/src/components/FormControl.vue'
const user = inject('$user') const user = inject('$user')
const activeQuestion = ref(0) const activeQuestion = ref(0)
const currentQuestion = ref('') const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0]) const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([]) const showAnswers = reactive([])
let questions = reactive([]) let questions = reactive([])
const possibleAnswer = ref(null) const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const props = defineProps({ const props = defineProps({
quizName: { quizName: {
@@ -311,7 +270,6 @@ const quiz = createResource({
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
populateQuestions() populateQuestions()
setupTimer()
}, },
}) })
@@ -327,37 +285,6 @@ const populateQuestions = () => {
} }
} }
const setupTimer = () => {
if (quiz.data.duration) {
timer.value = quiz.data.duration * 60
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value--
if (timer.value == 0) {
clearInterval(timerInterval)
submitQuiz()
}
}, 1000)
}
const formatTimer = (seconds) => {
const hrs = Math.floor(seconds / 3600)
.toString()
.padStart(2, '0')
const mins = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, '0')
const secs = (seconds % 60).toString().padStart(2, '0')
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
}
const timerProgress = computed(() => {
return (timer.value / (quiz.data.duration * 60)) * 100
})
const shuffleArray = (array) => { const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) { for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)) const j = Math.floor(Math.random() * (i + 1))
@@ -442,7 +369,6 @@ watch(
const startQuiz = () => { const startQuiz = () => {
activeQuestion.value = 1 activeQuestion.value = 1
localStorage.removeItem(quiz.data.title) localStorage.removeItem(quiz.data.title)
if (quiz.data.duration) startTimer()
} }
const markAnswer = (index) => { const markAnswer = (index) => {
@@ -524,10 +450,9 @@ const addToLocalStorage = () => {
} }
const nextQuetion = () => { const nextQuetion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') { if (!quiz.data.show_answers) {
checkAnswer() checkAnswer()
} else { } else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion() resetQuestion()
} }
} }
@@ -542,8 +467,7 @@ const resetQuestion = () => {
const submitQuiz = () => { const submitQuiz = () => {
if (!quiz.data.show_answers) { if (!quiz.data.show_answers) {
if (questionDetails.data.type == 'Open Ended') addToLocalStorage() checkAnswer()
else checkAnswer()
setTimeout(() => { setTimeout(() => {
createSubmission() createSubmission()
}, 500) }, 500)
@@ -553,15 +477,9 @@ const submitQuiz = () => {
} }
const createSubmission = () => { const createSubmission = () => {
quizSubmission.submit( quizSubmission.reload().then(() => {
{},
{
onSuccess(data) {
if (quiz.data && quiz.data.max_attempts) attempts.reload() if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval) })
},
}
)
} }
const resetQuiz = () => { const resetQuiz = () => {
@@ -570,14 +488,6 @@ const resetQuiz = () => {
showAnswers.length = 0 showAnswers.length = 0
quizSubmission.reset() quizSubmission.reset()
populateQuestions() populateQuestions()
setupTimer()
}
const getInstructions = (question) => {
if (question.type == 'Choices')
if (question.multiple) return __('Choose all answers that apply')
else return __('Choose one answer')
else return __('Type your answer')
} }
const getSubmissionColumns = () => { const getSubmissionColumns = () => {

View File

@@ -1,58 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div>
<Link
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToQuizForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addQuiz()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const props = defineProps({
onQuizAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addQuiz = () => {
props.onQuizAddition(quiz.value)
show.value = false
}
const redirectToQuizForm = () => {
window.open('/lms/quizzes/new', '_blank')
}
</script>

View File

@@ -1,23 +1,34 @@
<template> <template>
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between h-full p-4">
<div> <div>
<div class="flex itemsc-center justify-between"> <div class="font-semibold mb-1">
<div class="text-xl font-semibold leading-none mb-1">
{{ __(label) }} {{ __(label) }}
</div> </div>
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<div class="text-xs text-gray-600"> <div class="text-xs text-gray-600">
{{ __(description) }} {{ __(description) }}
</div> </div>
</div> </div>
<div class="flex space-x-8 my-5">
<SettingFields :fields="fields" :data="data.doc" /> <div v-for="(column, index) in columns" :key="index">
<div class="flex flex-col space-y-4 w-60">
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="field.value"
:doctype="field.doctype"
:label="field.label"
/>
<FormControl
v-else
:key="field.name"
v-model="field.value"
:label="field.label"
:type="field.type"
/>
</div>
</div>
</div>
</div>
<div class="flex flex-row-reverse mt-auto"> <div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="data.save.loading" @click="update"> <Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }} {{ __('Update') }}
@@ -27,8 +38,9 @@
</template> </template>
<script setup> <script setup>
import { Button, Badge } from 'frappe-ui' import { FormControl, Button } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import { computed } from 'vue'
import Link from '@/components/Controls/Link.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -48,23 +60,37 @@ const props = defineProps({
}, },
}) })
const columns = computed(() => {
const cols = []
let currentColumn = []
props.fields.forEach((field) => {
if (field.type === 'Column Break') {
if (currentColumn.length > 0) {
cols.push(currentColumn)
currentColumn = []
}
} else {
if (field.type == 'checkbox') {
field.value = props.data.doc[field.name] ? true : false
} else {
field.value = props.data.doc[field.name]
}
currentColumn.push(field)
}
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
const update = () => { const update = () => {
props.fields.forEach((f) => { props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value props.data.doc[f.name] = f.value
}
}) })
props.data.save.submit() props.data.save.submit()
} }
</script> </script>
<style>
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
font-family: revert;
}
.CodeMirror {
border-radius: 12px;
}
</style>

View File

@@ -1,144 +0,0 @@
<template>
<div
class="my-5"
:class="{ 'flex justify-between w-full': columns.length > 1 }"
>
<div v-for="(column, index) in columns" :key="index">
<div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'"
>
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="data[field.name]"
:doctype="field.doctype"
:label="__(field.label)"
/>
<div v-else-if="field.type == 'Code'">
<CodeEditor
:label="__(field.label)"
type="HTML"
description="The HTML you add here will be shown on your sign up page."
v-model="data[field.name]"
height="250px"
class="shrink-0"
:showLineNumbers="true"
>
</CodeEditor>
</div>
<div v-else-if="field.type == 'Upload'">
<div class="text-sm text-gray-600 mb-1">
{{ __(field.label) }}
</div>
<FileUploader
v-if="!data[field.name]"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => (data[field.name] = file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 w-[15rem] py-5"
>
<img :src="data[field.name]?.file_url" class="h-6 rounded" />
</div>
<div class="flex flex-col flex-wrap">
<span class="break-all">
{{ data[field.name]?.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(data[field.name]?.file_size) }}
</span>
</div>
<X
@click="data[field.name] = null"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="data[field.name]"
/>
<FormControl
v-else
:key="field.name"
v-model="data[field.name]"
:label="__(field.label)"
:type="field.type"
:rows="field.rows"
:options="field.options"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils'
import { X, FileText } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
})
const columns = computed(() => {
const cols = []
let currentColumn = []
props.fields.forEach((field) => {
if (field.type === 'Column Break') {
if (currentColumn.length > 0) {
cols.push(currentColumn)
currentColumn = []
}
} else {
if (field.type == 'checkbox') {
field.value = props.data[field.name] ? true : false
} else {
field.value = props.data[field.name]
}
currentColumn.push(field)
}
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
</script>

View File

@@ -27,7 +27,7 @@
: 'ml-2 w-auto opacity-100' : 'ml-2 w-auto opacity-100'
" "
> >
{{ __(link.label) }} {{ link.label }}
</span> </span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600"> <span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }} {{ link.count }}

View File

@@ -1,53 +0,0 @@
<template>
<FileUploader
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
ref="fileUploader"
class="hide"
/>
</template>
<script setup>
import { FileUploader } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
const fileUploader = ref(null)
const emit = defineEmits(['fileUploaded'])
const props = defineProps({
onFileUploaded: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
const fileInput = fileUploader.value.$el.querySelector('input[type="file"]')
if (fileInput) {
fileInput.click()
}
})
const addFile = (file) => {
props.onFileUploaded({
file_url: file.file_url,
file_type: file.file_type,
})
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const isVideo = (type) => {
return ['mov', 'mp4', 'avi', 'mkv', 'webm'].includes(type.toLowerCase())
}
const isAudio = (type) => {
return ['mp3', 'wav', 'ogg'].includes(type.toLowerCase())
}
</script>

View File

@@ -11,11 +11,11 @@
: 'hover:bg-gray-200 px-2 w-52' : 'hover:bg-gray-200 px-2 w-52'
" "
> >
<img <span
v-if="branding.data?.banner_image" v-if="branding.data?.brand_html"
:src="branding.data?.banner_image.file_url" v-html="branding.data?.brand_html"
class="w-8 h-8 rounded flex-shrink-0" class="w-8 h-8 rounded flex-shrink-0"
/> ></span>
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" /> <LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div <div
class="flex flex-1 flex-col text-left duration-300 ease-in-out" class="flex flex-1 flex-col text-left duration-300 ease-in-out"
@@ -28,10 +28,11 @@
<div class="text-base font-medium text-gray-900 leading-none"> <div class="text-base font-medium text-gray-900 leading-none">
<span <span
v-if=" v-if="
branding.data?.app_name && branding.data?.app_name != 'Frappe' branding.data?.brand_name &&
branding.data?.brand_name != 'Frappe'
" "
> >
{{ branding.data?.app_name }} {{ branding.data?.brand_name }}
</span> </span>
<span v-else> Learning </span> <span v-else> Learning </span>
</div> </div>
@@ -66,20 +67,25 @@ import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui' import { Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue' import Apps from '@/components/Apps.vue'
import { ChevronDown, LogIn, LogOut, User, Settings } from 'lucide-vue-next' import {
ChevronDown,
LogIn,
LogOut,
User,
ArrowRightLeft,
Settings,
} from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils' import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings' import { ref, markRaw } from 'vue'
import { markRaw, watch, ref } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue' import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter() const router = useRouter()
const showSettingsModal = ref(false)
const { logout, branding } = sessionStore() const { logout, branding } = sessionStore()
let { userResource } = usersStore() let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const props = defineProps({ const props = defineProps({
isCollapsed: { isCollapsed: {
@@ -88,13 +94,6 @@ const props = defineProps({
}, },
}) })
watch(
() => settingsStore.isSettingsOpen,
(value) => {
showSettingsModal.value = value
}
)
const userDropdownOptions = [ const userDropdownOptions = [
{ {
icon: User, icon: User,
@@ -119,7 +118,7 @@ const userDropdownOptions = [
icon: Settings, icon: Settings,
label: 'Settings', label: 'Settings',
onClick: () => { onClick: () => {
settingsStore.isSettingsOpen = true showSettingsModal.value = true
}, },
condition: () => { condition: () => {
return userResource.data?.is_moderator return userResource.data?.is_moderator

View File

@@ -3,15 +3,12 @@
<video <video
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
@click="togglePlay" class="rounded-lg border border-gray-100"
oncontextmenu="return false"
class="rounded-lg border border-gray-100 group cursor-pointer"
ref="videoRef"
> >
<source :src="fileURL" :type="type" /> <source :src="fileURL" :type="type" />
</video> </video>
<div <div
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible" class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
> >
<Button variant="ghost"> <Button variant="ghost">
<template #icon> <template #icon>
@@ -74,6 +71,7 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
videoRef.value = document.querySelector('video')
videoRef.value.onloadedmetadata = () => { videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration duration.value = videoRef.value.duration
} }
@@ -108,14 +106,6 @@ const pauseVideo = () => {
playing.value = false playing.value = false
} }
const togglePlay = () => {
if (playing.value) {
pauseVideo()
} else {
playVideo()
}
}
const videoEnded = () => { const videoEnded = () => {
playing.value = false playing.value = false
} }

View File

@@ -5,7 +5,6 @@ import router from './router'
import App from './App.vue' import App from './App.vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import dayjs from '@/utils/dayjs' import dayjs from '@/utils/dayjs'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from './translation' import translationPlugin from './translation'
import { usersStore } from './stores/user' import { usersStore } from './stores/user'
import { sessionStore } from './stores/session' import { sessionStore } from './stores/session'
@@ -37,4 +36,3 @@ let { isLoggedIn } = sessionStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.provide('$allUsers', allUsers) app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog

View File

@@ -15,11 +15,7 @@
</header> </header>
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen"> <div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
<div class="border-r-2"> <div class="border-r-2">
<Tabs <Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-y-hidden">
v-model="tabIndex"
:tabs="tabs"
tablistClass="overflow-y-hidden sticky top-11 bg-white z-10"
>
<template #tab="{ tab, selected }" class="overflow-x-hidden"> <template #tab="{ tab, selected }" class="overflow-x-hidden">
<div> <div>
<button <button
@@ -240,7 +236,7 @@ const breadcrumbs = computed(() => {
const isStudent = computed(() => { const isStudent = computed(() => {
return ( return (
user?.data && user?.data &&
batch.data?.students?.length && batch.data?.students.length &&
batch.data?.students.includes(user.data.name) batch.data?.students.includes(user.data.name)
) )
}) })

View File

@@ -13,12 +13,12 @@
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2"> <div class="grid grid-cols-2 gap-10 mb-4">
<div> <div>
<FormControl <FormControl
v-model="batch.title" v-model="batch.title"
:label="__('Title')" :label="__('Title')"
:required="true" class="mb-4"
/> />
</div> </div>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
@@ -36,50 +36,42 @@
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-xs text-gray-600 mb-2"> <div>
{{ __('Meta Image') }}
</div>
<FileUploader <FileUploader
v-if="!batch.image" v-if="!batch.image"
class="mt-4"
:fileTypes="['image/*']" :fileTypes="['image/*']"
:validateFile="validateFile" :validateFile="validateFile"
@success="(file) => saveImage(file)" @success="(file) => saveImage(file)"
> >
<template v-slot="{ file, progress, uploading, openFileSelector }"> <template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center"> <div class="mb-4">
<div class="border rounded-md w-fit py-5 px-20"> <Button @click="openFileSelector" :loading="uploading">
<Image class="size-5 stroke-1 text-gray-700" /> {{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button> </Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div> </div>
</template> </template>
</FileUploader> </FileUploader>
<div v-else class="mb-4"> <div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
<div class="flex items-center"> <div class="flex items-center">
<img :src="batch.image.file_url" class="border rounded-md w-40" /> <div class="border rounded-md p-2 mr-2">
<div class="ml-4"> <FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div> </div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
</div> </div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -87,22 +79,18 @@
v-model="instructors" v-model="instructors"
doctype="User" doctype="User"
:label="__('Instructors')" :label="__('Instructors')"
:required="true"
:filters="{ ignore_user_type: 1 }"
/> />
</div>
<div class="mb-4"> <div class="mb-4">
<FormControl <FormControl
v-model="batch.description" v-model="batch.description"
:label="__('Description')" :label="__('Description')"
type="textarea" type="textarea"
class="my-4" class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/> />
<div> <div>
<label class="block text-sm text-gray-600 mb-1"> <label class="block text-sm text-gray-600 mb-1">
{{ __('Batch Details') }} {{ __('Batch Details') }}
<span class="text-red-500">*</span>
</label> </label>
<TextEditor <TextEditor
:content="batch.batch_details" :content="batch.batch_details"
@@ -124,14 +112,12 @@
:label="__('Start Date')" :label="__('Start Date')"
type="date" type="date"
class="mb-4" class="mb-4"
:required="true"
/> />
<FormControl <FormControl
v-model="batch.end_date" v-model="batch.end_date"
:label="__('End Date')" :label="__('End Date')"
type="date" type="date"
class="mb-4" class="mb-4"
:required="true"
/> />
</div> </div>
<div> <div>
@@ -140,22 +126,18 @@
:label="__('Start Time')" :label="__('Start Time')"
type="time" type="time"
class="mb-4" class="mb-4"
:required="true"
/> />
<FormControl <FormControl
v-model="batch.end_time" v-model="batch.end_time"
:label="__('End Time')" :label="__('End Time')"
type="time" type="time"
class="mb-4" class="mb-4"
:required="true"
/> />
<FormControl <FormControl
v-model="batch.timezone" v-model="batch.timezone"
:label="__('Timezone')" :label="__('Timezone')"
type="text" type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4" class="mb-4"
:required="true"
/> />
</div> </div>
</div> </div>
@@ -171,7 +153,6 @@
:label="__('Seat Count')" :label="__('Seat Count')"
type="number" type="number"
class="mb-4" class="mb-4"
:placeholder="__('Number of seats available')"
/> />
<FormControl <FormControl
v-model="batch.evaluation_end_date" v-model="batch.evaluation_end_date"
@@ -251,11 +232,11 @@ import {
createResource, createResource,
} from 'frappe-ui' } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { useRouter } from 'vue-router'
import { showToast } from '../utils'
import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next'
import { capture } from '@/telemetry'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')

View File

@@ -8,12 +8,12 @@
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]" :items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/> />
<div class="flex space-x-2"> <div class="flex space-x-2">
<div class="w-44"> <div class="w-40">
<Select <Select
v-if="categories.data?.length" v-if="categories.data?.length"
v-model="currentCategory" v-model="currentCategory"
:options="categories.data" :options="categories.data"
:placeholder="__('Category')" :placeholder="__('Filter')"
/> />
</div> </div>
<router-link <router-link
@@ -27,7 +27,7 @@
<template #prefix> <template #prefix>
<Plus class="h-4 w-4 stroke-1.5" /> <Plus class="h-4 w-4 stroke-1.5" />
</template> </template>
{{ __('New') }} {{ __('New Batch') }}
</Button> </Button>
</router-link> </router-link>
</div> </div>
@@ -40,7 +40,6 @@
{{ __('Loading Batches...') }} {{ __('Loading Batches...') }}
</div> </div>
<Tabs <Tabs
v-if="hasBatches"
v-model="tabIndex" v-model="tabIndex"
:tabs="makeTabs" :tabs="makeTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap" tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
@@ -80,63 +79,24 @@
<BatchCard :batch="batch" /> <BatchCard :batch="batch" />
</router-link> </router-link>
</div> </div>
<div v-else class="p-5 italic text-gray-500"> <div
{{ __('No {0} batches').format(tab.label.toLowerCase()) }} v-else
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center mt-4">
<div>
{{ __('No {0} batches found').format(tab.label.toLowerCase()) }}
</div>
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
<div
v-else-if="
!batches.loading &&
!hasBatches &&
(user.data?.is_instructor || user.data?.is_moderator)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'BatchForm',
params: {
batchName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Batch') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can link courses and assessments to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!batches.loading && !hasBatches"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No batches found') }}
</div>
<div>
{{
__(
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
createListResource,
createResource, createResource,
Breadcrumbs, Breadcrumbs,
Button, Button,
@@ -144,14 +104,13 @@ import {
Badge, Badge,
Select, Select,
} from 'frappe-ui' } from 'frappe-ui'
import { BookOpen, Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue' import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue' import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const currentCategory = ref(null) const currentCategory = ref(null)
const hasBatches = ref(false)
onMounted(() => { onMounted(() => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
@@ -160,10 +119,10 @@ onMounted(() => {
} }
}) })
const batches = createResource({ const batches = createListResource({
doctype: 'LMS Batch', doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches', url: 'lms.lms.utils.get_batches',
cache: ['batches', user.data?.email], cache: ['batches', user?.data?.email],
auto: true, auto: true,
}) })
@@ -224,14 +183,6 @@ const addToTabs = (label) => {
}) })
} }
watch(batches, () => {
Object.keys(batches.data).forEach((key) => {
if (batches.data[key].length) {
hasBatches.value = true
}
})
})
watch( watch(
() => currentCategory.value, () => currentCategory.value,
() => { () => {

View File

@@ -1,50 +1,44 @@
<template> <template>
<div class=""> <div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
/>
</header>
<div <div
v-if="access.data?.access && orderSummary.data" v-if="access.data?.access && orderSummary.data"
class="pt-5 pb-10 mx-5" class="mt-10 w-1/2 mx-auto"
> >
<!-- <div class="mb-5"> <div class="text-3xl font-bold">
<div class="text-lg font-semibold"> {{ __('Billing Details') }}
{{ __('Address') }}
</div> </div>
</div> --> <div class="text-gray-600 mt-1">
<div class="flex flex-col lg:flex-row justify-between"> {{ __('Enter the billing information to complete the payment.') }}
<div
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
>
<div class="flex items-center justify-between space-x-2">
<div class="text-gray-600">
{{ __('Ordered Item') }}
</div> </div>
<div class=""> <div class="border rounded-md p-5 mt-5">
<div class="text-xl font-semibold">
{{ __('Summary') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Review the details of your purchase.') }}
</div>
<div class="mt-5">
<div class="flex items-center justify-between">
<div>
{{ orderSummary.data.title }} {{ orderSummary.data.title }}
</div> </div>
</div>
<div <div
v-if="orderSummary.data.gst_applied" :class="{
class="flex items-center justify-between" 'font-semibold text-xl': !orderSummary.data.gst_applied,
}"
> >
<div class="text-gray-600"> {{
{{ __('Original Amount') }} orderSummary.data.gst_applied
</div> ? orderSummary.data.original_amount_formatted
<div class=""> : orderSummary.data.total_amount_formatted
{{ orderSummary.data.original_amount_formatted }} }}
</div> </div>
</div> </div>
<div <div
v-if="orderSummary.data.gst_applied" v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2" class="flex items-center justify-between mt-2"
> >
<div class="text-gray-600"> <div>
{{ __('GST Amount') }} {{ __('GST Amount') }}
</div> </div>
<div> <div>
@@ -52,91 +46,109 @@
</div> </div>
</div> </div>
<div <div
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2" v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
> >
<div class="text-lg font-semibold"> <div>
{{ __('Total') }} {{ __('Total Amount') }}
</div> </div>
<div class="text-lg font-semibold"> <div class="font-semibold text-2xl">
{{ orderSummary.data.total_amount_formatted }} {{ orderSummary.data.total_amount_formatted }}
</div> </div>
</div> </div>
</div> </div>
<div class="flex-1 lg:mr-10"> <div class="text-xl font-semibold mt-10">
<div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }} {{ __('Address') }}
</div> </div>
<div class="text-gray-600 mt-1">
{{ __('Specify your billing address correctly.') }}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-2 gap-5 mt-4">
<div class="space-y-4"> <div>
<FormControl <div class="mt-4">
:label="__('Billing Name')" <div class="mb-1.5 text-sm text-gray-700">
v-model="billingDetails.billing_name" {{ __('Billing Name') }}
/> </div>
<FormControl <Input type="text" v-model="billingDetails.billing_name" />
:label="__('Address Line 1')" </div>
v-model="billingDetails.address_line1" <div class="mt-4">
/> <div class="mb-1.5 text-sm text-gray-700">
<FormControl {{ __('Address Line 1') }}
:label="__('Address Line 2')" </div>
v-model="billingDetails.address_line2" <Input type="text" v-model="billingDetails.address_line1" />
/> </div>
<FormControl :label="__('City')" v-model="billingDetails.city" /> <div class="mt-4">
<FormControl <div class="mb-1.5 text-sm text-gray-700">
:label="__('State')" {{ __('Address Line 2') }}
v-model="billingDetails.state" </div>
/> <Input type="text" v-model="billingDetails.address_line2" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('City') }}
</div>
<Input type="text" v-model="billingDetails.city" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('State') }}
</div>
<Input type="text" v-model="billingDetails.state" />
</div>
</div>
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Country') }}
</div> </div>
<div class="space-y-4">
<Link <Link
doctype="Country" doctype="Country"
:value="billingDetails.country" :value="billingDetails.country"
@change="(option) => changeCurrency(option)" @change="(option) => changeCurrency(option)"
:label="__('Country')"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
/> />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Postal Code') }}
</div>
<Input type="text" v-model="billingDetails.pincode" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Phone Number') }}
</div>
<Input type="text" v-model="billingDetails.phone" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Source') }}
</div>
<Link <Link
doctype="LMS Source" doctype="LMS Source"
:value="billingDetails.source" :value="billingDetails.source"
@change="(option) => (billingDetails.source = option)" @change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('Pan Number')"
v-model="billingDetails.pan"
/> />
</div> </div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('GST Number') }}
</div> </div>
<div class="flex items-center justify-between border-t pt-4 mt-8"> <Input type="text" v-model="billingDetails.gstin" />
<p class="text-gray-600"> </div>
{{ <div v-if="billingDetails.country == 'India'" class="mt-4">
__( <div class="mb-1.5 text-sm text-gray-700">
'Make sure to enter the right billing name as the same will be used in your invoice.' {{ __('Pan Number') }}
) </div>
}} <Input type="text" v-model="billingDetails.pan" />
</p> </div>
<Button variant="solid" size="md" @click="generatePaymentLink()"> </div>
</div>
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }} {{ __('Proceed to Payment') }}
</Button> </Button>
</div> </div>
</div> </div>
</div>
</div>
<div v-else-if="access.data?.message"> <div v-else-if="access.data?.message">
<NotPermitted <NotPermitted
:text="access.data.message" :text="access.data.message"
@@ -155,18 +167,11 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { import { Input, Button, createResource } from 'frappe-ui'
Input,
Button,
createResource,
FormControl,
Breadcrumbs,
Tooltip,
} from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue' import { reactive, inject, onMounted, ref } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue' import NotPermitted from '@/components/NotPermitted.vue'
import { showToast } from '@/utils/' import { createToast } from '@/utils/'
const user = inject('$user') const user = inject('$user')
@@ -197,8 +202,8 @@ const access = createResource({
name: props.name, name: props.name,
}, },
onSuccess(data) { onSuccess(data) {
setBillingDetails(data.address)
orderSummary.submit() orderSummary.submit()
setBillingDetails(data.address)
}, },
}) })
@@ -219,49 +224,84 @@ const orderSummary = createResource({
const billingDetails = reactive({}) const billingDetails = reactive({})
const setBillingDetails = (data) => { const setBillingDetails = (data) => {
billingDetails.billing_name = data?.billing_name || '' billingDetails.billing_name = data.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || '' billingDetails.address_line1 = data.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || '' billingDetails.address_line2 = data.address_line2 || ''
billingDetails.city = data?.city || '' billingDetails.city = data.city || ''
billingDetails.state = data?.state || '' billingDetails.state = data.state || ''
billingDetails.country = data?.country || '' billingDetails.country = data.country || ''
billingDetails.pincode = data?.pincode || '' billingDetails.pincode = data.pincode || ''
billingDetails.phone = data?.phone || '' billingDetails.phone = data.phone || ''
billingDetails.source = data?.source || '' billingDetails.source = data.source || ''
billingDetails.gstin = data?.gstin || '' billingDetails.gstin = data.gstin || ''
billingDetails.pan = data?.pan || '' billingDetails.pan = data.pan || ''
} }
const paymentLink = createResource({ const paymentOptions = createResource({
url: 'lms.lms.payments.get_payment_link', url: 'lms.lms.utils.get_payment_options',
makeParams(values) { makeParams(values) {
return { return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch', doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name, docname: props.name,
title: orderSummary.data.title, phone: billingDetails.phone,
amount: orderSummary.data.original_amount, country: billingDetails.country,
total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency,
address: billingDetails,
} }
}, },
}) })
const generatePaymentLink = () => { const generatePaymentLink = () => {
paymentLink.submit( paymentOptions.submit(
{}, {},
{ {
validate() { validate(params) {
if (!billingDetails.source) {
return __('Please let us know where you heard about us from.')
}
return validateAddress() return validateAddress()
}, },
onSuccess(data) { onSuccess(data) {
window.location.href = data data.handler = (response) => {
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
let docname = props.name
handleSuccess(response, doctype, docname, data.order_id)
}
let rzp1 = new Razorpay(data)
rzp1.open()
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') showError(err)
},
}
)
}
const paymentResource = createResource({
url: 'lms.lms.utils.verify_payment',
makeParams(values) {
return {
response: values.response,
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
address: billingDetails,
order_id: values.orderId,
}
},
})
const handleSuccess = (response, doctype, docname, orderId) => {
paymentResource.submit(
{
response: response,
orderId: orderId,
},
{
onSuccess(data) {
createToast({
title: 'Success',
text: 'Payment Successful',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
setTimeout(() => {
window.location.href = data
}, 3000)
}, },
} }
) )

View File

@@ -16,16 +16,16 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Tooltip <Tooltip
v-if="course.data.rating" v-if="course.data.avg_rating"
:text="__('Average Rating')" :text="__('Average Rating')"
class="flex items-center" class="flex items-center"
> >
<Star class="h-5 w-5 text-gray-100 fill-orange-500" /> <Star class="h-5 w-5 text-gray-100 fill-orange-500" />
<span class="ml-1"> <span class="ml-1">
{{ course.data.rating }} {{ course.data.avg_rating }}
</span> </span>
</Tooltip> </Tooltip>
<span v-if="course.data.rating" class="mx-3">&middot;</span> <span v-if="course.data.avg_rating" class="mx-3">&middot;</span>
<Tooltip <Tooltip
v-if="course.data.enrollment_count" v-if="course.data.enrollment_count"
:text="__('Enrolled Students')" :text="__('Enrolled Students')"
@@ -67,14 +67,14 @@
<CourseCardOverlay :course="course" class="md:hidden mb-4" /> <CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div <div
v-html="course.data.description" v-html="course.data.description"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal" class="course-description"
></div> ></div>
<div class="mt-10"> <div class="mt-10">
<CourseOutline :courseName="course.data.name" :showOutline="true" /> <CourseOutline :courseName="course.data.name" :showOutline="true" />
</div> </div>
<CourseReviews <CourseReviews
:courseName="course.data.name" :courseName="course.data.name"
:avg_rating="course.data.rating" :avg_rating="course.data.avg_rating"
:membership="course.data.membership" :membership="course.data.membership"
/> />
</div> </div>
@@ -116,7 +116,7 @@ const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }] let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({ items.push({
label: course?.data?.title, label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } }, route: { name: 'CourseDetail', params: { course: course?.data?.name } },
}) })
return items return items
}) })
@@ -131,6 +131,26 @@ const pageMeta = computed(() => {
updateDocumentTitle(pageMeta) updateDocumentTitle(pageMeta)
</script> </script>
<style> <style>
.course-description p {
margin-bottom: 1rem;
line-height: 1.7;
}
.course-description li {
line-height: 1.7;
}
.course-description ol {
list-style: auto;
margin: revert;
padding: revert;
}
.course-description ul {
list-style: disc;
margin: revert;
padding: revert;
}
.avatar-group { .avatar-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -7,14 +7,6 @@
> >
<Breadcrumbs class="h-7" :items="breadcrumbs" /> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center mt-3 md:mt-0"> <div class="flex items-center mt-3 md:mt-0">
<Button v-if="courseResource.data?.name" @click="trashCourse()">
<template #prefix>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
<span>
{{ __('Delete') }}
</span>
</Button>
<Button variant="solid" @click="submitCourse()" class="ml-2"> <Button variant="solid" @click="submitCourse()" class="ml-2">
<span> <span>
{{ __('Save') }} {{ __('Save') }}
@@ -31,23 +23,15 @@
v-model="course.title" v-model="course.title"
:label="__('Title')" :label="__('Title')"
class="mb-4" class="mb-4"
:required="true"
/> />
<FormControl <FormControl
v-model="course.short_introduction" v-model="course.short_introduction"
:label="__('Short Introduction')" :label="__('Short Introduction')"
:placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
class="mb-4" class="mb-4"
:required="true"
/> />
<div class="mb-4"> <div class="mb-4">
<div class="mb-1.5 text-sm text-gray-600"> <div class="mb-1.5 text-sm text-gray-700">
{{ __('Course Description') }} {{ __('Course Description') }}
<span class="text-red-500">*</span>
</div> </div>
<TextEditor <TextEditor
:content="course.description" :content="course.description"
@@ -57,11 +41,6 @@
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
<div class="mb-4">
<div class="text-xs text-gray-600 mb-2">
{{ __('Course Image') }}
<span class="text-red-500">*</span>
</div>
<FileUploader <FileUploader
v-if="!course.course_image" v-if="!course.course_image"
:fileTypes="['image/*']" :fileTypes="['image/*']"
@@ -71,48 +50,40 @@
<template <template
v-slot="{ file, progress, uploading, openFileSelector }" v-slot="{ file, progress, uploading, openFileSelector }"
> >
<div class="flex items-center"> <div class="mb-4">
<div class="border rounded-md w-fit py-5 px-20"> <Button @click="openFileSelector" :loading="uploading">
<Image class="size-5 stroke-1 text-gray-700" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{ {{
__('Appears on the course card in the course list') uploading ? `Uploading ${progress}%` : 'Upload an image'
}} }}
</div> </Button>
</div>
</div> </div>
</template> </template>
</FileUploader> </FileUploader>
<div v-else class="mb-4"> <div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Course Image') }}
</div>
<div class="flex items-center"> <div class="flex items-center">
<img <div class="border rounded-md p-2 mr-2">
:src="course.course_image.file_url" <FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
class="border rounded-md w-40" </div>
<div class="flex flex-col">
<span>
{{ course.course_image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(course.course_image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/> />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div>
</div> </div>
</div> </div>
<FormControl <FormControl
v-model="course.video_link" v-model="course.video_link"
:label="__('Preview Video')" :label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
class="mb-4" class="mb-4"
/> />
<div class="mb-4"> <div class="mb-4">
@@ -133,27 +104,15 @@
</div> </div>
<FormControl <FormControl
v-model="newTag" v-model="newTag"
:placeholder="__('Keywords for the course')"
class="w-52"
@keyup.enter="updateTags()" @keyup.enter="updateTags()"
id="tags" id="tags"
/> />
</div> </div>
</div> </div>
<div class="w-1/2 mb-4">
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings(close)"
/>
</div>
<MultiSelect <MultiSelect
v-model="instructors" v-model="instructors"
doctype="User" doctype="User"
:label="__('Instructors')" :label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:required="true"
/> />
</div> </div>
<div class="container border-t"> <div class="container border-t">
@@ -163,7 +122,7 @@
<div class="grid grid-cols-3 gap-10 mb-4"> <div class="grid grid-cols-3 gap-10 mb-4">
<div <div
v-if="user.data?.is_moderator" v-if="user.data?.is_moderator"
class="flex flex-col space-y-4" class="flex flex-col space-y-3"
> >
<FormControl <FormControl
type="checkbox" type="checkbox"
@@ -256,24 +215,24 @@ import {
ref, ref,
reactive, reactive,
watch, watch,
getCurrentInstance,
} from 'vue' } from 'vue'
import { showToast, updateDocumentTitle } from '@/utils' import {
convertToTitleCase,
showToast,
getFileSize,
updateDocumentTitle,
} from '../utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { Image, Trash2, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
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'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useSettings } from '@/stores/settings'
const user = inject('$user') const user = inject('$user')
const newTag = ref('') const newTag = ref('')
const router = useRouter() const router = useRouter()
const instructors = ref([]) const instructors = ref([])
const settingsStore = useSettings()
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -446,37 +405,23 @@ const submitCourse = () => {
} }
} }
const deleteCourse = createResource({ const validateMandatoryFields = () => {
url: 'lms.lms.api.delete_course', const mandatory_fields = [
makeParams(values) { 'title',
return { 'short_introduction',
course: props.courseName, 'description',
'video_link',
'course_image',
]
for (const field of mandatory_fields) {
if (!course[field]) {
let fieldLabel = convertToTitleCase(field.split('_').join(' '))
return `${fieldLabel} is mandatory`
}
}
if (course.paid_course && (!course.course_price || !course.currency)) {
return 'Course price and currency are mandatory for paid courses'
} }
},
onSuccess() {
showToast(__('Success'), __('Course deleted successfully'), 'check')
router.push({ name: 'Courses' })
},
})
const trashCourse = () => {
$dialog({
title: __('Delete Course'),
message: __(
'Deleting the course will also delete all its chapters and lessons. Are you sure you want to delete this course?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteCourse.submit()
close()
},
},
],
})
} }
watch( watch(
@@ -491,7 +436,7 @@ watch(
const validateFile = (file) => { const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase() let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) { if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.') return 'Only image file is allowed.'
} }
} }
@@ -518,12 +463,6 @@ const removeImage = () => {
course.course_image = null course.course_image = null
} }
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Categories'
settingsStore.isSettingsOpen = true
}
const check_permission = () => { const check_permission = () => {
let user_is_instructor = false let user_is_instructor = false
if (user.data?.is_moderator) return if (user.data?.is_moderator) return

View File

@@ -8,16 +8,7 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]" :items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/> />
<div class="flex space-x-2 justify-end"> <div class="flex space-x-2 justify-end">
<div class="w-40 md:w-44"> <div class="w-36">
<FormControl
v-if="categories.data?.length"
type="select"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl <FormControl
type="text" type="text"
placeholder="Search" placeholder="Search"
@@ -41,14 +32,13 @@
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('New') }} {{ __('New Course') }}
</Button> </Button>
</router-link> </router-link>
</div> </div>
</header> </header>
<div class=""> <div class="">
<Tabs <Tabs
v-if="hasCourses"
v-model="tabIndex" v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap" tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs" :tabs="makeTabs"
@@ -102,57 +92,18 @@
<CourseCard :course="course" /> <CourseCard :course="course" />
</router-link> </router-link>
</div> </div>
<div v-else class="p-5 italic text-gray-500"> <div
{{ __('No {0} courses').format(tab.label.toLowerCase()) }} v-else
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center mt-4">
<div>
{{ __('No {0} courses found').format(tab.label.toLowerCase()) }}
</div>
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
<div
v-else-if="
!courses.loading &&
(user.data?.is_moderator || user.data?.is_instructor)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Course') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can add chapters and lessons to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!courses.loading && !hasCourses"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No courses found') }}
</div>
<div class="leading-5">
{{
__(
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -167,21 +118,12 @@ import {
createResource, createResource,
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue' import { ref, computed, inject } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const searchQuery = ref('') const searchQuery = ref('')
const currentCategory = ref(null)
const hasCourses = ref(false)
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const courses = createResource({ const courses = createResource({
url: 'lms.lms.utils.get_courses', url: 'lms.lms.utils.get_courses',
@@ -226,66 +168,17 @@ const addToTabs = (label) => {
} }
const getCourses = (type) => { const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) { if (searchQuery.value) {
let query = searchQuery.value.toLowerCase() let query = searchQuery.value.toLowerCase()
courseList = courseList.filter( return courses.data[type].filter(
(course) => (course) =>
course.title.toLowerCase().includes(query) || course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) || course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
) )
} }
if (currentCategory.value && currentCategory.value != '') { return courses.data[type]
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
} }
return courseList
}
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
},
}
},
cache: ['courseCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(courses, () => {
if (courses.data) {
Object.keys(courses.data).forEach((section) => {
if (courses.data[section].length) {
hasCourses.value = true
}
})
}
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => { const pageMeta = computed(() => {
return { return {

View File

@@ -149,7 +149,7 @@ const newJob = createResource({
return { return {
doc: { doc: {
doctype: 'Job Opportunity', doctype: 'Job Opportunity',
company_logo: job.image?.file_url, company_logo: job.image.file_url,
...job, ...job,
}, },
} }

View File

@@ -52,88 +52,46 @@
</header> </header>
<div v-if="job.data" class="max-w-3xl mx-auto"> <div v-if="job.data" class="max-w-3xl mx-auto">
<div class="p-4"> <div class="p-4">
<div class="space-y-5 mb-10"> <div class="flex mb-10">
<div class="flex items-center">
<img <img
:src="job.data.company_logo" :src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4" class="w-16 h-16 rounded-lg object-contain mr-4"
:alt="job.data.company_name" :alt="job.data.company_name"
/> />
<div>
<div class="text-2xl font-semibold mb-4"> <div class="text-2xl font-semibold mb-4">
{{ job.data.job_title }} {{ job.data.job_title }}
</div> </div>
</div>
<div>
<div <div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-2 md:gap-y-4"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="p-4 bg-green-50 rounded-full"> <Building2 class="h-4 w-4 stroke-1.5" />
<Building2 class="h-4 w-4 text-green-500" /> <span>{{ job.data.company_name }}</span>
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Organisation') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.company_name }}
</span>
</div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="p-4 bg-red-50 rounded-full"> <MapPin class="h-4 w-4 stroke-1.5" />
<MapPin class="h-4 w-4 text-red-500" /> <span>{{ job.data.location }}</span>
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Location') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.location }}
</span>
</div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="p-4 bg-yellow-50 rounded-full"> <ClipboardType class="h-4 w-4 stroke-1.5" />
<ClipboardType class="h-4 w-4 text-yellow-500" /> <span>{{ job.data.type }}</span>
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs font-medium text-gray-600 uppercase">
{{ __('Category') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.type }}
</span>
</div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="p-4 bg-blue-50 rounded-full"> <CalendarDays class="h-4 w-4 stroke-1.5" />
<CalendarDays class="h-4 w-4 text-blue-500" /> <span>
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Posted on') }}
</span>
<span class="text-sm font-semibold">
{{ dayjs(job.data.creation).format('DD MMM YYYY') }} {{ dayjs(job.data.creation).format('DD MMM YYYY') }}
</span> </span>
</div> </div>
</div>
<div <div
v-if="applicationCount.data" v-if="applicationCount.data"
class="flex items-center space-x-2" class="flex items-center space-x-2"
> >
<span class="p-4 bg-purple-50 rounded-full"> <SquareUserRound class="h-4 w-4 stroke-1.5" />
<SquareUserRound class="h-4 w-4 text-purple-500" /> <span
</span> >{{ applicationCount.data }}
<div class="flex flex-col space-y-2"> {{ __('applications received') }}</span
<span class="text-xs text-gray-600 font-medium uppercase"> >
{{ __('Applications Received') }}
</span>
<span class="text-sm font-semibold">
{{ applicationCount.data }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -17,9 +17,14 @@
) )
}} }}
</p> </p>
<Button v-if="user.data" @click="enrollStudent()" variant="solid"> <router-link
v-if="user.data"
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
>
<Button variant="solid">
{{ __('Start Learning') }} {{ __('Start Learning') }}
</Button> </Button>
</router-link>
<Button v-else @click="redirectToLogin()"> <Button v-else @click="redirectToLogin()">
{{ __('Login') }} {{ __('Login') }}
</Button> </Button>
@@ -115,8 +120,7 @@
</div> </div>
<div <div
v-if=" v-if="
lesson.data.instructor_content && lesson.data.instructor_content?.blocks?.length &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent() allowInstructorContent()
" "
class="bg-gray-100 p-3 rounded-md mt-6" class="bg-gray-100 p-3 rounded-md mt-6"
@@ -189,7 +193,7 @@ import { createResource, Breadcrumbs, Button } from 'frappe-ui'
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue' import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next' import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import { getEditorTools, updateDocumentTitle } from '../utils' import { getEditorTools, updateDocumentTitle } from '../utils'
@@ -199,7 +203,6 @@ import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user') const user = inject('$user')
const router = useRouter()
const route = useRoute() const route = useRoute()
const allowDiscussions = ref(false) const allowDiscussions = ref(false)
const editor = ref(null) const editor = ref(null)
@@ -239,16 +242,9 @@ const lesson = createResource({
}, },
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
if (Object.keys(data).length === 0) {
router.push({ name: 'Courses' })
return
}
lessonProgress.value = data.membership?.progress lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content) if (data.content) editor.value = renderEditor('editor', data.content)
if ( if (data.instructor_content?.blocks?.length)
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor( instructorEditor.value = renderEditor(
'instructor-content', 'instructor-content',
data.instructor_content data.instructor_content
@@ -279,7 +275,7 @@ const renderEditor = (holder, content) => {
} }
const markProgress = () => { const markProgress = () => {
if (user.data && lesson.data && !lesson.data.progress) { if (user.data && !lesson.data?.progress) {
progress.submit() progress.submit()
} }
} }
@@ -301,14 +297,14 @@ const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }] let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({ items.push({
label: lesson?.data?.course_title, label: lesson?.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } }, route: { name: 'CourseDetail', params: { course: props.courseName } },
}) })
items.push({ items.push({
label: lesson?.data?.title, label: lesson?.data?.title,
route: { route: {
name: 'Lesson', name: 'Lesson',
params: { params: {
courseName: props.courseName, course: props.courseName,
chapterNumber: props.chapterNumber, chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber, lessonNumber: props.lessonNumber,
}, },
@@ -379,30 +375,6 @@ const allowInstructorContent = () => {
return false return false
} }
const enrollment = createResource({
url: 'frappe.client.insert',
makeParams() {
return {
doc: {
doctype: 'LMS Enrollment',
course: props.courseName,
member: user.data?.name,
},
}
},
})
const enrollStudent = () => {
enrollment.submit(
{},
{
onSuccess() {
window.location.reload()
},
}
)
}
const redirectToLogin = () => { const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}` window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
} }
@@ -476,10 +448,6 @@ updateDocumentTitle(pageMeta)
max-width: unset; max-width: unset;
} }
.codex-editor__redactor {
padding-bottom: 0px !important;
}
.codeBoxHolder { .codeBoxHolder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -569,13 +537,4 @@ updateDocumentTitle(pageMeta)
color: #383a42; color: #383a42;
background-color: #fafafa; background-color: #fafafa;
} }
.codeBoxTextArea {
line-height: 1.7;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style> </style>

View File

@@ -12,12 +12,7 @@
</header> </header>
<div class="py-5"> <div class="py-5">
<div class="w-5/6 mx-auto"> <div class="w-5/6 mx-auto">
<FormControl <FormControl v-model="lesson.title" label="Title" class="mb-4" />
v-model="lesson.title"
label="Title"
class="mb-4"
:required="true"
/>
<FormControl <FormControl
v-model="lesson.include_in_preview" v-model="lesson.include_in_preview"
type="checkbox" type="checkbox"
@@ -67,14 +62,14 @@
</div> </div>
<div class=""> <div class="">
<div class="sticky top-0 p-5"> <div class="sticky top-0 p-5">
<LessonHelp /> <LessonPlugins :editor="editor" :notesEditor="instructorEditor" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui' import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
import { import {
computed, computed,
reactive, reactive,
@@ -84,7 +79,7 @@ import {
onBeforeUnmount, onBeforeUnmount,
} from 'vue' } from 'vue'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue' import LessonPlugins from '@/components/LessonPlugins.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils' import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
@@ -122,7 +117,7 @@ onMounted(() => {
const renderEditor = (holder) => { const renderEditor = (holder) => {
return new EditorJS({ return new EditorJS({
holder: holder, holder: holder,
tools: getEditorTools(true), tools: getEditorTools(),
autofocus: true, autofocus: true,
}) })
} }
@@ -148,9 +143,7 @@ const lessonDetails = createResource({
Object.keys(data.lesson).forEach((key) => { Object.keys(data.lesson).forEach((key) => {
lesson[key] = data.lesson[key] lesson[key] = data.lesson[key]
}) })
lesson.include_in_preview = data?.lesson?.include_in_preview lesson.include_in_preview = data.include_in_preview ? true : false
? true
: false
addLessonContent(data) addLessonContent(data)
addInstructorNotes(data) addInstructorNotes(data)
enableAutoSave() enableAutoSave()
@@ -187,7 +180,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => { const enableAutoSave = () => {
autoSaveInterval = setInterval(() => { autoSaveInterval = setInterval(() => {
saveLesson() saveLesson()
}, 10000) }, 5000)
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -430,7 +423,7 @@ 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 } },
}, },
] ]
@@ -480,10 +473,6 @@ updateDocumentTitle(pageMeta)
max-width: none; max-width: none;
} }
.codex-editor--narrow .ce-toolbar__actions {
right: 100%;
}
.ce-toolbar__content { .ce-toolbar__content {
max-width: none; max-width: none;
} }
@@ -556,6 +545,10 @@ updateDocumentTitle(pageMeta)
cursor: pointer; cursor: pointer;
} }
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem { .codeBoxSelectedItem {
background-color: lightblue !important; background-color: lightblue !important;
} }
@@ -573,17 +566,4 @@ updateDocumentTitle(pageMeta)
color: #383a42; color: #383a42;
background-color: #fafafa; background-color: #fafafa;
} }
.codeBoxTextArea {
line-height: 1.7;
}
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
overflow-x: unset;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style> </style>

View File

@@ -146,7 +146,7 @@ const coverImage = createResource({
const setActiveTab = () => { const setActiveTab = () => {
let fragments = route.path.split('/') let fragments = route.path.split('/')
let sections = ['certificates', 'roles', 'slots', 'schedule'] let sections = ['certificates', 'roles', 'evaluations']
sections.forEach((section) => { sections.forEach((section) => {
if (fragments.includes(section)) { if (fragments.includes(section)) {
activeTab.value = convertToTitleCase(section) activeTab.value = convertToTitleCase(section)
@@ -161,8 +161,7 @@ watchEffect(() => {
About: { name: 'ProfileAbout' }, About: { name: 'ProfileAbout' },
Certificates: { name: 'ProfileCertificates' }, Certificates: { name: 'ProfileCertificates' },
Roles: { name: 'ProfileRoles' }, Roles: { name: 'ProfileRoles' },
Slots: { name: 'ProfileEvaluator' }, Evaluations: { name: 'ProfileEvaluator' },
Schedule: { name: 'ProfileEvaluationSchedule' },
}[activeTab.value] }[activeTab.value]
router.push(route) router.push(route)
} }
@@ -186,13 +185,8 @@ const isSessionUser = () => {
const getTabButtons = () => { const getTabButtons = () => {
let buttons = [{ label: 'About' }, { label: 'Certificates' }] let buttons = [{ label: 'About' }, { label: 'Certificates' }]
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' }) if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
if ( if (isSessionUser() && $user.data?.is_evaluator)
isSessionUser() && buttons.push({ label: 'Evaluations' })
($user.data?.is_evaluator || $user.data?.is_moderator)
) {
buttons.push({ label: 'Slots' })
buttons.push({ label: 'Schedule' })
}
return buttons return buttons
} }

View File

@@ -42,7 +42,7 @@
<img <img
:src="badge.badge_image" :src="badge.badge_image"
:alt="badge.badge" :alt="badge.badge"
class="bg-gray-100 rounded-t-md h-[200px] mx-auto" class="bg-gray-100 rounded-t-md"
/> />
<div class="p-5"> <div class="p-5">
<div class="text-2xl font-semibold mb-2"> <div class="text-2xl font-semibold mb-2">
@@ -142,7 +142,7 @@ const shareOnSocial = (badge, medium) => {
const summary = `I am happy to announce that I earned the ${ const summary = `I am happy to announce that I earned the ${
badge.badge badge.badge
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${ } badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
branding.data?.app_name branding.data?.brand_name
}.` }.`
if (medium == 'LinkedIn') if (medium == 'LinkedIn')

View File

@@ -1,102 +0,0 @@
<template>
<div class="mt-7 mb-20">
<div class="flex h-screen flex-col overflow-hidden">
<Calendar
v-if="evaluations.data?.length"
:config="{
defaultMode: 'Month',
disableModes: ['Day', 'Week'],
redundantCellHeight: 100,
enableShortcuts: false,
}"
:events="evaluations.data"
@click="(event) => openEvent(event)"
>
<template #header="{ currentMonthYear, decrement, increment }">
<div class="mb-2 flex justify-between">
<span class="text-lg font-semibold">
{{ currentMonthYear }}
</span>
<div class="flex gap-x-1">
<Button
@click="decrement()"
variant="ghost"
class="h-4 w-4"
icon="chevron-left"
/>
<Button
@click="increment()"
variant="ghost"
class="h-4 w-4"
icon="chevron-right"
/>
</div>
</div>
</template>
</Calendar>
</div>
</div>
<Event v-model="showEvent" :event="currentEvent" />
</template>
<script setup>
import { Calendar, createListResource, Button } from 'frappe-ui'
import { inject, ref } from 'vue'
import Event from '@/components/Modals/Event.vue'
const user = inject('$user')
const currentEvent = ref(null)
const showEvent = ref(false)
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const evaluations = createListResource({
doctype: 'LMS Certificate Request',
filters: {
evaluator: user.data?.name,
},
fields: [
'name',
'member_name',
'member',
'course',
'course_title',
'batch_name',
'batch_title',
'evaluator',
'evaluator_name',
'date',
'start_time',
'end_time',
'google_meet_link',
],
auto: true,
orderBy: 'creation desc',
limit: 100,
cache: ['schedule', user.data?.name],
transform(data) {
return data.map((d) => {
let mappedData = Object.assign({}, d)
mappedData.title = `${d.member_name}'s Evaluation`
mappedData.participant = d.member_name
mappedData.id = d.name
mappedData.venue = d.google_meet_link
mappedData.fromDate = `${d.date} ${d.start_time}`
mappedData.toDate = `${d.date} ${d.end_time}`
mappedData.color = 'green'
return mappedData
})
},
})
const openEvent = (event) => {
currentEvent.value = event.calendarEvent
showEvent.value = true
}
</script>

View File

@@ -3,42 +3,14 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizPage',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Open') }}
</Button>
</router-link>
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()"> <Button variant="solid" @click="submitQuiz()">
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</div>
</header> </header>
<div class="w-3/4 mx-auto py-5"> <div class="w-3/4 mx-auto py-5">
<!-- Details --> <!-- Details -->
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="text-sm font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<FormControl <FormControl
@@ -50,17 +22,11 @@
" "
/> />
<div v-if="quizDetails.data?.name"> <div v-if="quizDetails.data?.name">
<div class="grid grid-cols-2 gap-5 mt-4 mb-8"> <div class="grid grid-cols-3 gap-5 mt-2 mb-8">
<FormControl <FormControl
type="number"
v-model="quiz.max_attempts" v-model="quiz.max_attempts"
:label="__('Maximun Attempts')" :label="__('Maximun Attempts')"
/> />
<FormControl
type="number"
v-model="quiz.duration"
:label="__('Duration (in minutes)')"
/>
<FormControl <FormControl
v-model="quiz.total_marks" v-model="quiz.total_marks"
:label="__('Total Marks')" :label="__('Total Marks')"
@@ -74,7 +40,7 @@
<!-- Settings --> <!-- Settings -->
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="text-sm font-semibold mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-3 gap-5 my-4"> <div class="grid grid-cols-3 gap-5 my-4">
@@ -92,7 +58,7 @@
</div> </div>
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="text-sm font-semibold mb-4">
{{ __('Shuffle Settings') }} {{ __('Shuffle Settings') }}
</div> </div>
<div class="grid grid-cols-3"> <div class="grid grid-cols-3">
@@ -112,7 +78,7 @@
<!-- Questions --> <!-- Questions -->
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="font-semibold"> <div class="text-sm font-semibold">
{{ __('Questions') }} {{ __('Questions') }}
</div> </div>
<Button @click="openQuestionModal()"> <Button @click="openQuestionModal()">
@@ -159,7 +125,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
variant="ghost" variant="ghost"
@click="deleteQuestions(selections, unselectAll)" @click="deleteQuizzes(selections, unselectAll)"
> >
<Trash2 class="h-4 w-4 stroke-1.5" /> <Trash2 class="h-4 w-4 stroke-1.5" />
</Button> </Button>
@@ -208,7 +174,7 @@ import {
} from 'vue' } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import Question from '@/components/Modals/Question.vue' import Question from '@/components/Modals/Question.vue'
import { showToast, updateDocumentTitle } from '@/utils' import { showToast } from '../utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const showQuestionModal = ref(false) const showQuestionModal = ref(false)
@@ -232,7 +198,6 @@ const quiz = reactive({
total_marks: 0, total_marks: 0,
passing_percentage: 0, passing_percentage: 0,
max_attempts: 0, max_attempts: 0,
duration: 0,
limit_questions_to: 0, limit_questions_to: 0,
show_answers: true, show_answers: true,
show_submission_history: false, show_submission_history: false,
@@ -341,7 +306,7 @@ const createQuiz = () => {
onSuccess(data) { onSuccess(data) {
showToast(__('Success'), __('Quiz created successfully'), 'check') showToast(__('Success'), __('Quiz created successfully'), 'check')
router.push({ router.push({
name: 'QuizForm', name: 'QuizCreation',
params: { quizID: data.name }, params: { quizID: data.name },
}) })
}, },
@@ -382,17 +347,17 @@ const questionColumns = computed(() => {
{ {
label: __('ID'), label: __('ID'),
key: 'question', key: 'question',
width: '10rem', width: '25%',
}, },
{ {
label: __('Question'), label: __('Question'),
key: __('question_detail'), key: __('question_detail'),
width: '40rem', width: '60%',
}, },
{ {
label: __('Marks'), label: __('Marks'),
key: 'marks', key: 'marks',
width: '5rem', width: '10%',
}, },
] ]
}) })
@@ -410,29 +375,24 @@ const openQuestionModal = (question = null) => {
showQuestionModal.value = true showQuestionModal.value = true
} }
const deleteQuestionResource = createResource({ const deleteQuiz = createResource({
url: 'lms.lms.api.delete_documents', url: 'frappe.client.delete',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'LMS Quiz Question', doctype: 'LMS Quiz Question',
documents: values.questions, name: values.quiz,
} }
}, },
}) })
const deleteQuestions = (selections, unselectAll) => { const deleteQuizzes = (selections, unselectAll) => {
deleteQuestionResource.submit( selections.forEach(async (quiz) => {
{ deleteQuiz.submit({ quiz })
questions: Array.from(selections), })
}, setTimeout(() => {
{
onSuccess() {
showToast(__('Success'), __('Questions deleted successfully'), 'check')
quizDetails.reload() quizDetails.reload()
unselectAll() unselectAll()
}, }, 500)
}
)
} }
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
@@ -450,18 +410,9 @@ const breadcrumbs = computed(() => {
}) })
} */ } */
crumbs.push({ crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title, label: props.quizID == 'new' ? 'New Quiz' : quizDetails.data?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } }, route: { name: 'QuizCreation', params: { quizID: props.quizID } },
}) })
return crumbs return crumbs
}) })
const pageMeta = computed(() => {
return {
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
description: __('Form to create and edit quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script> </script>

View File

@@ -1,58 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="md:w-7/12 md:mx-auto mx-4 py-10">
<Quiz :quizName="quizID" />
</div>
</template>
<script setup>
import Quiz from '@/components/Quiz.vue'
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
}
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
})
const pageMeta = computed(() => {
return {
title: title.data?.title,
description: __('Quiz Submission'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -1,122 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
<div class="space-x-2">
<Badge
v-if="submisisonDetails.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" @click="saveSubmission()">
{{ __('Save') }}
</Button>
</div>
</header>
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.quiz_title"
:label="__('Quiz')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.member_name"
:label="__('Member')"
:disabled="true"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.score"
:label="__('Score')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.percentage"
:label="__('Percentage')"
:disabled="true"
/>
</div>
<div
v-for="row in submisisonDetails.doc.result"
class="border p-5 rounded-md space-y-4"
>
<div class="font-semibold">{{ row.idx }}. {{ row.question }}</div>
<div v-html="row.answer" class="leading-5"></div>
<div class="grid grid-cols-2 gap-5">
<FormControl v-model="row.marks" :label="__('Marks')" />
<FormControl
v-model="row.marks_out_of"
:label="__('Marks out of')"
:disabled="true"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
createDocumentResource,
Breadcrumbs,
FormControl,
Button,
Badge,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
})
const props = defineProps({
submission: {
type: String,
required: true,
},
})
const submisisonDetails = createDocumentResource({
doctype: 'LMS Quiz Submission',
name: props.submission,
auto: true,
})
const breadcrumbs = computed(() => {
return [
{
label: __('Quiz Submissions'),
route: {
name: 'QuizSubmissionList',
params: {
quizID: submisisonDetails.doc.quiz,
},
},
},
{
label: submisisonDetails.doc.quiz_title,
},
]
})
const saveSubmission = () => {
submisisonDetails.save.submit(
{},
{
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
</script>

View File

@@ -1,104 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="quizColumns"
:rows="submissions.data"
row-key="name"
:options="{ showTooltip: false, selectable: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in quizColumns">
</ListHeaderItem>
</ListHeader>
<ListRows>
<router-link
v-for="row in submissions.data"
:to="{
name: 'QuizSubmission',
params: {
submission: row.name,
},
}"
>
<ListRow :row="row" />
</router-link>
</ListRows>
</ListView>
</div>
</template>
<script setup>
import {
createListResource,
Breadcrumbs,
ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const submissions = createListResource({
doctype: 'LMS Quiz Submission',
filters: {
quiz: props.quizID,
},
fields: ['name', 'member_name', 'score', 'percentage', 'quiz_title'],
orderBy: 'creation desc',
auto: true,
})
const quizColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: 2,
},
{
label: __('Quiz'),
key: 'quiz_title',
width: 2,
},
{
label: __('Score'),
key: 'score',
width: 1,
align: 'center',
},
{
label: __('Percentage'),
key: 'percentage',
width: 1,
align: 'center',
},
]
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submissions') }]
})
</script>

View File

@@ -5,7 +5,7 @@
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <router-link
:to="{ :to="{
name: 'QuizForm', name: 'QuizCreation',
params: { params: {
quizID: 'new', quizID: 'new',
}, },
@@ -19,7 +19,7 @@
</Button> </Button>
</router-link> </router-link>
</header> </header>
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5"> <div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
<ListView <ListView
:columns="quizColumns" :columns="quizColumns"
:rows="quizzes.data" :rows="quizzes.data"
@@ -36,7 +36,7 @@
<router-link <router-link
v-for="row in quizzes.data" v-for="row in quizzes.data"
:to="{ :to="{
name: 'QuizForm', name: 'QuizCreation',
params: { params: {
quizID: row.name, quizID: row.name,
}, },
@@ -47,22 +47,6 @@
</ListRows> </ListRows>
</ListView> </ListView>
</div> </div>
<div
v-else
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No quizzes found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any quizzes yet. To create a new quiz, click on the "New Quiz" button above.'
)
}}
</div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
@@ -77,8 +61,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue' import { computed, inject, onMounted } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
@@ -140,13 +123,4 @@ const breadcrumbs = computed(() => {
}, },
] ]
}) })
const pageMeta = computed(() => {
return {
title: __('Quizzes'),
description: __('List of quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script> </script>

View File

@@ -79,15 +79,9 @@ const routes = [
}, },
{ {
name: 'ProfileEvaluator', name: 'ProfileEvaluator',
path: 'slots', path: 'evaluations',
component: () => import('@/pages/ProfileEvaluator.vue'), component: () => import('@/pages/ProfileEvaluator.vue'),
}, },
{
name: 'ProfileEvaluationSchedule',
path: 'schedule',
component: () =>
import('@/pages/ProfileEvaluationSchedule.vue'),
},
], ],
}, },
{ {
@@ -154,26 +148,8 @@ const routes = [
}, },
{ {
path: '/quizzes/:quizID', path: '/quizzes/:quizID',
name: 'QuizForm', name: 'QuizCreation',
component: () => import('@/pages/QuizForm.vue'), component: () => import('@/pages/QuizCreation.vue'),
props: true,
},
{
path: '/quiz/:quizID',
name: 'QuizPage',
component: () => import('@/pages/QuizPage.vue'),
props: true,
},
{
path: '/quiz-submissions/:quizID',
name: 'QuizSubmissionList',
component: () => import('@/pages/QuizSubmissionList.vue'),
props: true,
},
{
path: '/quiz-submission/:submission',
name: 'QuizSubmission',
component: () => import('@/pages/QuizSubmission.vue'),
props: true, props: true,
}, },
] ]

View File

@@ -17,7 +17,7 @@ export const sessionStore = defineStore('lms-session', () => {
} }
let user = ref(sessionUser()) let user = ref(sessionUser())
if (user.value) { if (user) {
allUsers.reload() allUsers.reload()
} }
const isLoggedIn = computed(() => !!user.value) const isLoggedIn = computed(() => !!user.value)

View File

@@ -1,12 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettings = defineStore('settings', () => {
const isSettingsOpen = ref(false)
const activeTab = ref(null)
return {
isSettingsOpen,
activeTab,
}
})

View File

@@ -62,7 +62,7 @@ export class CodeBox {
static get toolbox() { static get toolbox() {
const app = createApp({ const app = createApp({
render: () => h(Code, { size: 18, strokeWidth: 1.5, color: 'black' }), render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }),
}); });
const div = document.createElement('div'); const div = document.createElement('div');

View File

@@ -0,0 +1,32 @@
import Embed from '@editorjs/embed'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
export class CustomEmbed extends Embed {
render() {
const container = super.render()
const { service, source, embed } = this.data
if (service === 'youtube' || service === 'vimeo') {
// Remove the iframe or existing embed content
container.innerHTML = ''
// Create a placeholder element for Vue component
const vueContainer = document.createElement('div')
vueContainer.setAttribute('data-service', service)
vueContainer.setAttribute('data-video-id', this.data.source)
// Append the Vue placeholder
container.appendChild(vueContainer)
console.log(source)
// Mount the Vue component (using a global Vue instance)
const app = createApp(VideoBlock, {
file: source,
type: 'video/youtube',
})
app.mount(vueContainer)
}
return container
}
}

View File

@@ -57,15 +57,6 @@ export function formatNumberIntoCurrency(number, currency) {
return '' return ''
} }
// create a function that formats numbers in thousands to k
export function formatAmount(amount) {
if (amount > 999) {
return (amount / 1000).toFixed(1) + 'k'
}
return amount
}
export function convertToTitleCase(str) { export function convertToTitleCase(str) {
if (!str) { if (!str) {
return '' return ''
@@ -91,13 +82,10 @@ export function getFileSize(file_size) {
export function showToast(title, text, icon, iconClasses = null) { export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) { if (!iconClasses) {
if (icon == 'check') { iconClasses =
iconClasses = 'bg-green-600 text-white rounded-md p-px' icon == 'check'
} else if (icon == 'circle-warn') { ? 'bg-green-600 text-white rounded-md p-px'
iconClasses = 'bg-yellow-600 text-white rounded-md p-px' : 'bg-red-600 text-white rounded-md p-px'
} else {
iconClasses = 'bg-red-600 text-white rounded-md p-px'
}
} }
createToast({ createToast({
title: title, title: title,
@@ -161,9 +149,9 @@ export function getEditorTools() {
class: CodeBox, class: CodeBox,
config: { config: {
themeURL: themeURL:
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-dark.min.css', 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
themeName: 'atom-one-dark', themeName: 'atom-one-dark', // Optional
useDefaultTheme: 'dark', useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
}, },
}, },
list: { list: {
@@ -511,10 +499,3 @@ export function singularize(word) {
(r) => endings[r] (r) => endings[r]
) )
} }
export const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
}
}

View File

@@ -1,9 +1,7 @@
import QuizBlock from '@/components/QuizBlock.vue' import QuizBlock from '@/components/QuizBlock.vue'
import QuizPlugin from '@/components/QuizPlugin.vue' import { createApp } from 'vue'
import { createApp, h } from 'vue'
import { usersStore } from '../stores/user' import { usersStore } from '../stores/user'
import translationPlugin from '../translation' import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
export class Quiz { export class Quiz {
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
@@ -11,31 +9,17 @@ export class Quiz {
this.readOnly = readOnly this.readOnly = readOnly
} }
static get toolbox() {
const app = createApp({
render: () =>
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Quiz'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() { static get isReadOnlySupported() {
return true return true
} }
render() { render() {
this.wrapper = document.createElement('div') this.wrapper = document.createElement('div')
if (Object.keys(this.data).length) { if (this.data) {
this.renderQuiz(this.data.quiz) let renderedQuiz = this.renderQuiz(this.data.quiz)
} else { if (!this.readOnly) {
this.renderQuizModal() this.wrapper.innerHTML = renderedQuiz
}
} }
return this.wrapper return this.wrapper
} }
@@ -43,7 +27,7 @@ export class Quiz {
renderQuiz(quiz) { renderQuiz(quiz) {
if (this.readOnly) { if (this.readOnly) {
const app = createApp(QuizBlock, { const app = createApp(QuizBlock, {
quiz: quiz, quiz: quiz, // Pass quiz content as prop
}) })
app.use(translationPlugin) app.use(translationPlugin)
const { userResource } = usersStore() const { userResource } = usersStore()
@@ -51,23 +35,11 @@ export class Quiz {
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} }
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'> return `<div class='border rounded-md p-10 text-center mb-2'>
<span class="font-medium"> <span class="font-medium">
Quiz: ${quiz} Quiz: ${quiz}
</span> </span>
</div>` </div>`
return
}
renderQuizModal() {
const app = createApp(QuizPlugin, {
onQuizAddition: (quiz) => {
this.data.quiz = quiz
this.renderQuiz(quiz)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
} }
save(blockContent) { save(blockContent) {

View File

@@ -1,9 +1,6 @@
import AudioBlock from '@/components/AudioBlock.vue' import AudioBlock from '@/components/AudioBlock.vue'
import VideoBlock from '@/components/VideoBlock.vue' import VideoBlock from '@/components/VideoBlock.vue'
import UploadPlugin from '@/components/UploadPlugin.vue' import { createApp } from 'vue'
import { h, createApp } from 'vue'
import { Upload as UploadIcon } from 'lucide-vue-next'
import translationPlugin from '../translation'
export class Upload { export class Upload {
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
@@ -11,38 +8,17 @@ export class Upload {
this.readOnly = readOnly this.readOnly = readOnly
} }
static get toolbox() {
const app = createApp({
render: () =>
h(UploadIcon, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: 'Upload',
icon: div.innerHTML,
}
}
static get isReadOnlySupported() { static get isReadOnlySupported() {
return true return true
} }
render() { render() {
this.wrapper = document.createElement('div') this.wrapper = document.createElement('div')
this.renderUpload(this.data)
if (this.data && this.data.file_url) {
this.renderFile(this.data)
} else {
this.renderFileUploader()
}
return this.wrapper return this.wrapper
} }
renderFile(file) { renderUpload(file) {
if (this.isVideo(file.file_type)) { if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, { const app = createApp(VideoBlock, {
file: file.file_url, file: file.file_url,
@@ -56,11 +32,9 @@ export class Upload {
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} else if (file.file_type == 'PDF') { } else if (file.file_type == 'PDF') {
this.wrapper.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${ this.wrapper.innerHTML = `<iframe src="${encodeURI(
window.location.origin
}${encodeURI(
file.file_url file.file_url
)}&embedded=true" width='100%' height='700px' class="mb-4" type="application/pdf"></iframe>` )}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
return return
} else { } else {
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI( this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
@@ -70,25 +44,6 @@ export class Upload {
} }
} }
renderFileUploader() {
const app = createApp(UploadPlugin, {
onFileUploaded: (file) => {
this.data.file_url = file.file_url
this.data.file_type = file.file_type
this.renderFile(file)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
validate(savedData) {
if (!savedData.file_url || !savedData.file_type) {
return false
}
return true
}
save(blockContent) { save(blockContent) {
return { return {
file_url: this.data.file_url, file_url: this.data.file_url,

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.11.0" __version__ = "2.4.0"

0
lms/config/__init__.py Normal file
View File

13
lms/config/desktop.py Normal file
View File

@@ -0,0 +1,13 @@
from frappe import _
def get_data():
return [
{
"module_name": "Community",
"color": "grey",
"icon": "octicon octicon-file-directory",
"type": "module",
"label": _("Community"),
}
]

12
lms/config/docs.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Configuration for docs
"""
# source_link = "https://github.com/[org_name]/community"
# docs_base_url = "https://[org_name].github.io/community"
# headline = "App that does everything"
# sub_heading = "Yes, you got that right the first time, everything"
def get_context(context):
context.brand_html = "Community"

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
[
{
"category": "Web Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:16.841571",
"name": "Web Development"
},
{
"category": "Business",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:32.304850",
"name": "Business"
},
{
"category": "Design",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:12.621022",
"name": "Design"
},
{
"category": "Personal Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:19.287404",
"name": "Personal Development"
},
{
"category": "Finance",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:28.579714",
"name": "Finance"
},
{
"category": "Frontend",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-05-08 14:05:16.979275",
"name": "Frontend"
},
{
"category": "Framework",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2023-06-15 18:01:41.598282",
"name": "Framework"
}
]

View File

@@ -110,13 +110,12 @@ doc_events = {
# --------------- # ---------------
scheduler_events = { scheduler_events = {
"hourly": [ "hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals", "lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
"lms.lms.api.update_course_statistics",
], ],
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"], "daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
} }
fixtures = ["Custom Field", "Function", "Industry", "LMS Category"] fixtures = ["Custom Field", "Function", "Industry"]
# Testing # Testing
# ------- # -------
@@ -186,7 +185,6 @@ jinja = {
"lms.lms.utils.get_lesson_url", "lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url", "lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette", "lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor",
], ],
"filters": [], "filters": [],
} }

View File

@@ -1,12 +1,10 @@
import frappe import frappe
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
from lms.lms.api import give_dicussions_permission
def after_install(): def after_install():
add_pages_to_nav() add_pages_to_nav()
create_batch_source() create_batch_source()
give_dicussions_permission()
def after_sync(): def after_sync():

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests import UnitTestCase from frappe.tests.utils import FrappeTestCase
class TestLMSJobApplication(UnitTestCase): class TestLMSJobApplication(FrappeTestCase):
pass pass

View File

@@ -1,15 +1,12 @@
"""API methods for the LMS. """API methods for the LMS.
""" """
import json
import frappe import frappe
from frappe.translate import get_all_translations from frappe.translate import get_all_translations
from frappe import _ from frappe import _
from frappe.query_builder import DocType from frappe.query_builder import DocType
from frappe.query_builder.functions import Count from frappe.query_builder.functions import Count
from frappe.utils import time_diff, now_datetime, get_datetime, flt from frappe.utils import time_diff, now_datetime, get_datetime
from typing import Optional
from lms.lms.utils import get_average_rating, get_lesson_count
@frappe.whitelist() @frappe.whitelist()
@@ -291,17 +288,11 @@ def get_file_info(file_url):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_branding(): def get_branding():
"""Get branding details.""" """Get branding details."""
website_settings = frappe.get_single("Website Settings") return {
image_fields = ["banner_image", "footer_logo", "favicon"] "brand_name": frappe.db.get_single_value("Website Settings", "app_name"),
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
for field in image_fields: "favicon": frappe.db.get_single_value("Website Settings", "favicon"),
if website_settings.get(field): }
file_info = get_file_info(website_settings.get(field))
website_settings.update({field: json.loads(json.dumps(file_info))})
else:
website_settings.update({field: None})
return website_settings
@frappe.whitelist() @frappe.whitelist()
@@ -328,7 +319,7 @@ def get_evaluator_details(evaluator):
) )
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}): if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
doc = frappe.get_doc("Course Evaluator", evaluator) doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
else: else:
doc = frappe.new_doc("Course Evaluator") doc = frappe.new_doc("Course Evaluator")
doc.evaluator = evaluator doc.evaluator = evaluator
@@ -492,15 +483,7 @@ def delete_sidebar_item(webpage):
@frappe.whitelist() @frappe.whitelist()
def delete_lesson(lesson, chapter): def delete_lesson(lesson, chapter):
# Delete Reference frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
chapter = frappe.get_doc("Course Chapter", chapter)
chapter.lessons = [row for row in chapter.lessons if row.lesson != lesson]
chapter.save()
# Delete progress
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
# Delete Lesson
frappe.db.delete("Course Lesson", lesson) frappe.db.delete("Course Lesson", lesson)
@@ -590,17 +573,14 @@ def get_members(start=0, search=""):
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
if search: if search:
or_filters["full_name"] = ["like", f"%{search}%"] filters["full_name"] = ["like", f"%{search}%"]
or_filters["email"] = ["like", f"%{search}%"]
members = frappe.get_all( members = frappe.get_all(
"User", "User",
filters=filters, filters=filters,
fields=["name", "full_name", "user_image", "username", "last_active"], fields=["name", "full_name", "user_image", "username"],
or_filters=or_filters,
page_length=20, page_length=20,
start=start, start=start,
) )
@@ -630,249 +610,3 @@ def check_app_permission():
return True return True
return False return False
@frappe.whitelist()
def save_evaluation_details(
member,
course,
batch_name,
evaluator,
date,
start_time,
end_time,
status,
rating,
summary,
):
"""
Save evaluation details for a member against a course.
"""
evaluation = frappe.db.exists(
"LMS Certificate Evaluation", {"member": member, "course": course}
)
details = {
"date": date,
"start_time": start_time,
"end_time": end_time,
"status": status,
"rating": rating / 5,
"summary": summary,
"batch_name": batch_name,
}
if evaluation:
frappe.db.set_value("LMS Certificate Evaluation", evaluation, details)
return evaluation
else:
doc = frappe.new_doc("LMS Certificate Evaluation")
details.update(
{
"member": member,
"course": course,
"evaluator": evaluator,
}
)
doc.update(details)
doc.insert()
return doc.name
@frappe.whitelist()
def save_certificate_details(
member,
course,
batch_name,
evaluator,
issue_date,
expiry_date,
template,
published=True,
):
"""
Save certificate details for a member against a course.
"""
certificate = frappe.db.exists("LMS Certificate", {"member": member, "course": course})
details = {
"published": published,
"issue_date": issue_date,
"expiry_date": expiry_date,
"template": template,
"batch_name": batch_name,
}
if certificate:
frappe.db.set_value("LMS Certificate", certificate, details)
return certificate
else:
doc = frappe.new_doc("LMS Certificate")
details.update(
{
"member": member,
"course": course,
"evaluator": evaluator,
}
)
doc.update(details)
doc.insert()
return doc.name
@frappe.whitelist()
def delete_documents(doctype, documents):
frappe.only_for("Moderator")
for doc in documents:
frappe.delete_doc(doctype, doc)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
fields = []
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
if gateway.gateway_controller is None:
try:
data = frappe.get_doc(f"{payment_gateway} Settings").as_dict()
meta = frappe.get_meta(f"{payment_gateway} Settings").fields
doctype = f"{payment_gateway} Settings"
docname = f"{payment_gateway} Settings"
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
else:
try:
data = frappe.get_doc(gateway.gateway_settings, gateway.gateway_controller).as_dict()
meta = frappe.get_meta(gateway.gateway_settings).fields
doctype = gateway.gateway_settings
docname = gateway.gateway_controller
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
for row in meta:
if row.fieldtype not in ["Column Break", "Section Break"]:
if row.fieldtype in ["Attach", "Attach Image"]:
fieldtype = "Upload"
data[row.fieldname] = get_file_info(data.get(row.fieldname))
else:
fieldtype = row.fieldtype
fields.append(
{
"label": row.label,
"name": row.fieldname,
"type": fieldtype,
}
)
return {
"fields": fields,
"data": data,
"doctype": doctype,
"docname": docname,
}
def update_course_statistics():
courses = frappe.get_all("LMS Course", fields=["name"])
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count(
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
)
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
frappe.db.set_value(
"LMS Course",
course.name,
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
)
@frappe.whitelist()
def get_announcements(batch):
return frappe.get_all(
"Communication",
filters={
"reference_doctype": "LMS Batch",
"reference_name": batch,
},
fields=[
"subject",
"content",
"recipients",
"cc",
"communication_date",
"sender",
"sender_full_name",
],
order_by="communication_date desc",
)
@frappe.whitelist()
def delete_course(course):
chapters = frappe.get_all("Course Chapter", {"course": course}, pluck="name")
chapter_references = frappe.get_all(
"Chapter Reference", {"parent": course}, pluck="name"
)
for chapter in chapters:
lessons = frappe.get_all("Course Lesson", {"chapter": chapter}, pluck="name")
lesson_references = frappe.get_all(
"Lesson Reference", {"parent": chapter}, pluck="name"
)
for lesson in lesson_references:
frappe.delete_doc("Lesson Reference", lesson)
for lesson in lessons:
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
topics = frappe.get_all(
"Discussion Topic",
{"reference_doctype": "Course Lesson", "reference_docname": lesson},
pluck="name",
)
for topic in topics:
frappe.db.delete("Discussion Reply", {"topic": topic})
frappe.db.delete("Discussion Topic", topic)
frappe.delete_doc("Course Lesson", lesson)
for chapter in chapter_references:
frappe.delete_doc("Chapter Reference", chapter)
for chapter in chapters:
frappe.delete_doc("Course Chapter", chapter)
frappe.db.delete("LMS Enrollment", {"course": course})
frappe.delete_doc("LMS Course", course)
def give_dicussions_permission():
doctypes = ["Discussion Topic", "Discussion Reply"]
roles = ["LMS Student", "Course Creator", "Moderator", "Batch Evaluator"]
for doctype in doctypes:
for role in roles:
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role}):
frappe.get_doc(
{
"doctype": "Custom DocPerm",
"parent": doctype,
"role": role,
"read": 1,
"write": 1,
"create": 1,
"delete": 1,
}
).save(ignore_permissions=True)

View File

@@ -7,3 +7,17 @@ from frappe.model.document import Document
class BatchStudent(Document): class BatchStudent(Document):
pass pass
@frappe.whitelist()
def enroll_batch(batch_name):
if frappe.db.exists(
"Batch Student", {"student": frappe.session.user, "parent": batch_name}
):
frappe.throw("You are already enrolled in this batch")
enrollment = frappe.new_doc("Batch Student")
enrollment.student = frappe.session.user
enrollment.parent = batch_name
enrollment.parentfield = "students"
enrollment.parenttype = "LMS Batch"
enrollment.save(ignore_permissions=True)

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests import UnitTestCase from frappe.tests.utils import FrappeTestCase
class TestBatchStudent(UnitTestCase): class TestBatchStudent(FrappeTestCase):
pass pass

View File

@@ -9,8 +9,9 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"course", "course",
"column_break_3",
"title", "title",
"column_break_3",
"description",
"section_break_5", "section_break_5",
"lessons" "lessons"
], ],
@@ -34,6 +35,11 @@
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{ {
"fieldname": "section_break_5", "fieldname": "section_break_5",
"fieldtype": "Section Break" "fieldtype": "Section Break"
@@ -53,7 +59,7 @@
"link_fieldname": "chapter" "link_fieldname": "chapter"
} }
], ],
"modified": "2024-10-29 16:54:20.904683", "modified": "2023-09-29 17:03:58.013819",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Chapter", "name": "Course Chapter",

View File

@@ -1,27 +1,11 @@
# Copyright (c) 2021, FOSS United and contributors # Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt # For license information, please see license.txt
import frappe # import frappe
from frappe.model.document import Document from frappe.model.document import Document
from lms.lms.utils import get_course_progress from frappe.utils.telemetry import capture
from lms.lms.api import update_course_statistics
class CourseChapter(Document): class CourseChapter(Document):
def on_update(self): def after_insert(self):
self.recalculate_course_progress() capture("chapter_created", "lms")
update_course_statistics()
def recalculate_course_progress(self):
previous_lessons = (
self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
)
current_lessons = self.lessons
if previous_lessons and previous_lessons != current_lessons:
enrolled_members = frappe.get_all(
"LMS Enrollment", {"course": self.course}, ["member", "name"]
)
for enrollment in enrolled_members:
new_progress = get_course_progress(self.course, enrollment.member)
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", new_progress)

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests import UnitTestCase from frappe.tests.utils import FrappeTestCase
class TestCourseEvaluator(UnitTestCase): class TestCourseEvaluator(FrappeTestCase):
pass pass

View File

@@ -1,4 +1,148 @@
// Copyright (c) 2021, FOSS United and contributors // Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on("Course Lesson", {}); frappe.ui.form.on("Course Lesson", {
setup: function (frm) {
frm.trigger("setup_help");
},
setup_help(frm) {
let quiz_link = `<a href="/app/lms-quiz"> ${__("Quiz List")} </a>`;
let exercise_link = `<a href="/app/lms-exercise"> ${__(
"Exercise List"
)} </a>`;
let file_link = `<a href="/app/file"> ${__("File DocType")} </a>`;
frm.get_field("help").html(`
<p>${__(
"You can add some more additional content to the lesson using a special syntax. The table below mentions all types of dynamic content that you can add to the lessons and the syntax for the same."
)}</p>
<table class="table">
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
<th style="width: 20%;">
${__("Content Type")}
</th>
<th style="width: 40%;">
${__("Syntax")}
</th>
<th>
${__("Description")}
</th>
</tr>
<tr>
<td>
${__("YouTube Video")}
</td>
<td>
{{ YouTubeVideo("unique_embed_id") }}
</td>
<td>
<span>
${__(
"Copy and paste the syntax in the editor. Replace 'embed_src' with the embed source that YouTube provides. To get the source, follow the steps mentioned below."
)}
</span>
<ul class="p-4">
<li>
${__("Upload the video on youtube.")}
</li>
<li>
${__(
"When you share a youtube video, it shows an option called Embed."
)}
</li>
<li>
${__(
"On clicking it, it provides an iframe. Copy the source (src) of the iframe and paste it here."
)}
</li>
</ul>
</td>
</tr>
<tr>
<td>
${__("Quiz")}
</td>
<td>
{{ Quiz("lms_quiz_id") }}
</td>
<td>
${__(
"Copy and paste the syntax in the editor. Replace 'lms_quiz_id' with the ID of the Quiz you want to add. You can get the ID of the quiz from the {0}.",
[quiz_link]
)}
</td>
</tr>
<tr>
<td>
${__("Video")}
</td>
<td>
{{ Video("url_of_source") }}
</td>
<td>
${__(
"Upload a video from your local machine to the {0}. Copy and paste this syntax in the editor. Replace 'url_of_source' with the File URL field of the document you created in the File DocType.",
[file_link]
)}
</td>
</tr>
<tr>
<td>
${"Exercise"}
</td>
<td>
{{ Exercise("exercise_id") }}
</td>
<td>
${__(
"Copy and paste the syntax in the editor. Replace 'exercise_id' with the ID of the Exercise you want to add. You can get the ID of the exercise from the {0}.",
[exercise_link]
)}
</td>
</tr>
<tr>
<td>
${__("Assignment")}
</td>
<td>
{{ Assignment("id-filetype") }}
</td>
</tr>
</table>
<hr>
<table class="table">
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
<th style="width: 90%">
${__("Supported File Types for Assignment")}
</th>
<th>
${__("Syntax")}
</th>
</tr>
<tr>
<td>
.doc, .docx, .xml
<td>
${__("Document")}
</td>
</tr>
<tr>
<td>
.pdf
</td>
<td>
${__("PDF")}
</td>
</tr>
<tr>
<td>
.png, .jpg, .jpeg
</td>
<td>
${__("Image")}
</td>
</tr>
</table>
`);
},
});

Some files were not shown because too many files have changed in this diff Show More