feat: chapter creation

This commit is contained in:
Jannat Patel
2024-03-04 22:10:51 +05:30
parent e863abe37c
commit 9ae96bd1fa
16 changed files with 4703 additions and 52 deletions

View File

@@ -8,10 +8,16 @@
"preview": "vite preview"
},
"dependencies": {
"@editorjs/checklist": "^1.6.0",
"@editorjs/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1",
"@editorjs/list": "^1.9.0",
"@editorjs/paragraph": "^2.11.3",
"chart.js": "^4.4.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.22",
"frappe-ui": "^0.1.31",
"lucide-vue-next": "^0.309.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",

View File

@@ -1,12 +1,15 @@
<template>
<div class="text-base">
<div
v-if="showHeader && outline.data?.length"
class="flex justify-between mb-4"
v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between mb-4"
>
<div class="text-2xl font-semibold">
{{ __('Course Content') }}
<div class="text-lg font-semibold">
{{ __(title) }}
</div>
<Button @click="openChapterModal()">
{{ __('Add Chapter') }}
</Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
@@ -20,7 +23,7 @@
v-slot="{ open }"
v-for="(chapter, index) in outline.data"
:key="chapter.name"
:defaultOpen="openChapter(chapter.idx)"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton ref="" class="flex w-full px-2 py-4">
<ChevronRight
@@ -41,7 +44,7 @@
</DisclosureButton>
<DisclosurePanel class="pb-2">
<div v-for="lesson in chapter.lessons" :key="lesson.name">
<div class="outline-lesson py-2 pl-8">
<div class="outline-lesson pl-8 py-2">
<router-link
:to="{
name: 'Lesson',
@@ -70,13 +73,38 @@
</router-link>
</div>
</div>
<div v-if="allowEdit" class="flex mt-2 pl-8">
<router-link
:to="{
name: 'CreateLesson',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
<Button class="ml-2" @click="openChapterModal(chapter)">
{{ __('Edit Chapter') }}
</Button>
</div>
</DisclosurePanel>
</Disclosure>
</div>
</div>
<ChapterModal
v-model="showChapterModal"
v-model:outline="outline"
:course="courseName"
:chapterDetail="getCurrentChapter()"
/>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
@@ -86,9 +114,13 @@ import {
FileText,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
const route = useRoute()
const expandAll = ref(true)
const showChapterModal = ref(false)
const currentChapter = ref(null)
const props = defineProps({
courseName: {
type: String,
@@ -98,7 +130,11 @@ const props = defineProps({
type: Boolean,
default: false,
},
showHeader: {
title: {
type: String,
default: '',
},
allowEdit: {
type: Boolean,
default: false,
},
@@ -113,12 +149,17 @@ const outline = createResource({
auto: true,
})
const openChapter = (index) => {
const openChapterDetail = (index) => {
return index == route.params.chapterNumber || index == 1
}
const expandAllChapters = () => {
expandAll.value = !expandAll.value
const openChapterModal = (chapter = null) => {
currentChapter.value = chapter
showChapterModal.value = true
}
const getCurrentChapter = () => {
return currentChapter.value
}
</script>
<style>

View File

@@ -0,0 +1,30 @@
<template>
<div v-if="course">
<div class="text-xl font-semibold">
{{ course.title }}
</div>
<div v-if="course.chapters.length">
{{ course.chapters }}
</div>
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
<div>
{{
__(
'There are no chapters in this course. Create and manage chapters from here.'
)
}}
</div>
<Button class="mt-4">
{{ __('Add Chapter') }}
</Button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
course: {
type: Object,
default: {},
},
})
</script>

View File

@@ -3,7 +3,6 @@
<div class="h-full overflow-auto" id="scrollContainer">
<slot />
</div>
{{ tabs }}
<div
v-if="tabs"
class="grid grid-cols-5 border-t border-gray-300 standalone:pb-4"
@@ -15,8 +14,7 @@
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
@click="handleClick(tab)"
>
{{ tab.label }}
<component :is="tab.icon" class="h-6 w-6" />
<component :is="tab.icon" class="h-6 w-6 stroke-1.5 text-gray-700" />
</button>
</div>
</div>
@@ -31,8 +29,6 @@ const tabs = computed(() => {
return getSidebarLinks()
})
console.log(tabs.value)
/* let isActive = computed((tab) => {
console.log(tab);
return router.currentRoute.value.name === tab.to

View File

@@ -0,0 +1,164 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Create a Batch'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: (close) => createBatch(close),
},
],
}"
>
<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>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
class="mb-4"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
class="mb-4"
/>
</div>
<div>
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
class="mb-4"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
class="mb-4"
/>
</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>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
/>
<FormControl
v-model="batch.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
</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"
/>
<FormControl
v-model="batch_details.raw"
:label="__('Batch Details')"
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'
const batch = reactive({
title: '',
published: false,
start_date: '',
end_date: '',
start_time: '',
end_time: '',
medium: '',
category: '',
seat_count: 0,
evaluation_end_date: '',
description: '',
batch_details: '',
batch_details_raw: '',
meta_image: '',
paid_batch: false,
amount: 0,
currency: '',
})
</script>

View File

@@ -0,0 +1,161 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add Chapter'),
size: 'lg',
actions: [
{
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
variant: 'solid',
onClick: (close) =>
chapterDetail ? editChapter(close) : addChapter(close),
},
],
}"
>
<template #body-content>
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
const show = defineModel()
const outline = defineModel('outline')
const props = defineProps({
course: {
type: String,
required: true,
},
chapterDetail: {
type: Object,
},
})
const chapter = reactive({
title: '',
})
const chapterResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Course Chapter',
title: chapter.title,
description: chapter.description,
course: props.course,
},
}
},
})
const chapterEditResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Chapter',
name: props.chapterDetail?.name,
fieldname: 'title',
value: chapter.title,
}
},
})
const chapterReference = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Chapter Reference',
chapter: values.name,
parent: props.course,
parenttype: 'LMS Course',
parentfield: 'chapters',
},
}
},
})
const addChapter = (close) => {
chapterResource.submit(
{},
{
validate() {
if (!chapter.title) {
return 'Title is required'
}
},
onSuccess: (data) => {
chapterReference.submit(
{ name: data.name },
{
onSuccess(data) {
outline.value.reload()
createToast({
text: 'Chapter added successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
},
onError(err) {
showError(err)
},
}
)
close()
},
onError(err) {
showError(err)
},
}
)
}
const editChapter = (close) => {
chapterEditResource.submit(
{},
{
validate() {
if (!chapter.title) {
return 'Title is required'
}
},
onSuccess() {
outline.value.reload()
createToast({
text: 'Chapter updated successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
close()
},
onError(err) {
showError(err)
},
}
)
}
const showError = (err) => {
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,
})
}
watch(
() => props.chapterDetail,
(newChapter) => {
chapter.title = newChapter?.title
}
)
</script>

View File

@@ -6,7 +6,7 @@
size: 'xl',
actions: [
{
label: 'Submit',
label: __('Submit'),
variant: 'solid',
onClick: (close) => submitEvaluation(close),
},

View File

@@ -0,0 +1,59 @@
<template>
<div>
<div class="mb-1.5 text-sm text-gray-700">
{{ __(label) }}
</div>
<div class="flex items-center">
{{ tags }}
<div
v-for="tag in tags?.split(', ')"
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
</div>
</div>
</template>
<script setup>
import { FormControl } from 'frappe-ui'
import { X } from 'lucide-vue-next'
import { ref } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: 'Tags',
},
})
console.log(props.modelValue)
let tags = ref(props.modelValue)
console.log(tags.value)
const emit = defineEmits(['update:modelValue'])
let newTag = ref('')
let emitChange = (value) => {
emit('update:modelValue', value)
}
const updateTags = () => {
if (newTag) {
tags.value = tags.value ? `${tags.value}, ${newTag}` : newTag
newTag.value = ''
emitChange(tags.value)
}
}
const removeTag = (tag) => {
tags.value = tags.value.replace(tag, '').replace(', ,', ',')
emitChange(tags.value)
}
</script>

View File

@@ -16,7 +16,13 @@
</Button>
</div>
</header>
<div class="pb-5">
<div v-if="batches.data" class="pb-5">
<div
v-if="batches.data.length == 0 && batches.list.loading"
class="p-5 text-base text-gray-700"
>
{{ __('Loading Batches...') }}
</div>
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
<template #tab="{ tab, selected }">
<div>
@@ -69,13 +75,14 @@
</div>
</template>
<script setup>
import { createResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
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'
const user = inject('$user')
const batches = createResource({
const batches = createListResource({
doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches',
cache: ['batches', user?.data?.email],
auto: true,

View File

@@ -82,7 +82,7 @@
<CourseOutline
:courseName="course.data.name"
:showOutline="true"
:showHeader="true"
title="Course Outline"
/>
</div>
<CourseReviews

View File

@@ -109,8 +109,7 @@
</div>
<div class="flex items-center">
<div
v-if="tags"
v-for="tag in tags?.split(', ')"
v-for="tag in getTags"
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
>
{{ tag }}
@@ -170,27 +169,14 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-5 pt-5">
<div v-if="courseResource.doc">
<div class="text-xl font-semibold">
{{ course.title }}
</div>
<div v-if="courseResource.doc.chapters.length">
{{ courseResource.chapters }}
</div>
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
<div>
{{
__(
'There are no chapters in this course. Create and manage chapters from here.'
)
}}
</div>
<Button class="mt-4">
{{ __('Add Chapter') }}
</Button>
</div>
</div>
<div class="border-l px-5 pt-5">
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
<CourseOutline
v-if="courseResource.doc"
:courseName="courseResource.doc.name"
:title="courseResource.doc.title"
:allowEdit="true"
/>
</div>
</div>
</div>
@@ -205,10 +191,11 @@ import {
FormControl,
FileUploader,
} from 'frappe-ui'
import { inject, onMounted, computed, ref } from 'vue'
import { inject, onMounted, computed, ref, reactive } from 'vue'
import { convertToTitleCase, createToast, getFileSize } from '../utils'
import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next'
import CourseOutline from '@/components/CourseOutline.vue'
const user = inject('$user')
const tags = ref('')
@@ -246,8 +233,13 @@ const courseResource = createDocumentResource({
name: props.courseName,
auto: false,
onSuccess(data) {
imageResource.reload({ image: data.image })
tags.value = data.tags
imageResource.reload({ image: data.image })
Object.assign(course, data)
course.published = data.published ? true : false
course.upcoming = data.upcoming ? true : false
course.disable_self_learning = data.disable_self_learning ? true : false
course.paid_course = data.paid_course ? true : false
},
})
@@ -280,7 +272,7 @@ const course = computed(() => {
description: courseResource.doc?.description || '',
video_link: courseResource.doc?.video_link || '',
course_image: courseResource.doc?.image || null,
tags: tags.value,
tags: courseResource.doc?.tags || '',
published: courseResource.doc?.published ? true : false,
upcoming: courseResource.doc?.upcoming ? true : false,
disable_self_learning: courseResource.doc?.disable_self_learning
@@ -290,9 +282,31 @@ const course = computed(() => {
paid_course: courseResource.doc?.paid_course ? true : false,
course_price: courseResource.doc?.course_price || '',
currency: courseResource.doc?.currency || '',
image: courseResource.doc?.image || null,
}
})
const getTags = computed(() => {
return courseResource.doc?.tags
? courseResource.doc.tags.split(', ')
: tags.value?.split(', ')
})
/*
const course = reactive({
title: '',
short_introduction: '',
description: '',
video_link: '',
course_image: null,
tags: "",
published: false,
upcoming: false,
disable_self_learning: false,
paid_course: false,
course_price: '',
currency: '',
}) */
const courseCreationResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {

View File

@@ -0,0 +1,130 @@
<template>
<div class="h-screen text-base">
<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="w-7/12 mx-auto pt-5">
<div class="text-lg font-semibold mb-5">
{{ __('Lesson Details') }}
</div>
<FormControl v-model="lesson.title" label="Title" class="mb-4" />
<FormControl
v-model="lesson.include_in_preview"
type="checkbox"
label="Include in Preview"
/>
<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>
</div>
</div>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
import { computed, reactive, onMounted } 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'
const props = defineProps({
courseName: {
type: String,
required: true,
},
chapterNumber: {
type: String,
required: true,
},
lessonNumber: {
type: String,
required: true,
},
})
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>",
},
},
},
},
},
})
)
const lesson = reactive({
title: '',
include_in_preview: false,
body: '',
instructor_notes: '',
})
const lessonDetails = createResource({
url: 'lms.lms.utils.get_lesson_creation_details',
params: {
course: props.courseName,
chapter: props.chapterNumber,
lesson: props.lessonNumber,
},
auto: true,
})
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Courses',
route: { name: 'Courses' },
},
{
label: lessonDetails.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
},
]
crumbs.push({
label: 'Create Lesson',
route: {
name: 'CreateLesson',
params: {
courseName: props.courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
},
})
return crumbs
})
</script>

View File

@@ -1 +0,0 @@
<template></template>

View File

@@ -78,9 +78,9 @@ const routes = [
props: true,
},
{
path: '/courses/:courseName/outline',
name: 'CourseOutline',
component: () => import('@/pages/CreateOutline.vue'),
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
name: 'CreateLesson',
component: () => import('@/pages/CreateLesson.vue'),
props: true,
},
]

View File

@@ -0,0 +1,78 @@
export default class YouTubeVideo {
constructor({ data }) {
this.data = data
}
static get toolbox() {
return {
title: 'YouTube Video',
icon: `<img src="/assets/lms/icons/video.svg" width="15" height="15">`,
}
}
render() {
this.wrapper = document.createElement('div')
if (this.data && this.data.youtube) {
$(this.wrapper).html(this.render_youtube(this.data.youtube))
} else {
this.render_youtube_dialog()
}
return this.wrapper
}
render_youtube_dialog() {
let me = this
let youtubedialog = new frappe.ui.Dialog({
title: __('YouTube Video'),
fields: [
{
fieldname: 'youtube',
fieldtype: 'Data',
label: __('YouTube Video ID'),
reqd: 1,
},
{
fieldname: 'instructions_section_break',
fieldtype: 'Section Break',
label: __('Instructions:'),
},
{
fieldname: 'instructions',
fieldtype: 'HTML',
label: __('Instructions'),
options: __(
'Enter the YouTube Video ID. The ID is the part of the URL after <code>watch?v=</code>. For example, if the URL is <code>https://www.youtube.com/watch?v=QH2-TGUlwu4</code>, the ID is <code>QH2-TGUlwu4</code>'
),
},
],
primary_action_label: __('Insert'),
primary_action(values) {
youtubedialog.hide()
me.youtube = values.youtube
$(me.wrapper).html(me.render_youtube(values.youtube))
},
})
youtubedialog.show()
}
render_youtube(youtube) {
return `<iframe width="100%" height="400"
src="https://www.youtube.com/embed/${youtube}"
title="YouTube video player"
frameborder="0"
style="border-radius: var(--border-radius-lg); margin: 1rem 0;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>`
}
validate(savedData) {
return !savedData.youtube || !savedData.youtube.trim() ? false : true
}
save(block_content) {
return {
youtube: this.data.youtube || this.youtube,
}
}
}

3966
frontend/yarn-error.log Normal file

File diff suppressed because it is too large Load Diff