feat: chapter creation
This commit is contained in:
@@ -8,10 +8,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"chart.js": "^4.4.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.22",
|
"frappe-ui": "^0.1.31",
|
||||||
"lucide-vue-next": "^0.309.0",
|
"lucide-vue-next": "^0.309.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-base">
|
<div class="text-base">
|
||||||
<div
|
<div
|
||||||
v-if="showHeader && outline.data?.length"
|
v-if="title && (outline.data?.length || allowEdit)"
|
||||||
class="flex justify-between mb-4"
|
class="flex items-center justify-between mb-4"
|
||||||
>
|
>
|
||||||
<div class="text-2xl font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Course Content') }}
|
{{ __(title) }}
|
||||||
</div>
|
</div>
|
||||||
|
<Button @click="openChapterModal()">
|
||||||
|
{{ __('Add Chapter') }}
|
||||||
|
</Button>
|
||||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||||
</span> -->
|
</span> -->
|
||||||
@@ -20,7 +23,7 @@
|
|||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
v-for="(chapter, index) in outline.data"
|
v-for="(chapter, index) in outline.data"
|
||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapter(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex w-full px-2 py-4">
|
<DisclosureButton ref="" class="flex w-full px-2 py-4">
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel class="pb-2">
|
<DisclosurePanel class="pb-2">
|
||||||
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
<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
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
@@ -70,13 +73,38 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChapterModal
|
||||||
|
v-model="showChapterModal"
|
||||||
|
v-model:outline="outline"
|
||||||
|
:course="courseName"
|
||||||
|
:chapterDetail="getCurrentChapter()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
@@ -86,9 +114,13 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const expandAll = ref(true)
|
const expandAll = ref(true)
|
||||||
|
const showChapterModal = ref(false)
|
||||||
|
const currentChapter = ref(null)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -98,7 +130,11 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
showHeader: {
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
allowEdit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -113,12 +149,17 @@ const outline = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const openChapter = (index) => {
|
const openChapterDetail = (index) => {
|
||||||
return index == route.params.chapterNumber || index == 1
|
return index == route.params.chapterNumber || index == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandAllChapters = () => {
|
const openChapterModal = (chapter = null) => {
|
||||||
expandAll.value = !expandAll.value
|
currentChapter.value = chapter
|
||||||
|
showChapterModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentChapter = () => {
|
||||||
|
return currentChapter.value
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<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">
|
<div class="h-full overflow-auto" id="scrollContainer">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
{{ tabs }}
|
|
||||||
<div
|
<div
|
||||||
v-if="tabs"
|
v-if="tabs"
|
||||||
class="grid grid-cols-5 border-t border-gray-300 standalone:pb-4"
|
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"
|
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
||||||
@click="handleClick(tab)"
|
@click="handleClick(tab)"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
<component :is="tab.icon" class="h-6 w-6 stroke-1.5 text-gray-700" />
|
||||||
<component :is="tab.icon" class="h-6 w-6" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,8 +29,6 @@ const tabs = computed(() => {
|
|||||||
return getSidebarLinks()
|
return getSidebarLinks()
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(tabs.value)
|
|
||||||
|
|
||||||
/* let isActive = computed((tab) => {
|
/* let isActive = computed((tab) => {
|
||||||
console.log(tab);
|
console.log(tab);
|
||||||
return router.currentRoute.value.name === tab.to
|
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',
|
size: 'xl',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: 'Submit',
|
label: __('Submit'),
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitEvaluation(close),
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
||||||
<template #tab="{ tab, selected }">
|
<template #tab="{ tab, selected }">
|
||||||
<div>
|
<div>
|
||||||
@@ -69,13 +75,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 { Plus } from 'lucide-vue-next'
|
||||||
import BatchCard from '@/components/BatchCard.vue'
|
import BatchCard from '@/components/BatchCard.vue'
|
||||||
import { inject, ref, computed } from 'vue'
|
import { inject, ref, computed } from 'vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const batches = createResource({
|
const batches = createListResource({
|
||||||
|
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,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
<CourseOutline
|
<CourseOutline
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:showOutline="true"
|
:showOutline="true"
|
||||||
:showHeader="true"
|
title="Course Outline"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
|
|||||||
@@ -109,8 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div
|
||||||
v-if="tags"
|
v-for="tag in getTags"
|
||||||
v-for="tag in tags?.split(', ')"
|
|
||||||
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@@ -170,27 +169,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 px-5 pt-5">
|
<div class="border-l px-5 pt-5">
|
||||||
<div v-if="courseResource.doc">
|
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
|
||||||
<div class="text-xl font-semibold">
|
<CourseOutline
|
||||||
{{ course.title }}
|
v-if="courseResource.doc"
|
||||||
</div>
|
:courseName="courseResource.doc.name"
|
||||||
<div v-if="courseResource.doc.chapters.length">
|
:title="courseResource.doc.title"
|
||||||
{{ courseResource.chapters }}
|
:allowEdit="true"
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,10 +191,11 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
} from 'frappe-ui'
|
} 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 { convertToTitleCase, createToast, getFileSize } from '../utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const tags = ref('')
|
const tags = ref('')
|
||||||
@@ -246,8 +233,13 @@ const courseResource = createDocumentResource({
|
|||||||
name: props.courseName,
|
name: props.courseName,
|
||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
imageResource.reload({ image: data.image })
|
|
||||||
tags.value = data.tags
|
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 || '',
|
description: courseResource.doc?.description || '',
|
||||||
video_link: courseResource.doc?.video_link || '',
|
video_link: courseResource.doc?.video_link || '',
|
||||||
course_image: courseResource.doc?.image || null,
|
course_image: courseResource.doc?.image || null,
|
||||||
tags: tags.value,
|
tags: courseResource.doc?.tags || '',
|
||||||
published: courseResource.doc?.published ? true : false,
|
published: courseResource.doc?.published ? true : false,
|
||||||
upcoming: courseResource.doc?.upcoming ? true : false,
|
upcoming: courseResource.doc?.upcoming ? true : false,
|
||||||
disable_self_learning: courseResource.doc?.disable_self_learning
|
disable_self_learning: courseResource.doc?.disable_self_learning
|
||||||
@@ -290,9 +282,31 @@ const course = computed(() => {
|
|||||||
paid_course: courseResource.doc?.paid_course ? true : false,
|
paid_course: courseResource.doc?.paid_course ? true : false,
|
||||||
course_price: courseResource.doc?.course_price || '',
|
course_price: courseResource.doc?.course_price || '',
|
||||||
currency: courseResource.doc?.currency || '',
|
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({
|
const courseCreationResource = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
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,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/courses/:courseName/outline',
|
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
|
||||||
name: 'CourseOutline',
|
name: 'CreateLesson',
|
||||||
component: () => import('@/pages/CreateOutline.vue'),
|
component: () => import('@/pages/CreateLesson.vue'),
|
||||||
props: true,
|
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