feat: lesson creation

This commit is contained in:
Jannat Patel
2024-03-05 23:07:58 +05:30
parent b9f6a23412
commit 0ce7c74778
11 changed files with 409 additions and 155 deletions

View File

@@ -7,7 +7,7 @@
<div class="text-lg font-semibold">
{{ __(title) }}
</div>
<Button @click="openChapterModal()">
<Button v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }}
</Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
@@ -25,7 +25,7 @@
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton ref="" class="flex w-full px-2 py-4">
<DisclosureButton ref="" class="flex w-full px-2 py-3">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
@@ -34,20 +34,16 @@
}"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
/>
<div class="text-base text-left font-medium">
<div class="text-base text-left font-medium leading-5">
{{ chapter.title }}
</div>
<div class="ml-auto text-sm">
{{ chapter.lessons.length }}
{{ chapter.lessons.length == 1 ? __('lesson') : __('lessons') }}
</div>
</DisclosureButton>
<DisclosurePanel class="pb-2">
<div v-for="lesson in chapter.lessons" :key="lesson.name">
<div class="outline-lesson pl-8 py-2">
<router-link
:to="{
name: 'Lesson',
name: allowEdit ? 'CreateLesson' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
@@ -55,7 +51,7 @@
},
}"
>
<div class="flex items-center text-sm">
<div class="flex items-center text-sm leading-5">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"

View File

@@ -3,7 +3,7 @@
v-model="show"
:options="{
title: __('Create a Batch'),
size: 'xl',
size: '3xl',
actions: [
{
label: __('Save'),
@@ -15,14 +15,19 @@
>
<template #body-content>
<div>
<FormControl v-model="batch.title" :label="__('Title')" class="mb-4" />
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
class="mb-4"
/>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-3 gap-4">
<div>
<FormControl
v-model="batch.title"
:label="__('Title')"
class="mb-4"
/>
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
</div>
<div>
<FormControl
v-model="batch.start_date"
@@ -52,19 +57,7 @@
/>
</div>
</div>
<div class="grid grid-cols-2">
<div>
<FormControl
v-model="batch.medium"
:label="__('Medium')"
class="mb-4"
/>
<FormControl
v-model="batch.category"
:label="__('Category')"
class="mb-4"
/>
</div>
<div class="grid grid-cols-3 gap-4 mt-4 border-t pt-4">
<div>
<FormControl
v-model="batch.seat_count"
@@ -79,68 +72,103 @@
class="mb-4"
/>
</div>
<div>
<FormControl
v-model="batch.medium"
:label="__('Medium')"
class="mb-4"
/>
<FormControl
v-model="batch.category"
:label="__('Category')"
class="mb-4"
/>
</div>
<div>
<FileUploader
v-if="!batch.meta_image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="
(file) => {
batch.meta_image.value = file
}
"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
</div>
</div>
<div class="border-t pt-4 mb-4">
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<FormControl
v-model="batch.amount"
:label="__('Amount')"
type="number"
class="my-4"
/>
<Link
doctype="Currency"
v-model="batch.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
<div class="grid grid-cols-2 gap-4 border-y pt-4 mb-4"></div>
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
class="mb-4"
/>
<TextEditor
v-model="batch.batch_details"
:label="__('Batch Details')"
class="mb-4"
/>
<div>
<label class="block text-sm text-gray-600 mb-1">
{{ __('Batch Details') }}
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = 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] mb-4"
/>
</div>
<FormControl
v-model="batch_details.raw"
:label="__('Batch Details')"
v-model="batch.batch_details_raw"
:label="__('Batch Details Raw')"
type="textarea"
class="mb-4"
/>
<FileUploader
v-if="!image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="
(file) => {
image = file
}
"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
</Button>
</div>
</template>
</FileUploader>
<div>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
class="mb-4"
/>
<FormControl
v-model="batch.amount"
:label="__('Amount')"
type="number"
class="mb-4"
/>
<Link
doctype="Currency"
v-model="course.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, TextEditor, FileUploader, Link } from 'frappe-ui'
import {
Dialog,
FormControl,
TextEditor,
FileUploader,
Button,
} from 'frappe-ui'
import { reactive, defineModel } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const batch = reactive({
title: '',

View File

@@ -15,7 +15,7 @@
</header>
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
<div class="border-r-2">
<Tabs class="overflow-hidden" v-model="tabIndex" :tabs="tabs">
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
<template #tab="{ tab, selected }" class="overflow-x-hidden">
<div>
<button

View File

@@ -37,8 +37,8 @@
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
<div class="">
<div
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 mt-6"
v-html="batch.data.batch_details"
class="batch-description"
></div>
</div>
<div>
@@ -46,7 +46,7 @@
</div>
</div>
<div>
<div class="text-2xl font-semibold">
<div class="text-2xl font-semibold mt-10">
{{ __('Courses') }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">

View File

@@ -8,7 +8,7 @@
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex">
<Button variant="solid">
<Button variant="solid" @click="openBatchModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -73,14 +73,18 @@
</Tabs>
</div>
</div>
<BatchCreation v-model="showBatchModal" />
</template>
<script setup>
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed } from 'vue'
import BatchCreation from '@/components/Modals/BatchCreation.vue'
const user = inject('$user')
const showBatchModal = ref(false)
const batches = createListResource({
doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches',
@@ -116,4 +120,8 @@ if (user.data) {
count: computed(() => batches.data?.enrolled?.length),
})
}
const openBatchModal = () => {
showBatchModal.value = true
}
</script>

View File

@@ -321,6 +321,7 @@ const courseCreationResource = createResource({
})
const submitCourse = () => {
console.log(courseResource.doc?.modified)
if (courseResource.doc) {
courseResource.setValue.submit(
{
@@ -331,8 +332,11 @@ const submitCourse = () => {
validate() {
return validateMandatoryFields()
},
onSuccess() {
showToast('Success', 'Course updated successfully', 'check')
},
onError(err) {
showToast(err)
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
@@ -341,6 +345,9 @@ const submitCourse = () => {
validate() {
return validateMandatoryFields()
},
onSuccess() {
showToast('Success', 'Course created successfully', 'check')
},
onError(err) {
showToast(err)
},
@@ -392,14 +399,17 @@ const removeTag = (tag) => {
newTag.value = ''
}
const showToast = (err) => {
const showToast = (title, text, icon) => {
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,
title: title,
text: text,
icon: icon,
iconClasses:
icon == 'check'
? 'bg-green-600 text-white rounded-md p-px'
: 'bg-red-600 text-white rounded-md p-px',
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: icon == 'check' ? 5 : 10,
})
}

View File

@@ -5,9 +5,14 @@
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="w-7/12 mx-auto pt-5">
<div class="text-lg font-semibold mb-5">
{{ __('Lesson Details') }}
<div class="w-7/12 mx-auto py-5">
<div class="flex items-center justify-between mb-5">
<div class="text-lg font-semibold">
{{ __('Lesson Details') }}
</div>
<Button variant="solid" @click="saveLesson()">
{{ __('Save') }}
</Button>
</div>
<FormControl v-model="lesson.title" label="Title" class="mb-4" />
<FormControl
@@ -15,24 +20,40 @@
type="checkbox"
label="Include in Preview"
/>
<div class="mt-4">
<label class="block text-xs text-gray-600 mb-1">
{{ __('Instructor Notes') }}
</label>
<div id="instructor-notes" class="border rounded-md px-10 py-3"></div>
</div>
<div class="mt-4">
<label class="block text-xs text-gray-600 mb-1">
{{ __('Content') }}
</label>
<div id="content" class="border rounded-md px-10 py-3"></div>
<div id="content" class="border rounded-md py-3"></div>
</div>
</div>
</div>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
import { computed, reactive, onMounted } from 'vue'
import {
Breadcrumbs,
FormControl,
createResource,
Button,
createDocumentResource,
} from 'frappe-ui'
import { computed, reactive, onMounted, onBeforeMount } from 'vue'
import EditorJS from '@editorjs/editorjs'
import Header from '@editorjs/header'
import Paragraph from '@editorjs/paragraph'
import List from '@editorjs/list'
import Embed from '@editorjs/embed'
import YouTubeVideo from '../utils/youtube.js'
import { createToast } from '../utils'
let editor
let editLessonResource
const props = defineProps({
courseName: {
@@ -49,47 +70,56 @@ const props = defineProps({
},
})
onMounted(
() =>
new EditorJS({
holder: 'content',
tools: {
header: Header,
youtube: YouTubeVideo,
paragraph: {
class: Paragraph,
inlineToolbar: true,
config: {
preserveBlank: true,
},
},
list: List,
embed: {
class: Embed,
config: {
services: {
youtube: true,
vimeo: true,
codepen: true,
slides: {
regex:
/https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
embedUrl:
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
html: "<iframe width='100%' height='300' frameborder='0' allowfullscreen='true'></iframe>",
},
},
onMounted(() => {
editor = renderEditor('content')
/* renderEditor('instructor-notes') */
})
const renderEditor = (holder) => {
return new EditorJS({
holder: holder,
tools: getEditorTools(),
})
}
const getEditorTools = () => {
return {
header: Header,
youtube: YouTubeVideo,
paragraph: {
class: Paragraph,
inlineToolbar: true,
config: {
preserveBlank: true,
},
},
list: List,
embed: {
class: Embed,
config: {
services: {
youtube: true,
vimeo: true,
codepen: true,
slides: {
regex:
/https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
embedUrl:
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
html: "<iframe width='100%' height='300' frameborder='0' allowfullscreen='true'></iframe>",
},
},
},
})
)
},
}
}
const lesson = reactive({
title: '',
include_in_preview: false,
body: '',
body: 'Test',
instructor_notes: '',
content: '',
})
const lessonDetails = createResource({
@@ -100,8 +130,136 @@ const lessonDetails = createResource({
lesson: props.lessonNumber,
},
auto: true,
onSuccess(data) {
if (data.lesson) {
createEditResource(data)
}
},
})
const newLessonResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Course Lesson',
course: props.courseName,
chapter: lessonDetails.data?.chapter.name,
...lesson,
},
}
},
})
const createEditResource = (data) => {
editLessonResource = createDocumentResource({
doctype: 'Course Lesson',
name: data.lesson,
auto: true,
onSuccess(data) {
Object.keys(data).forEach((key) => {
lesson[key] = data[key]
})
lesson.include_in_preview = data.include_in_preview ? true : false
console.log(editor)
console.log(editor.isReady)
editor.isReady.then(() => {
editor.render(JSON.parse(data.content))
})
},
})
}
const lessonReference = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Lesson Reference',
parent: lessonDetails.data?.chapter.name,
parenttype: 'Course Chapter',
parentfield: 'lessons',
lesson: values.lesson,
idx: props.lessonNumber,
},
}
},
})
const saveLesson = () => {
editor.save().then((outputData) => {
lesson.content = JSON.stringify(outputData)
console.log(editLessonResource?.doc?.modified)
if (editLessonResource?.doc) {
editLessonResource.setValue.submit(
{
...lesson,
},
{
validate() {
return validateLesson()
},
onSuccess() {
showToast('Success', 'Lesson updated successfully', 'check')
},
onError(err) {
showToast('Error', err.message, 'x')
},
}
)
} else {
createNewLesson()
}
})
}
const createNewLesson = () => {
newLessonResource.submit(
{},
{
validate() {
return validateLesson()
},
onSuccess(data) {
lessonReference.submit(
{ lesson: data.name },
{
onSuccess() {
showToast('Success', 'Lesson created successfully', 'check')
},
}
)
},
onError(err) {
showToast('Error', err.message, 'x')
},
}
)
}
const validateLesson = () => {
if (!lesson.title) {
return 'Title is required'
}
if (!lesson.content) {
return 'Content is required'
}
}
const showToast = (title, text, icon) => {
createToast({
title: title,
text: text,
icon: icon,
iconClasses:
icon == 'check'
? 'bg-green-600 text-white rounded-md p-px'
: 'bg-red-600 text-white rounded-md p-px',
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: icon == 'check' ? 5 : 10,
})
}
const breadcrumbs = computed(() => {
let crumbs = [
{
@@ -114,8 +272,21 @@ const breadcrumbs = computed(() => {
},
]
if (editLessonResource?.doc) {
crumbs.push({
label: editLessonResource.doc.title,
route: {
name: 'Lesson',
params: {
courseName: props.courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
},
})
}
crumbs.push({
label: 'Create Lesson',
label: editLessonResource?.doc ? 'Edit Lesson' : 'Create Lesson',
route: {
name: 'CreateLesson',
params: {

View File

@@ -27,7 +27,7 @@
<div class="text-3xl font-semibold">
{{ lesson.data.title }}
</div>
<div>
<div class="flex items-center">
<router-link
v-if="lesson.data.prev"
:to="{
@@ -40,7 +40,27 @@
}"
>
<Button class="mr-2">
<ChevronLeft class="w-4 h-4 stroke-1" />
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'CreateLesson',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button class="mr-2">
{{ __('Edit') }}
</Button>
</router-link>
<router-link
@@ -55,7 +75,12 @@
}"
>
<Button>
<ChevronRight class="w-4 h-4 stroke-1" />
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
</div>
@@ -86,6 +111,18 @@
</span>
</div>
<div
v-if="lesson.data.content"
v-for="content in JSON.parse(lesson.data.content).blocks"
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 mt-6"
>
<div v-if="content.type == 'paragraph'">
<div>
{{ content.data.text }}
</div>
</div>
</div>
<div
v-else
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-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 mt-6"
>
<div v-if="lesson.data.youtube">
@@ -346,6 +383,16 @@ const allowDiscussions = () => {
user.data?.is_instructor
)
}
const allowEdit = () => {
if (user.data?.is_instructor) {
return true
}
if (lesson.data?.instructor.includes(user.data?.name)) {
return true
}
return false
}
</script>
<style>
.avatar-group {

View File

@@ -197,13 +197,13 @@ const courseCompletion = createResource({
const signupChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'New Signups'
options.borderColor = '#278F5E'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#B6DEC5')
gradient.addColorStop(0.5, '#E4F5E9')
gradient.addColorStop(1, '#F3FCF5')
gradient.addColorStop(0, '#4563f0')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
@@ -213,13 +213,13 @@ const signupChartOptions = () => {
const enrollmentChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Course Enrollments'
options.borderColor = '#B52A2A'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#FFC6A5')
gradient.addColorStop(0.5, '#FFD8C5')
gradient.addColorStop(1, '#FFE9E5')
gradient.addColorStop(0, '#4563f0')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
@@ -229,13 +229,13 @@ const enrollmentChartOptions = () => {
const lessonChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Lesson Completion'
options.borderColor = '#0070CC'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#B6DEC5')
gradient.addColorStop(0.5, '#E4F5E9')
gradient.addColorStop(1, '#F3FCF5')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
@@ -245,7 +245,7 @@ const lessonChartOptions = () => {
const courseChartOptions = () => {
let options = chartOptions(true)
options.plugins.title.text = 'Course Completion'
options.backgroundColor = ['#E4521B', '#FEEB65']
options.backgroundColor = ['#4563f0', '#f683ae']
return options
}

View File

@@ -20,7 +20,6 @@ const routes = [
props: true,
},
{
// Create a route for path /courses/inventory-management/learn/1.1
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber',
name: 'Lesson',
component: () => import('@/pages/Lesson.vue'),

View File

@@ -1338,6 +1338,7 @@ def get_lesson(course, chapter, lesson):
"file_type",
"instructor_notes",
"course",
"content",
],
as_dict=True,
)
@@ -1756,16 +1757,10 @@ def get_lesson_creation_details(course, chapter, lesson):
"Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson"
)
if lesson_name:
lesson_details = frappe.db.get_value(
"Course Lesson",
lesson_name,
["name", "title", "body", "instructor_notes", "include_in_preview"],
as_dict=True,
)
return {
"course_title": frappe.db.get_value("LMS Course", course, "title"),
"chapter_title": frappe.db.get_value("Course Chapter", chapter_name, "title"),
"lesson_title": lesson_details if lesson_name else None,
"chapter": frappe.db.get_value(
"Course Chapter", chapter_name, ["title", "name"], as_dict=True
),
"lesson": lesson_name if lesson_name else None,
}