feat: chapter creation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
frontend/src/components/CreateOutline.vue
Normal file
30
frontend/src/components/CreateOutline.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
164
frontend/src/components/Modals/BatchCreation.vue
Normal file
164
frontend/src/components/Modals/BatchCreation.vue
Normal 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>
|
||||
161
frontend/src/components/Modals/ChapterModal.vue
Normal file
161
frontend/src/components/Modals/ChapterModal.vue
Normal 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>
|
||||
@@ -6,7 +6,7 @@
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
label: __('Submit'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => submitEvaluation(close),
|
||||
},
|
||||
|
||||
59
frontend/src/components/Tags.vue
Normal file
59
frontend/src/components/Tags.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<CourseOutline
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:showHeader="true"
|
||||
title="Course Outline"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
|
||||
@@ -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) {
|
||||
|
||||
130
frontend/src/pages/CreateLesson.vue
Normal file
130
frontend/src/pages/CreateLesson.vue
Normal 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>
|
||||
@@ -1 +0,0 @@
|
||||
<template></template>
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
78
frontend/src/utils/youtube.js
Normal file
78
frontend/src/utils/youtube.js
Normal 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
3966
frontend/yarn-error.log
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user