feat: batch creation

This commit is contained in:
Jannat Patel
2024-03-15 21:54:02 +05:30
parent 83a1b03bb7
commit 63bcbb6506
33 changed files with 1536 additions and 887 deletions

View 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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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({

View File

@@ -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()">

View 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>

View 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>

View File

@@ -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: [

View 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>

View File

@@ -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>

View File

@@ -3,7 +3,7 @@
v-model="show"
:options="{
title: __('Add a Student'),
size: 'xl',
size: 'sm',
actions: [
{
label: 'Submit',

View File

@@ -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`
}

View 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>

View File

@@ -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'

View File

@@ -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',

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
<template></template>
<script setup></script>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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,
},
]

View File

@@ -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,

View File

@@ -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>

View 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,
}
}
}