1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -71,6 +71,7 @@ declare module 'vue' {
|
||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
|
||||
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"plyr": "^3.7.8",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="flex items-center justify-between space-x-2 mb-4 px-2"
|
||||
@@ -17,9 +17,6 @@
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
{{ __('Add Chapter') }}
|
||||
</Button>
|
||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||
</span> -->
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
|
||||
16
frontend/src/components/Icons/Play.vue
Normal file
16
frontend/src/components/Icons/Play.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 68 75"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 6.78182C0 1.60212 5.5742 -1.65958 10.09 0.879521L64.09 31.2545C68.6916 33.8443 68.6916 40.4693 64.09 43.0595L10.09 73.4345C5.5744 75.9736 0 72.7119 0 67.5322V6.78182ZM26.2695 38.5201C26.2695 37.3248 25.2265 37.9342 26.2695 38.5201C27.332 39.1178 27.332 37.9225 26.2695 38.5201Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -653,3 +653,8 @@ const getSubmissionColumns = () => {
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
p {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,53 @@
|
||||
<template>
|
||||
<div ref="videoContainer" class="video-block group relative">
|
||||
<div ref="videoContainer" class="video-block relative group">
|
||||
<video
|
||||
@timeupdate="updateTime"
|
||||
@ended="videoEnded"
|
||||
@click="togglePlay"
|
||||
oncontextmenu="return false"
|
||||
class="rounded-lg border border-gray-100 group cursor-pointer"
|
||||
class="rounded-md border border-gray-100 cursor-pointer"
|
||||
ref="videoRef"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
<div
|
||||
class="flex items-center space-x-2 bg-surface-gray-3 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
|
||||
v-if="!playing"
|
||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||
@click="playVideo"
|
||||
>
|
||||
<div
|
||||
class="rounded-full p-4 pl-4.5"
|
||||
style="
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 0, 0, 0.3) 0%,
|
||||
rgba(0, 0, 0, 0.4) 50%
|
||||
);
|
||||
"
|
||||
>
|
||||
<Play />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
|
||||
:class="{
|
||||
'invisible group-hover:visible': playing,
|
||||
}"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
<Play
|
||||
v-if="!playing"
|
||||
@click="playVideo"
|
||||
class="w-4 h-4 text-ink-gray-9"
|
||||
class="size-4 text-ink-gray-9"
|
||||
/>
|
||||
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-ink-gray-9" />
|
||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" @click="toggleMute">
|
||||
<template #icon>
|
||||
<Volume2 v-if="!muted" class="w-4 h-4 text-ink-gray-9" />
|
||||
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" />
|
||||
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
||||
<VolumeX v-else class="size-5 text-ink-white" />
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
@@ -38,12 +59,12 @@
|
||||
@input="changeCurrentTime"
|
||||
class="duration-slider w-full h-1"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
<span class="text-sm font-semibold">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</span>
|
||||
<Button variant="ghost" @click="toggleFullscreen">
|
||||
<template #icon>
|
||||
<Maximize class="w-4 h-4 text-ink-gray-9" />
|
||||
<Maximize class="size-5 text-ink-white" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,8 +72,9 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Button } from 'frappe-ui'
|
||||
import Play from '@/components/Icons/Play.vue'
|
||||
|
||||
const videoRef = ref(null)
|
||||
const videoContainer = ref(null)
|
||||
@@ -147,7 +169,6 @@ const toggleFullscreen = () => {
|
||||
<style scoped>
|
||||
.video-block {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -165,15 +186,16 @@ iframe {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: theme('colors.gray.400');
|
||||
border-radius: 10px;
|
||||
background-color: theme('colors.gray.100');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duration-slider::-webkit-slider-thumb {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
width: 2px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
background-color: theme('colors.gray.900');
|
||||
background-color: theme('colors.gray.500');
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
@@ -186,7 +208,7 @@ iframe {
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
box-shadow: -500px 0 0 500px theme('colors.gray.900');
|
||||
box-shadow: -500px 0 0 500px theme('colors.gray.600');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-4"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="courses.data?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
||||
>
|
||||
<router-link
|
||||
v-for="course in courses.data"
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<CertificationLinks :courseName="courseName" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Tooltip v-if="lesson.data?.membership" :text="__('Zen Mode')">
|
||||
<Button @click="goFullScreen()">
|
||||
<template #icon>
|
||||
<Focus class="w-4 h-4 stroke-2" />
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CertificationLinks :courseName="courseName" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||
<div v-if="lesson.data.no_preview" class="border-r">
|
||||
@@ -33,146 +42,187 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="border-r container pt-5 pb-10 px-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
<div class="flex items-center mt-2 md:mt-0">
|
||||
<router-link
|
||||
v-if="lesson.data.prev"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.prev.split('.')[0],
|
||||
lessonNumber: lesson.data.prev.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="mr-2">
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="mr-2">
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="lesson.data.next"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.next.split('.')[0],
|
||||
lessonNumber: lesson.data.next.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="lessonContainer"
|
||||
class="bg-surface-white"
|
||||
:class="{
|
||||
'overflow-y-auto': zenModeEnabled,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
||||
:class="{
|
||||
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
|
||||
}"
|
||||
>
|
||||
<div class="text-ink-gray-5 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="zenModeEnabled"
|
||||
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
|
||||
>
|
||||
<span>
|
||||
{{ lesson.data.chapter_title }} -
|
||||
{{ lesson.data.course_title }}
|
||||
</span>
|
||||
<Info class="size-3" />
|
||||
<div
|
||||
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
|
||||
>
|
||||
{{ Math.ceil(lesson.data.membership.progress) }}%
|
||||
{{ __('completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
||||
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode">
|
||||
<template #icon>
|
||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="lesson.data.prev"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.prev.split('.')[0],
|
||||
lessonNumber: lesson.data.prev.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="lesson.data.next"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.next.split('.')[0],
|
||||
lessonNumber: lesson.data.next.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||
>
|
||||
<div class="text-ink-gray-5 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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-6"
|
||||
>
|
||||
<LessonContent :content="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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-5"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
:key="lesson.data.name"
|
||||
/>
|
||||
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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent :content="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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<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-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20" ref="discussionsContainer">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
:key="lesson.data.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
@@ -202,8 +252,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Button, usePageMeta } from 'frappe-ui'
|
||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
watch,
|
||||
inject,
|
||||
ref,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
@@ -212,6 +276,9 @@ import {
|
||||
ChevronRight,
|
||||
LockKeyholeIcon,
|
||||
LogIn,
|
||||
Focus,
|
||||
Info,
|
||||
MessageCircleQuestion,
|
||||
} from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools } from '../utils'
|
||||
@@ -221,6 +288,8 @@ import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
@@ -229,6 +298,10 @@ const allowDiscussions = ref(false)
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const lessonProgress = ref(0)
|
||||
const lessonContainer = ref(null)
|
||||
const zenModeEnabled = ref(false)
|
||||
const hasQuiz = ref(false)
|
||||
const discussionsContainer = ref(null)
|
||||
const timer = ref(0)
|
||||
const { brand } = sessionStore()
|
||||
let timerInterval
|
||||
@@ -250,11 +323,59 @@ const props = defineProps({
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
enablePlyr()
|
||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||
})
|
||||
|
||||
const attachFullscreenEvent = () => {
|
||||
if (document.fullscreenElement) {
|
||||
zenModeEnabled.value = true
|
||||
allowDiscussions.value = false
|
||||
} else {
|
||||
zenModeEnabled.value = false
|
||||
if (!hasQuiz.value) {
|
||||
allowDiscussions.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||
})
|
||||
|
||||
const enablePlyr = () => {
|
||||
setTimeout(() => {
|
||||
const videoElement = document.getElementsByClassName('video-player')
|
||||
if (videoElement.length === 0) return
|
||||
|
||||
const src = document
|
||||
.getElementsByClassName('video-player')[0]
|
||||
.getAttribute('src')
|
||||
if (src) {
|
||||
let videoID = src.split('/').pop()
|
||||
document
|
||||
.getElementsByClassName('video-player')[0]
|
||||
.setAttribute('data-plyr-embed-id', videoID)
|
||||
}
|
||||
new Plyr('.video-player', {
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
},
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'fullscreen',
|
||||
],
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const lesson = createResource({
|
||||
url: 'lms.lms.utils.get_lesson',
|
||||
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
@@ -263,36 +384,37 @@ const lesson = createResource({
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (Object.keys(data).length === 0) {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: props.courseName },
|
||||
})
|
||||
return
|
||||
}
|
||||
lessonProgress.value = data.membership?.progress
|
||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||
if (
|
||||
data.instructor_content &&
|
||||
JSON.parse(data.instructor_content)?.blocks?.length > 1
|
||||
)
|
||||
instructorEditor.value = renderEditor(
|
||||
'instructor-content',
|
||||
data.instructor_content
|
||||
)
|
||||
editor.value?.isReady.then(() => {
|
||||
checkIfDiscussionsAllowed()
|
||||
})
|
||||
|
||||
if (!editor.value && data.body) {
|
||||
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
||||
const hasQuiz = quizRegex.test(data.body)
|
||||
if (!hasQuiz) allowDiscussions.value = true
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const setupLesson = (data) => {
|
||||
if (Object.keys(data).length === 0) {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: props.courseName },
|
||||
})
|
||||
return
|
||||
}
|
||||
lessonProgress.value = data.membership?.progress
|
||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||
if (
|
||||
data.instructor_content &&
|
||||
JSON.parse(data.instructor_content)?.blocks?.length > 1
|
||||
)
|
||||
instructorEditor.value = renderEditor(
|
||||
'instructor-content',
|
||||
data.instructor_content
|
||||
)
|
||||
editor.value?.isReady.then(() => {
|
||||
checkIfDiscussionsAllowed()
|
||||
})
|
||||
|
||||
if (!editor.value && data.body) {
|
||||
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
||||
hasQuiz.value = quizRegex.test(data.body)
|
||||
if (!hasQuiz.value) allowDiscussions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const renderEditor = (holder, content) => {
|
||||
// empty the holder
|
||||
if (document.getElementById(holder))
|
||||
@@ -362,10 +484,18 @@ watch(
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
startTimer()
|
||||
enablePlyr()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lesson.data,
|
||||
(data) => {
|
||||
setupLesson(data)
|
||||
}
|
||||
)
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
@@ -381,13 +511,13 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const checkIfDiscussionsAllowed = () => {
|
||||
let quizPresent = false
|
||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||
if (block.type === 'quiz') quizPresent = true
|
||||
if (block.type === 'quiz') hasQuiz.value = true
|
||||
})
|
||||
|
||||
if (
|
||||
!quizPresent &&
|
||||
!hasQuiz.value &&
|
||||
!zenModeEnabled.value &&
|
||||
(lesson.data?.membership ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor)
|
||||
@@ -431,6 +561,37 @@ const enrollStudent = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const goFullScreen = () => {
|
||||
if (lessonContainer.value.requestFullscreen) {
|
||||
lessonContainer.value.requestFullscreen()
|
||||
} else if (lessonContainer.value.mozRequestFullScreen) {
|
||||
lessonContainer.value.mozRequestFullScreen()
|
||||
} else if (lessonContainer.value.webkitRequestFullscreen) {
|
||||
lessonContainer.value.webkitRequestFullscreen()
|
||||
} else if (lessonContainer.value.msRequestFullscreen) {
|
||||
lessonContainer.value.msRequestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
const showDiscussionsInZenMode = () => {
|
||||
if (allowDiscussions.value) {
|
||||
allowDiscussions.value = false
|
||||
} else {
|
||||
allowDiscussions.value = true
|
||||
scrollDiscussionsIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
const scrollDiscussionsIntoView = () => {
|
||||
nextTick(() => {
|
||||
discussionsContainer.value?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
@@ -604,4 +765,30 @@ usePageMeta(() => {
|
||||
.tc-table {
|
||||
border-left: 1px solid #e8e8eb;
|
||||
}
|
||||
|
||||
.plyr__volume input[type='range'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0.5) 50%
|
||||
);
|
||||
}
|
||||
|
||||
.plyr__control:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.plyr--video {
|
||||
border: 1px solid theme('colors.gray.200');
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--plyr-range-fill-background: white;
|
||||
--plyr-video-control-background-hover: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,14 +92,17 @@ import {
|
||||
inject,
|
||||
ref,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonHelp from '@/components/LessonHelp.vue'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { AppleIcon, ChevronRight } from 'lucide-vue-next'
|
||||
import { createToast, getEditorTools } from '@/utils'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const editor = ref(null)
|
||||
@@ -133,6 +136,7 @@ onMounted(() => {
|
||||
editor.value = renderEditor('content')
|
||||
instructorEditor.value = renderEditor('instructor-notes')
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
enablePlyr()
|
||||
})
|
||||
|
||||
const renderEditor = (holder) => {
|
||||
@@ -141,6 +145,9 @@ const renderEditor = (holder) => {
|
||||
tools: getEditorTools(true),
|
||||
autofocus: true,
|
||||
defaultBlock: 'markdown',
|
||||
onChange: async (api, event) => {
|
||||
enablePlyr()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -463,6 +470,37 @@ const showToast = (title, text, icon) => {
|
||||
})
|
||||
}
|
||||
|
||||
const enablePlyr = () => {
|
||||
setTimeout(() => {
|
||||
const videoElement = document.getElementsByClassName('video-player')
|
||||
if (videoElement.length === 0) return
|
||||
|
||||
const src = document
|
||||
.getElementsByClassName('video-player')[0]
|
||||
.getAttribute('src')
|
||||
if (src) {
|
||||
let videoID = src.split('/').pop()
|
||||
document
|
||||
.getElementsByClassName('video-player')[0]
|
||||
.setAttribute('data-plyr-embed-id', videoID)
|
||||
}
|
||||
new Plyr('.video-player', {
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
},
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'fullscreen',
|
||||
],
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
@@ -639,4 +677,30 @@ iframe {
|
||||
.ce-popover-item[data-item-name='markdown'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.plyr__volume input[type='range'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0.5) 50%
|
||||
);
|
||||
}
|
||||
|
||||
.plyr__control:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.plyr--video {
|
||||
border: 1px solid theme('colors.gray.200');
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--plyr-range-fill-background: white;
|
||||
--plyr-video-control-background-hover: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -87,7 +87,6 @@ import { sessionStore } from '@/stores/session'
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
console.log(user.data?.sitename)
|
||||
|
||||
const persona = reactive({
|
||||
role: null,
|
||||
|
||||
@@ -199,63 +199,15 @@ export function getEditorTools() {
|
||||
services: {
|
||||
youtube: {
|
||||
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
|
||||
embedUrl:
|
||||
'https://www.youtube.com/embed/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||
};" frameborder="0" allowfullscreen></iframe>`,
|
||||
id: ([id, params]) => {
|
||||
if (!params && id) {
|
||||
return id
|
||||
}
|
||||
|
||||
const paramsMap = {
|
||||
start: 'start',
|
||||
end: 'end',
|
||||
t: 'start',
|
||||
// eslint-disable-next-line camelcase
|
||||
time_continue: 'start',
|
||||
list: 'list',
|
||||
}
|
||||
|
||||
let newParams = params
|
||||
.slice(1)
|
||||
.split('&')
|
||||
.map((param) => {
|
||||
const [name, value] = param.split('=')
|
||||
|
||||
if (!id && name === 'v') {
|
||||
id = value
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!paramsMap[name]) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
value === 'LL' ||
|
||||
value.startsWith('RDMM') ||
|
||||
value.startsWith('FL')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${paramsMap[name]}=${value}`
|
||||
})
|
||||
.filter((param) => !!param)
|
||||
|
||||
return id + '?' + newParams.join('&')
|
||||
},
|
||||
embedUrl: '<%= remote_id %>',
|
||||
/* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&iv_load_policy=3&modestbranding=1&playsinline=1&showinfo=0&rel=0&enablejsapi=1' */
|
||||
html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
|
||||
id: ([id]) => id,
|
||||
},
|
||||
vimeo: {
|
||||
regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
|
||||
embedUrl:
|
||||
'https://player.vimeo.com/video/<%= remote_id %>',
|
||||
html: `<iframe style="width:100%; height: ${
|
||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||
};" frameborder="0" allowfullscreen></iframe>`,
|
||||
embedUrl: '<%= remote_id %>',
|
||||
html: `<div class="video-player" data-plyr-provider="vimeo"></div>`,
|
||||
id: ([id]) => id,
|
||||
},
|
||||
cloudflareStream: {
|
||||
|
||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
||||
'engine.io-client',
|
||||
'tailwind.config.js',
|
||||
'highlight.js',
|
||||
'plyr',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
2838
frontend/yarn.lock
Normal file
2838
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1314,6 +1314,9 @@ def get_lesson(course, chapter, lesson):
|
||||
else:
|
||||
progress = get_progress(course, lesson_details.name)
|
||||
|
||||
lesson_details.chapter_title = frappe.db.get_value(
|
||||
"Course Chapter", chapter_name, "title"
|
||||
)
|
||||
lesson_details.rendered_content = render_html(lesson_details)
|
||||
neighbours = get_neighbour_lesson(course, chapter, lesson)
|
||||
lesson_details.next = neighbours["next"]
|
||||
|
||||
Reference in New Issue
Block a user