feat: batch creation
This commit is contained in:
141
frontend/src/components/BatchCourses.vue
Normal file
141
frontend/src/components/BatchCourses.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="user.data?.is_moderator"
|
||||
variant="solid"
|
||||
@click="openCourseModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add Course') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="courses.data?.length">
|
||||
<ListView
|
||||
:columns="getCoursesColumns()"
|
||||
:rows="courses.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in courses.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" @click="removeCourses(selections)">
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<BatchCourseModal
|
||||
v-model="showCourseModal"
|
||||
:batch="batch"
|
||||
v-model:courses="courses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue'
|
||||
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
||||
import {
|
||||
createResource,
|
||||
Button,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const showCourseModal = ref(false)
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch,
|
||||
},
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openCourseModal = () => {
|
||||
showCourseModal.value = true
|
||||
}
|
||||
|
||||
const getCoursesColumns = () => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
label: 'Lessons',
|
||||
key: 'lesson_count',
|
||||
},
|
||||
{
|
||||
label: 'Enrollments',
|
||||
key: 'enrollment_count',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const removeCourse = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Batch Course',
|
||||
name: values.course,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeCourses = (selections) => {
|
||||
console.log(selections)
|
||||
selections.forEach(async (course) => {
|
||||
removeCourse.submit({ course })
|
||||
await setTimeout(1000)
|
||||
})
|
||||
courses.reload()
|
||||
}
|
||||
</script>
|
||||
@@ -73,11 +73,21 @@
|
||||
>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<Button v-if="user?.data?.is_moderator" class="w-full mt-2">
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator"
|
||||
:to="{
|
||||
name: 'BatchCreation',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@@ -55,16 +55,14 @@
|
||||
<Button variant="ghost" @click="removeStudents(selections)">
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
label="Unselect all"
|
||||
@click="unselectAll.toString()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
<StudentModal
|
||||
:batch="props.batch"
|
||||
v-model="showStudentModal"
|
||||
|
||||
@@ -103,8 +103,8 @@ import { computed, inject } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { createToast } from '@/utils/'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="flex items-center justify-between mb-4"
|
||||
>
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="font-semibold" :class="allowEdit ? 'text-base' : 'text-lg'">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button v-if="allowEdit" @click="openChapterModal()">
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
{{ __('Add Chapter') }}
|
||||
</Button>
|
||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||
|
||||
99
frontend/src/components/LessonContent.vue
Normal file
99
frontend/src/components/LessonContent.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div v-if="youtube">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
:src="getYouTubeVideoSource(youtube)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-for="block in content.split('\n\n')">
|
||||
<div v-if="block.includes('{{ YouTubeVideo')">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
:src="getYouTubeVideoSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Quiz')">
|
||||
<Quiz :quiz="getId(block)" />
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Video')">
|
||||
<video controls width="100%" controlsList="nodownload">
|
||||
<source :src="getId(block)" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ PDF')">
|
||||
<iframe
|
||||
:src="getPDFSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Audio')">
|
||||
<audio width="100%" controls controlsList="nodownload">
|
||||
<source :src="getId(block)" type="audio/mp3" />
|
||||
</audio>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Embed')">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="400"
|
||||
:src="getId(block)"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
<div v-else v-html="markdown.render(block)"></div>
|
||||
</div>
|
||||
<div v-if="quizId">
|
||||
<Quiz :quiz="quizId" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Quiz from '@/components/QuizBlock.vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
youtube: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
quizId: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const getYouTubeVideoSource = (block) => {
|
||||
if (block.includes('{{')) {
|
||||
block = getId(block)
|
||||
}
|
||||
return `https://www.youtube.com/embed/${block}`
|
||||
}
|
||||
|
||||
const getPDFSource = (block) => {
|
||||
return `${getId(block)}#toolbar=0`
|
||||
}
|
||||
|
||||
const getId = (block) => {
|
||||
return block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
}
|
||||
</script>
|
||||
137
frontend/src/components/LessonPlugins.vue
Normal file
137
frontend/src/components/LessonPlugins.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Components') }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Select an Editor') }}
|
||||
</div>
|
||||
<Select v-model="currentEditor" :options="getEditorOptions()" />
|
||||
</div>
|
||||
<div class="flex mt-4">
|
||||
<Link
|
||||
v-model="quiz"
|
||||
class="flex-1"
|
||||
doctype="LMS Quiz"
|
||||
:label="__('Select a Quiz')"
|
||||
/>
|
||||
<Button @click="addQuiz()" class="self-end ml-2">
|
||||
<template #icon>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Add an image, video, pdf or audio.') }}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<FileUploader
|
||||
v-if="!file"
|
||||
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
|
||||
:validateFile="validateFile"
|
||||
@success="(data) => addFile(data)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? __('Uploading {0}%').format(progress)
|
||||
: __('Upload an File')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs">
|
||||
{{ file.file_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileUploader, Button, Select } from 'frappe-ui'
|
||||
import { Plus, FileText } from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const quiz = ref(null)
|
||||
const file = ref(null)
|
||||
const lessonEditor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const currentEditor = ref('Lesson Content')
|
||||
|
||||
const props = defineProps({
|
||||
editor: {
|
||||
required: true,
|
||||
},
|
||||
notesEditor: {
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const addQuiz = () => {
|
||||
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||
if (quiz.value) {
|
||||
getCurrentEditor().blocks.insert('quiz', {
|
||||
quiz: quiz.value,
|
||||
})
|
||||
quiz.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const addFile = (data) => {
|
||||
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||
getCurrentEditor().blocks.insert('upload', data)
|
||||
}
|
||||
|
||||
const getBlocksCount = () => {
|
||||
return getCurrentEditor().blocks.getBlocksCount
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3'].includes(extension)) {
|
||||
return 'Only image and video files are allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const getEditorOptions = () => {
|
||||
return [
|
||||
{
|
||||
label: 'Lesson Content',
|
||||
value: 'Lesson Content',
|
||||
},
|
||||
{
|
||||
label: 'Instructor Content',
|
||||
value: 'Instructor Content',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const getCurrentEditor = () => {
|
||||
return currentEditor.value == 'Lesson Content'
|
||||
? lessonEditor.value
|
||||
: instructorEditor.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.editor, props.notesEditor],
|
||||
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
|
||||
lessonEditor.value = newEditor
|
||||
instructorEditor.value = newNotesEditor
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Create') }}
|
||||
{{ __('Add Live Class') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
@@ -88,7 +88,7 @@ const props = defineProps({
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch: props.batchName,
|
||||
batch_name: props.batch,
|
||||
date: ['>=', new Date()],
|
||||
},
|
||||
fields: [
|
||||
|
||||
68
frontend/src/components/Modals/BatchCourseModal.vue
Normal file
68
frontend/src/components/Modals/BatchCourseModal.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a course'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: __('Submit'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => addCourse(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="LMS Course" v-model="course" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource } from 'frappe-ui'
|
||||
import { ref, defineModel } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const show = defineModel()
|
||||
const course = ref(null)
|
||||
const courses = defineModel('courses')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const createBatchCourse = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Batch Course',
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
course: course.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCourse = (close) => {
|
||||
createBatchCourse.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
courses.value.reload()
|
||||
close()
|
||||
course.value = null
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,192 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Create a Batch'),
|
||||
size: '3xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => createBatch(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div>
|
||||
<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"
|
||||
: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-3 gap-4 mt-4 border-t pt-4">
|
||||
<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.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"
|
||||
/>
|
||||
<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.batch_details_raw"
|
||||
:label="__('Batch Details Raw')"
|
||||
type="textarea"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
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: '',
|
||||
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>
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a Student'),
|
||||
size: 'xl',
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
|
||||
@@ -45,25 +45,7 @@
|
||||
<template #default="{ tab }">
|
||||
<div class="pt-5 px-10 pb-10">
|
||||
<div v-if="tab.label == 'Courses'">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 gap-8 mt-5"
|
||||
>
|
||||
<div v-for="course in courses.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :key="course.name" :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<BatchCourses :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard'">
|
||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||
@@ -94,9 +76,10 @@
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-3">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
@@ -111,7 +94,6 @@
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-html="batch.data.description"></div>
|
||||
</div>
|
||||
<AnnouncementModal
|
||||
v-model="showAnnouncementModal"
|
||||
@@ -180,8 +162,8 @@ import {
|
||||
MessageCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import BatchCourses from '@/components/BatchCourses.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
import BatchStudents from '@/components/BatchStudents.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
@@ -213,7 +195,7 @@ const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
||||
if (!isStudent.value) {
|
||||
crumbs.push({
|
||||
label: batch.data?.title,
|
||||
label: 'Details',
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
@@ -275,15 +257,6 @@ const tabs = computed(() => {
|
||||
return batchTabs
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/batches`
|
||||
}
|
||||
|
||||
390
frontend/src/pages/BatchCreation.vue
Normal file
390
frontend/src/pages/BatchCreation.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<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 class="h-7" :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="container">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
class="mt-4"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(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 v-else class="mt-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ batch.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(batch.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-b mb-5">
|
||||
<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>
|
||||
</div>
|
||||
<div class="container border-b mb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
:options="[
|
||||
{
|
||||
label: 'Online',
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: 'Offline',
|
||||
value: 'Offline',
|
||||
},
|
||||
]"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batch.category"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Payment') }}
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, inject, reactive } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
Button,
|
||||
TextEditor,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize } from '../utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import { showToast } from '../utils'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = reactive({
|
||||
title: '',
|
||||
published: false,
|
||||
description: '',
|
||||
batch_details: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
evaluation_end_date: '',
|
||||
seat_count: '',
|
||||
medium: '',
|
||||
category: '',
|
||||
image: null,
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
amount: 0,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
if (props.batchName != 'new') {
|
||||
batchDetail.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const newBatch = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch',
|
||||
meta_image: batch.image.file_url,
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const batchDetail = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = ['published', 'paid_batch']
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
}
|
||||
if (data.meta_image) imageResource.reload({ image: data.meta_image })
|
||||
},
|
||||
})
|
||||
|
||||
const editBatch = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
fieldname: {
|
||||
meta_image: batch.image.file_url,
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
batch.image = data
|
||||
},
|
||||
})
|
||||
|
||||
const saveBatch = () => {
|
||||
if (batchDetail.data) {
|
||||
editBatchDetails()
|
||||
} else {
|
||||
createNewBatch()
|
||||
}
|
||||
}
|
||||
|
||||
const createNewBatch = () => {
|
||||
newBatch.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editBatchDetails = () => {
|
||||
editBatch.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
batch.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
batch.image = null
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Batches',
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
},
|
||||
]
|
||||
if (batchDetail.data) {
|
||||
crumbs.push({
|
||||
label: batchDetail.data.title,
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: props.batchName,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
||||
route: { name: 'BatchCreation', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
@@ -45,9 +45,11 @@
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-semibold mt-10">
|
||||
{{ __('Courses') }}
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
@@ -78,10 +80,10 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { Breadcrumbs, createResource, Button } from 'frappe-ui'
|
||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '../utils'
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
/>
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{
|
||||
name: 'CreateBatch',
|
||||
name: 'BatchCreation',
|
||||
params: { batchName: 'new' },
|
||||
}"
|
||||
>
|
||||
@@ -88,7 +89,6 @@ import BatchCard from '@/components/BatchCard.vue'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showBatchModal = ref(false)
|
||||
|
||||
const batches = createListResource({
|
||||
doctype: 'LMS Batch',
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
v-if="courses.data.length == 0 && courses.list.loading"
|
||||
class="p-5 text-base text-gray-700"
|
||||
>
|
||||
Loading Courses...
|
||||
{{ __('Loading Courses...') }}
|
||||
</div>
|
||||
<Tabs
|
||||
v-else
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<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 class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div>Batch creation</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Breadcrumbs } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Batches',
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
},
|
||||
]
|
||||
crumbs.push({
|
||||
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
||||
route: { name: 'CreateBatch', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
@@ -8,10 +8,10 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center">
|
||||
<router-link
|
||||
v-if="courseResource.doc"
|
||||
v-if="courseResource.data"
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseResource.doc.name },
|
||||
params: { courseName: courseResource.data.name },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="mt-5 mb-10">
|
||||
<div class="container mb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Course Details') }}
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
@@ -55,14 +55,10 @@
|
||||
/>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!image"
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="
|
||||
(file) => {
|
||||
image = file
|
||||
}
|
||||
"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
@@ -86,10 +82,10 @@
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ image.file_name }}
|
||||
{{ course.course_image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(image.file_size) }}
|
||||
{{ getFileSize(course.course_image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@@ -104,12 +100,12 @@
|
||||
class="mb-4"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
<div class="mb-1.5 text-xs text-gray-600">
|
||||
{{ __('Tags') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
v-for="tag in getTags"
|
||||
v-for="tag in course.tags.split(', ')"
|
||||
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -124,7 +120,7 @@
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Course Settings') }}
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<FormControl
|
||||
@@ -146,7 +142,7 @@
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Course Pricing') }}
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
@@ -172,9 +168,9 @@
|
||||
<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"
|
||||
v-if="courseResource.data"
|
||||
:courseName="courseResource.data.name"
|
||||
:title="course.title"
|
||||
:allowEdit="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -192,15 +188,13 @@ import {
|
||||
FileUploader,
|
||||
} from 'frappe-ui'
|
||||
import { inject, onMounted, computed, ref, reactive } from 'vue'
|
||||
import { convertToTitleCase, createToast, getFileSize } from '../utils'
|
||||
import { convertToTitleCase, showToast, 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('')
|
||||
const newTag = ref('')
|
||||
const image = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -208,84 +202,6 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Courses',
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
]
|
||||
if (courseResource.doc) {
|
||||
crumbs.push({
|
||||
label: courseResource.doc?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const courseResource = createDocumentResource({
|
||||
doctype: 'LMS Course',
|
||||
name: props.courseName,
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
image.value = data
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
if (props.courseName !== 'new') {
|
||||
courseResource.reload()
|
||||
}
|
||||
})
|
||||
|
||||
/* const course = computed(() => {
|
||||
return {
|
||||
title: courseResource.doc?.title || '',
|
||||
short_introduction: courseResource.doc?.short_introduction || '',
|
||||
description: courseResource.doc?.description || '',
|
||||
video_link: courseResource.doc?.video_link || '',
|
||||
course_image: courseResource.doc?.image || null,
|
||||
tags: courseResource.doc?.tags || '',
|
||||
published: courseResource.doc?.published ? true : false,
|
||||
upcoming: courseResource.doc?.upcoming ? true : false,
|
||||
disable_self_learning: courseResource.doc?.disable_self_learning
|
||||
? true
|
||||
: false,
|
||||
course_image: image.value,
|
||||
paid_course: courseResource.doc?.paid_course ? true : false,
|
||||
course_price: courseResource.doc?.course_price || '',
|
||||
currency: courseResource.doc?.currency || '',
|
||||
image: courseResource.doc?.image || null,
|
||||
}
|
||||
}) */
|
||||
|
||||
const course = reactive({
|
||||
title: '',
|
||||
short_introduction: '',
|
||||
@@ -301,10 +217,13 @@ const course = reactive({
|
||||
currency: '',
|
||||
})
|
||||
|
||||
const getTags = computed(() => {
|
||||
return courseResource.doc?.tags
|
||||
? courseResource.doc.tags.split(', ')
|
||||
: tags.value?.split(', ')
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
if (props.courseName !== 'new') {
|
||||
courseResource.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const courseCreationResource = createResource({
|
||||
@@ -313,24 +232,82 @@ const courseCreationResource = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Course',
|
||||
image: image.value.file_url,
|
||||
image: course.course_image.file_url,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courseEditResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
name: values.course,
|
||||
fieldname: {
|
||||
image: course.course_image.file_url,
|
||||
...course,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courseResource = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
name: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'upcoming',
|
||||
'disable_self_learning',
|
||||
'paid_course',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
course[key] = course[key] ? true : false
|
||||
}
|
||||
|
||||
if (data.image) imageResource.reload({ image: data.image })
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
course.course_image = data
|
||||
},
|
||||
})
|
||||
|
||||
const getTags = computed(() => {
|
||||
return courseResource.doc?.tags
|
||||
? courseResource.doc.tags.split(', ')
|
||||
: tags.value?.split(', ')
|
||||
})
|
||||
|
||||
const submitCourse = () => {
|
||||
if (courseResource.doc) {
|
||||
courseResource.setValue.submit(
|
||||
if (courseResource.data) {
|
||||
courseEditResource.submit(
|
||||
{
|
||||
image: image.value?.file_url || null,
|
||||
...course.value,
|
||||
course: courseResource.data.name,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
return validateMandatoryFields()
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Course updated successfully', 'check')
|
||||
},
|
||||
@@ -340,10 +317,7 @@ const submitCourse = () => {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
courseCreationResource.submit(course.value, {
|
||||
validate() {
|
||||
return validateMandatoryFields()
|
||||
},
|
||||
courseCreationResource.submit(course, {
|
||||
onSuccess() {
|
||||
showToast('Success', 'Course created successfully', 'check')
|
||||
},
|
||||
@@ -363,15 +337,12 @@ const validateMandatoryFields = () => {
|
||||
'course_image',
|
||||
]
|
||||
for (const field of mandatory_fields) {
|
||||
if (!course.value[field]) {
|
||||
if (!course[field]) {
|
||||
let fieldLabel = convertToTitleCase(field.split('_').join(' '))
|
||||
return `${fieldLabel} is mandatory`
|
||||
}
|
||||
}
|
||||
if (
|
||||
course.value.paid_course &&
|
||||
(!course.value.course_price || !course.value.currency)
|
||||
) {
|
||||
if (course.paid_course && (!course.course_price || !course.currency)) {
|
||||
return 'Course price and currency are mandatory for paid courses'
|
||||
}
|
||||
}
|
||||
@@ -385,35 +356,44 @@ const validateFile = (file) => {
|
||||
|
||||
const updateTags = () => {
|
||||
if (newTag.value) {
|
||||
tags.value = tags.value ? `${tags.value}, ${newTag.value}` : newTag.value
|
||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||
newTag.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag) => {
|
||||
tags.value = tags.value
|
||||
course.tags = course.tags
|
||||
?.split(', ')
|
||||
.filter((t) => t !== tag)
|
||||
.join(', ')
|
||||
newTag.value = ''
|
||||
}
|
||||
|
||||
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 saveImage = (file) => {
|
||||
course.course_image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
image.value = null
|
||||
course.value.course_image = null
|
||||
course.course_image = null
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Courses',
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
]
|
||||
if (courseResource.data) {
|
||||
crumbs.push({
|
||||
label: course.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen text-base">
|
||||
<div class="grid grid-cols-[75%,25%] h-full">
|
||||
<div>
|
||||
<div class="border-r">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
@@ -10,53 +10,56 @@
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div class="w-5/6 mx-auto py-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Lesson Details') }}
|
||||
<div class="py-5">
|
||||
<div class="w-5/6 mx-auto">
|
||||
<FormControl v-model="lesson.title" label="Title" class="mb-4" />
|
||||
<FormControl
|
||||
v-model="lesson.include_in_preview"
|
||||
type="checkbox"
|
||||
label="Include in Preview"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t mt-4">
|
||||
<div class="w-5/6 mx-auto pt-4">
|
||||
<div
|
||||
class="flex justify-between cursor-pointer"
|
||||
@click="
|
||||
() => {
|
||||
openInstructorEditor = !openInstructorEditor
|
||||
}
|
||||
"
|
||||
>
|
||||
<label class="block font-medium text-gray-600 mb-1">
|
||||
{{ __('Instructor Notes') }}
|
||||
</label>
|
||||
<ChevronRight
|
||||
class="stroke-2 h-5 w-5 text-gray-600"
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': openInstructorEditor,
|
||||
'duration-200': !openInstructorEditor,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="openInstructorEditor"
|
||||
id="instructor-notes"
|
||||
class="py-3"
|
||||
></div>
|
||||
</div>
|
||||
</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">
|
||||
{{ __('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 py-3"></div>
|
||||
<div class="border-t mt-4">
|
||||
<div class="w-5/6 mx-auto pt-4">
|
||||
<label class="block font-medium text-gray-600 mb-1">
|
||||
{{ __('Content') }}
|
||||
</label>
|
||||
<div id="content" class="py-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l px-5 pt-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Components') }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex">
|
||||
<Link
|
||||
v-model="quiz"
|
||||
class="flex-1"
|
||||
doctype="LMS Quiz"
|
||||
:label="__('Select a Quiz')"
|
||||
/>
|
||||
<Button @click="addQuiz()" class="self-end ml-2">
|
||||
<template #icon>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="sticky top-0 p-5">
|
||||
<LessonPlugins :editor="editor" :notesEditor="instructorEditor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,13 +76,14 @@ import {
|
||||
import { computed, reactive, onMounted, inject, ref } from 'vue'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import { createToast } from '../utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import LessonPlugins from '@/components/LessonPlugins.vue'
|
||||
import { getEditorTools } from '../utils'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
let editor
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const user = inject('$user')
|
||||
const quiz = ref(null)
|
||||
const openInstructorEditor = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -100,7 +104,8 @@ onMounted(() => {
|
||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
editor = renderEditor('content')
|
||||
editor.value = renderEditor('content')
|
||||
instructorEditor.value = renderEditor('instructor-notes')
|
||||
})
|
||||
|
||||
const renderEditor = (holder) => {
|
||||
@@ -132,8 +137,27 @@ const lessonDetails = createResource({
|
||||
lesson[key] = data.lesson[key]
|
||||
})
|
||||
lesson.include_in_preview = data.include_in_preview ? true : false
|
||||
editor.isReady.then(() => {
|
||||
editor.render(JSON.parse(data.lesson.content))
|
||||
editor.value.isReady.then(() => {
|
||||
if (data.lesson.content) {
|
||||
editor.value.render(JSON.parse(data.lesson.content))
|
||||
} else if (data.lesson.body) {
|
||||
let blocks = convertToJSON(data.lesson)
|
||||
editor.value.render({
|
||||
blocks: blocks,
|
||||
})
|
||||
}
|
||||
})
|
||||
instructorEditor.value.isReady.then(() => {
|
||||
if (data.lesson.instructor_content) {
|
||||
instructorEditor.value.render(
|
||||
JSON.parse(data.lesson.instructor_content)
|
||||
)
|
||||
} else if (data.lesson.instructor_notes) {
|
||||
let blocks = convertToJSON(data.lesson)
|
||||
instructorEditor.value.render({
|
||||
blocks: blocks,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -180,30 +204,106 @@ const lessonReference = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const saveLesson = () => {
|
||||
editor.save().then((outputData) => {
|
||||
lesson.content = JSON.stringify(outputData)
|
||||
if (lessonDetails.data?.lesson) {
|
||||
editLesson.submit(
|
||||
{
|
||||
lesson: lessonDetails.data.lesson.name,
|
||||
const convertToJSON = (lessonData) => {
|
||||
let blocks = []
|
||||
lessonData.body.split('\n').forEach((block) => {
|
||||
if (block.includes('{{ YouTubeVideo')) {
|
||||
let youtubeID = block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
if (!youtubeID.includes('https://'))
|
||||
youtubeID = `https://www.youtube.com/embed/${youtubeID}`
|
||||
blocks.push({
|
||||
type: 'embed',
|
||||
data: {
|
||||
service: 'youtube',
|
||||
embed: youtubeID,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
return validateLesson()
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Lesson updated successfully', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
} else if (block.includes('{{ Quiz')) {
|
||||
let quiz = block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
blocks.push({
|
||||
type: 'quiz',
|
||||
data: {
|
||||
quiz: quiz,
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ Video')) {
|
||||
let video = block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
blocks.push({
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: video,
|
||||
file_type: 'video',
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ Audio')) {
|
||||
let audio = block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
blocks.push({
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: audio,
|
||||
file_type: 'audio',
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ PDF')) {
|
||||
let pdf = block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
blocks.push({
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: pdf,
|
||||
file_type: 'pdf',
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ Embed')) {
|
||||
let embed = block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
blocks.push({
|
||||
type: 'embed',
|
||||
data: {
|
||||
service: embed.split('|||')[0],
|
||||
embed: embed.split('|||')[1],
|
||||
},
|
||||
})
|
||||
} else if (block.includes('![]')) {
|
||||
let image = block.match(/\((.*?)\)/)[1]
|
||||
blocks.push({
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: image,
|
||||
file_type: 'image',
|
||||
},
|
||||
})
|
||||
} else if (block.includes('#')) {
|
||||
let level = (block.match(/#/g) || []).length
|
||||
blocks.push({
|
||||
type: 'header',
|
||||
data: {
|
||||
text: block.replace(/#/g, '').trim(),
|
||||
level: level,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
createNewLesson()
|
||||
blocks.push({
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: block,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
|
||||
const saveLesson = () => {
|
||||
editor.value.save().then((outputData) => {
|
||||
lesson.content = JSON.stringify(outputData)
|
||||
instructorEditor.value.save().then((outputData) => {
|
||||
lesson.instructor_content = JSON.stringify(outputData)
|
||||
if (lessonDetails.data?.lesson) {
|
||||
editCurrentLesson()
|
||||
} else {
|
||||
createNewLesson()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const createNewLesson = () => {
|
||||
@@ -230,6 +330,25 @@ const createNewLesson = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const editCurrentLesson = () => {
|
||||
editLesson.submit(
|
||||
{
|
||||
lesson: lessonDetails.data.lesson.name,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
return validateLesson()
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Lesson updated successfully', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const validateLesson = () => {
|
||||
if (!lesson.title) {
|
||||
return 'Title is required'
|
||||
@@ -239,20 +358,6 @@ const validateLesson = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addQuiz = () => {
|
||||
if (quiz.value) {
|
||||
editor.blocks.insert(
|
||||
'quiz',
|
||||
{
|
||||
quiz: quiz.value,
|
||||
},
|
||||
{},
|
||||
editor.blocks.getBlocksCount()
|
||||
)
|
||||
quiz.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (title, text, icon) => {
|
||||
createToast({
|
||||
title: title,
|
||||
@@ -306,3 +411,16 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.embed-tool__caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ce-toolbar__actions {
|
||||
right: 108%;
|
||||
}
|
||||
|
||||
.ce-block__content {
|
||||
max-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
2
frontend/src/pages/JobCreation.vue
Normal file
2
frontend/src/pages/JobCreation.vue
Normal file
@@ -0,0 +1,2 @@
|
||||
<template></template>
|
||||
<script setup></script>
|
||||
@@ -8,12 +8,14 @@
|
||||
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
|
||||
/>
|
||||
<div class="flex">
|
||||
<Button v-if="user.data?.name" variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('New Job') }}
|
||||
</Button>
|
||||
<router-link v-if="user.data?.name">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('New Job') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="jobs.data">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="grid grid-cols-[70%,30%] h-full">
|
||||
<div v-if="lesson.data.no_preview" class="border-r-2 text-center pt-10">
|
||||
<div v-if="lesson.data.no_preview" class="border-r-1 text-center pt-10">
|
||||
<p class="mb-4">
|
||||
{{
|
||||
__(
|
||||
@@ -111,74 +111,38 @@
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.data.content"
|
||||
v-for="content in JSON.parse(lesson.data.content).blocks"
|
||||
v-if="lesson.data.instructor_content && allowInstructorContent()"
|
||||
class="bg-gray-100 p-3 rounded-md mt-6"
|
||||
>
|
||||
<div class="text-gray-600 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
</div>
|
||||
<div
|
||||
id="instructor-content"
|
||||
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"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lesson.data.instructor_notes"
|
||||
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"
|
||||
>
|
||||
<LessonContent :data="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.data.content"
|
||||
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-5"
|
||||
>
|
||||
<div id="editor"></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"
|
||||
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-5"
|
||||
>
|
||||
<div v-if="lesson.data.youtube">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
:src="getYouTubeVideoSource(lesson.data.youtube)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-for="block in lesson.data.body.split('\n\n\n')">
|
||||
<div v-if="block.includes('{{ YouTubeVideo')">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
:src="getYouTubeVideoSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Quiz')">
|
||||
<Quiz :quiz="getId(block)" />
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Video')">
|
||||
<video controls width="100%" controlsList="nodownload">
|
||||
<source :src="getId(block)" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ PDF')">
|
||||
<iframe
|
||||
:src="getPDFSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Audio')">
|
||||
<audio width="100%" controls controlsList="nodownload">
|
||||
<source :src="getId(block)" type="audio/mp3" />
|
||||
</audio>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Embed')">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="400"
|
||||
:src="getId(block)"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
<div v-else v-html="markdown.render(block)"></div>
|
||||
</div>
|
||||
<div v-if="lesson.data.quiz_id">
|
||||
<Quiz :quiz="lesson.data.quiz_id" />
|
||||
</div>
|
||||
<LessonContent
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<Discussions
|
||||
@@ -221,21 +185,15 @@ import { computed, watch, ref, inject, createApp } from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import Quiz from '@/components/QuizBlock.vue'
|
||||
import { getEditorTools } from '../utils'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const route = useRoute()
|
||||
let editor
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
})
|
||||
let editor, instructorEditor
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -264,26 +222,31 @@ const lesson = createResource({
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
console.log(data)
|
||||
if (data.membership)
|
||||
current_lesson.submit({
|
||||
name: data.membership.name,
|
||||
lesson_name: data.name,
|
||||
})
|
||||
renderEditor()
|
||||
if (data.content) editor = renderEditor('editor', data.content)
|
||||
|
||||
if (data.instructor_content)
|
||||
instructorEditor = renderEditor(
|
||||
'instructor-content',
|
||||
data.instructor_content
|
||||
)
|
||||
|
||||
markProgress(data)
|
||||
},
|
||||
})
|
||||
|
||||
const renderEditor = () => {
|
||||
if (lesson.data?.content) {
|
||||
editor = new EditorJS({
|
||||
holder: 'editor',
|
||||
tools: getEditorTools(),
|
||||
data: JSON.parse(lesson.data.content),
|
||||
readOnly: true,
|
||||
})
|
||||
}
|
||||
const renderEditor = (holder, content) => {
|
||||
return new EditorJS({
|
||||
holder: holder,
|
||||
tools: getEditorTools(),
|
||||
data: JSON.parse(content),
|
||||
readOnly: true,
|
||||
defaultBlock: 'embed', // editor adds an empty block at the top, so to avoid that added default block as embed
|
||||
})
|
||||
}
|
||||
|
||||
const markProgress = (data) => {
|
||||
@@ -349,21 +312,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const getYouTubeVideoSource = (block) => {
|
||||
if (block.includes('{{')) {
|
||||
block = getId(block)
|
||||
}
|
||||
return `https://www.youtube.com/embed/${block}`
|
||||
}
|
||||
|
||||
const getPDFSource = (block) => {
|
||||
return `${getId(block)}#toolbar=0`
|
||||
}
|
||||
|
||||
const getId = (block) => {
|
||||
return block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect_to=/courses/${props.courseName}/learn/${route.params.chapterNumber}-${route.params.lessonNumber}`
|
||||
}
|
||||
@@ -377,12 +325,14 @@ const allowDiscussions = () => {
|
||||
}
|
||||
|
||||
const allowEdit = () => {
|
||||
if (user.data?.is_instructor) {
|
||||
return true
|
||||
}
|
||||
if (lesson.data?.instructor.includes(user.data?.name)) {
|
||||
return true
|
||||
}
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const allowInstructorContent = () => {
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
@@ -437,4 +387,12 @@ const allowEdit = () => {
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codex-editor__redactor {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.embed-tool__caption {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,8 +84,14 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/batches/:batchName/edit',
|
||||
name: 'CreateBatch',
|
||||
component: () => import('@/pages/CreateBatch.vue'),
|
||||
name: 'BatchCreation',
|
||||
component: () => import('@/pages/BatchCreation.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/batches/:batchName/edit',
|
||||
name: 'JobCreation',
|
||||
component: () => import('@/pages/JobCreation.vue'),
|
||||
props: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,10 +2,11 @@ import { toast } from 'frappe-ui'
|
||||
import { useDateFormat, useTimeAgo } from '@vueuse/core'
|
||||
import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next'
|
||||
import { Quiz } from '@/utils/quiz'
|
||||
import { Upload } from '@/utils/upload'
|
||||
import Header from '@editorjs/header'
|
||||
import Paragraph from '@editorjs/paragraph'
|
||||
import List from '@editorjs/list'
|
||||
import Embed from '@editorjs/embed'
|
||||
import NestedList from '@editorjs/nested-list'
|
||||
|
||||
export function createToast(options) {
|
||||
toast({
|
||||
@@ -69,10 +70,25 @@ export function getFileSize(file_size) {
|
||||
return value
|
||||
}
|
||||
|
||||
export function 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,
|
||||
})
|
||||
}
|
||||
|
||||
export function getEditorTools() {
|
||||
return {
|
||||
header: Header,
|
||||
quiz: Quiz,
|
||||
upload: Upload,
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
@@ -80,9 +96,15 @@ export function getEditorTools() {
|
||||
preserveBlank: true,
|
||||
},
|
||||
},
|
||||
list: List,
|
||||
list: {
|
||||
class: NestedList,
|
||||
config: {
|
||||
defaultStyle: 'ordered',
|
||||
},
|
||||
},
|
||||
embed: {
|
||||
class: Embed,
|
||||
inlineToolbar: false,
|
||||
config: {
|
||||
services: {
|
||||
youtube: true,
|
||||
|
||||
@@ -4,13 +4,6 @@ import { usersStore } from '../stores/user'
|
||||
import translationPlugin from '../translation'
|
||||
|
||||
export class Quiz {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Quiz',
|
||||
icon: `<img src="/assets/lms/icons/quiz.svg" width="15" height="15">`,
|
||||
}
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.data = data
|
||||
this.readOnly = readOnly
|
||||
@@ -42,7 +35,7 @@ export class Quiz {
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
}
|
||||
return `<div class='border rounded-md p-10 text-center'>
|
||||
return `<div class='border rounded-md p-10 text-center mb-2'>
|
||||
<span class="font-medium">
|
||||
Quiz: ${quiz}
|
||||
</span>
|
||||
|
||||
43
frontend/src/utils/upload.js
Normal file
43
frontend/src/utils/upload.js
Normal file
@@ -0,0 +1,43 @@
|
||||
export class Upload {
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.data = data
|
||||
this.readOnly = readOnly
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
return true
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div')
|
||||
this.wrapper.innerHTML = this.renderUpload(this.data)
|
||||
return this.wrapper
|
||||
}
|
||||
|
||||
renderUpload(file) {
|
||||
if (file.file_type == 'video') {
|
||||
return `<video controls width='100%' controls controlsList='nodownload' class="mb-4">
|
||||
<source src=${encodeURI(file.file_url)} type='video/mp4'>
|
||||
</video>`
|
||||
} else if (file.file_type == 'audio') {
|
||||
return `<audio controls width='100%' controls controlsList='nodownload' class="mb-4">
|
||||
<source src=${encodeURI(file.file_url)} type='audio/mp3'>
|
||||
</audio>`
|
||||
} else if (file.file_type == 'pdf') {
|
||||
return `<iframe src="${encodeURI(
|
||||
file.file_url
|
||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
||||
} else {
|
||||
return `<img class="mb-4" src=${encodeURI(
|
||||
file.file_url
|
||||
)} width='100%'>`
|
||||
}
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
return {
|
||||
file_url: this.data.file_url,
|
||||
file_type: this.data.file_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user