Compare commits

...

24 Commits

Author SHA1 Message Date
Frappe PR Bot
6a68ae989e chore(release): Bumped to Version 2.31.0 2025-06-16 11:49:38 +00:00
Jannat Patel
00993da781 Merge pull request #1577 from pateljannat/issues-115
fix: misc issues
2025-06-16 17:09:33 +05:30
Jannat Patel
e9ef67e402 chore: regenerated yarn lock 2025-06-16 16:55:31 +05:30
Jannat Patel
83ebfececf feat: edit related courses from frontend 2025-06-16 15:13:30 +05:30
Jannat Patel
ec8bf6251f Merge branch 'develop' of https://github.com/frappe/lms into issues-115 2025-06-16 13:07:26 +05:30
Jannat Patel
1b2874b3a5 Merge pull request #1565 from OsafAliSayed/related_courses
Feat: Related courses
2025-06-16 13:07:18 +05:30
Jannat Patel
0ac1053a71 Merge pull request #1575 from frappe/pot_develop_2025-06-13
chore: update POT file
2025-06-16 12:55:02 +05:30
Jannat Patel
224d270952 Merge pull request #1572 from harshpwctech/develop
feat: Embedding for Bunny Stream
2025-06-16 12:54:49 +05:30
Jannat Patel
c6137545cd ci: verify yarn lock file 2025-06-16 12:53:53 +05:30
Jannat Patel
335417f9f4 fix: persona form role issue 2025-06-16 12:44:29 +05:30
Jannat Patel
cb797223ed fix: time markers on video slider for quiz 2025-06-16 12:07:39 +05:30
Jannat Patel
3a2a0313ac fix: show an intermediate dialog informing users of the quiz if its in between video 2025-06-16 11:22:29 +05:30
Jannat Patel
e221a5a73a Merge branch 'develop' of https://github.com/frappe/lms into issues-115 2025-06-16 10:47:47 +05:30
frappe-pr-bot
2b7aaf095f chore: update POT file 2025-06-13 16:04:26 +00:00
Jannat Patel
6f01e7b8d8 fix: job count 2025-06-13 20:33:51 +05:30
Jannat Patel
d594419200 feat: show live class joining and leaving time in attendance list 2025-06-12 23:18:35 +05:30
Jannat Patel
bf50e3f898 Merge pull request #1571 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-11 20:05:43 +05:30
safe user
d434f1781f feat: Embedding for Bunny Stream 2025-06-11 06:53:26 +00:00
safe user
3f311a45ef feat: Embedding for Bunny Stream 2025-06-11 06:42:29 +00:00
Jannat Patel
9293b7796e chore: Serbian (Latin) translations 2025-06-11 03:33:13 +05:30
OsafAliSayed
b1e7883526 fix(relatedCourses): remove loading component 2025-06-10 18:03:43 +00:00
OsafAliSayed
212800155b style(linter): apply linting fixes 2025-06-09 06:13:21 +00:00
OsafAliSayed
c241bf2104 feat(related-courses): add related courses component 2025-06-09 06:13:21 +00:00
OsafAliSayed
bda61f32f3 feat(related-courses): add related courses frontend 2025-06-09 06:11:40 +00:00
24 changed files with 791 additions and 590 deletions

View File

@@ -83,6 +83,7 @@ declare module 'vue' {
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default'] QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default'] QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default'] Rating: typeof import('./src/components/Controls/Rating.vue')['default']
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default'] ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View File

@@ -1,7 +1,9 @@
<template> <template>
<FrappeUIProvider> <FrappeUIProvider>
<Layout> <Layout>
<router-view /> <div class="text-base">
<router-view />
</div>
</Layout> </Layout>
<Dialogs /> <Dialogs />
</FrappeUIProvider> </FrappeUIProvider>

View File

@@ -191,7 +191,7 @@ import {
h, h,
onUnmounted, onUnmounted,
} from 'vue' } from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '@/utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'

View File

@@ -70,9 +70,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge } from 'frappe-ui' import { formatTime } from '@/utils'
import { formatTime } from '../utils' import { Clock, Globe } from 'lucide-vue-next'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'

View File

@@ -148,7 +148,7 @@
</template> </template>
<script setup> <script setup>
import { Button, createResource, Tooltip, toast } from 'frappe-ui' import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue' import { getCurrentInstance, inject, ref, watch } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
@@ -197,13 +197,22 @@ const props = defineProps({
const outline = createResource({ const outline = createResource({
url: 'lms.lms.utils.get_course_outline', url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName], cache: ['course_outline', props.courseName],
params: { makeParams() {
course: props.courseName, return {
progress: props.getProgress, course: props.courseName,
progress: props.getProgress,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
outline.reload()
}
)
const deleteLesson = createResource({ const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson', url: 'lms.lms.api.delete_lesson',
makeParams(values) { makeParams(values) {

View File

@@ -64,7 +64,7 @@
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { computed, ref, inject } from 'vue' import { watch, ref, inject } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import ReviewModal from '@/components/Modals/ReviewModal.vue' import ReviewModal from '@/components/Modals/ReviewModal.vue'
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
const reviews = createResource({ const reviews = createResource({
url: 'lms.lms.utils.get_reviews', url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName], cache: ['course_reviews', props.courseName],
params: { makeParams() {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
reviews.reload()
}
)
const showReviewModal = ref(false) const showReviewModal = ref(false)
function openReviewModal() { function openReviewModal() {

View File

@@ -94,7 +94,7 @@
</template> </template>
<script setup> <script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui' import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, onUnmounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'

View File

@@ -69,7 +69,7 @@
<script setup> <script setup>
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils' import { singularize, timeAgo } from '@/utils'
import { ref, onMounted, inject, onUnmounted } from 'vue' import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue' import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue' import DiscussionModal from '@/components/Modals/DiscussionModal.vue'

View File

@@ -54,7 +54,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue' import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'

View File

@@ -3,44 +3,59 @@
v-model="show" v-model="show"
:options="{ :options="{
title: __('Attendance for Class - {0}').format(live_class?.title), title: __('Attendance for Class - {0}').format(live_class?.title),
size: 'xl', size: '4xl',
}" }"
> >
<template #body-content> <template #body-content>
<div class="space-y-5"> <div
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
>
<div>
{{ __('Member') }}
</div>
<div class="grid grid-cols-3 gap-20">
<div>
{{ __('Joined at') }}
</div>
<div class="text-center">
{{ __('Left at') }}
</div>
<div>
{{ __('Attended for') }}
</div>
</div>
</div>
<div class="divide-y text-base">
<div <div
v-for="participant in participants.data" v-for="participant in participants.data"
@click="redirectToProfile(participant.member_username)" @click="redirectToProfile(participant.member_username)"
class="cursor-pointer text-base w-fit" class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
> >
<Tooltip placement="right"> <div class="flex items-center space-x-2">
<div class="flex items-center space-x-2"> <Avatar
<Avatar :image="participant.member_image"
:image="participant.member_image" :label="participant.member_name"
:label="participant.member_name" size="xl"
size="xl" />
/> <div class="space-y-1">
<div class="space-y-1"> <div class="font-medium">
<div class="font-medium"> {{ participant.member_name }}
{{ participant.member_name }} </div>
</div> <div>
<div> {{ participant.member }}
{{ participant.member }}
</div>
</div> </div>
</div> </div>
<template #body> </div>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl" <div class="grid grid-cols-3 gap-20 text-right">
> <div>
{{ dayjs(participant.joined_at).format('HH:mm a') }} - {{ dayjs(participant.joined_at).format('HH:mm a') }}
{{ dayjs(participant.left_at).format('HH:mm a') }} </div>
<br /> <div>
{{ __('attended for') }} {{ participant.duration }} {{ dayjs(participant.left_at).format('HH:mm a') }}
{{ __('minutes') }} </div>
</div> <div>{{ participant.duration }} {{ __('minutes') }}</div>
</template> </div>
</Tooltip>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,52 @@
<template>
<div v-if="relatedCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-6">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Related Courses') }}
</div>
</div>
<div
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 relatedCourses.data"
:key="course.name"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
class="cursor-pointer"
>
<CourseCard :course="course" />
</router-link>
</div>
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { watch } from 'vue'
import CourseCard from '@/components/CourseCard.vue'
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const relatedCourses = createResource({
url: 'lms.lms.utils.get_related_courses',
cache: ['related_courses', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
relatedCourses.reload()
}
)
</script>

View File

@@ -116,7 +116,7 @@ import {
EllipsisVertical, EllipsisVertical,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue' import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '../utils' import { formatTime } from '@/utils'
import { Button, createResource, call } from 'frappe-ui' import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue' import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue' import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'

View File

@@ -1,9 +1,6 @@
<template> <template>
<div> <div>
<div <div v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
v-if="quizzes.length && !showQuiz && readOnly"
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
>
{{ {{
__('This video contains {0} {1}:').format( __('This video contains {0} {1}:').format(
quizzes.length, quizzes.length,
@@ -12,8 +9,10 @@
}} }}
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1"> <div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
<span> {{ index + 1 }}. {{ quiz.quiz }} </span> <span>
{{ __('at {0}').format(formatTimestamp(quiz.time)) }} {{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
</span>
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
</div> </div>
</div> </div>
<div <div
@@ -65,15 +64,28 @@
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" /> <Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<input
type="range" <div class="relative flex items-center w-full flex-1">
min="0" <input
:max="duration" type="range"
step="0.1" min="0"
v-model="currentTime" :max="duration"
@input="changeCurrentTime" step="0.1"
class="duration-slider w-full h-1" v-model="currentTime"
/> @input="changeCurrentTime"
class="duration-slider h-1"
/>
<!-- QUIZ MARKERS -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<div
v-for="(quiz, index) in quizzes"
:key="index"
:style="getQuizMarkerStyle(quiz.time)"
class="absolute top-0 h-full w-2 bg-surface-amber-3"
></div>
</div>
</div>
<span class="text-sm font-medium"> <span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }} {{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span> </span>
@@ -116,11 +128,27 @@
:saveQuizzes="saveQuizzes" :saveQuizzes="saveQuizzes"
:duration="duration" :duration="duration"
/> />
<Dialog
v-model="showQuizLoader"
:options="{
size: 'sm',
}"
>
<template #body>
<div class="p-5 text-base">
{{
__(
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
}}
</div>
</template>
</Dialog>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui' import { Button, Dialog } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils' import { formatSeconds, formatTimestamp } from '@/utils'
import Play from '@/components/Icons/Play.vue' import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue' import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
@@ -133,6 +161,8 @@ let duration = ref(0)
let muted = ref(false) let muted = ref(false)
const showQuizModal = ref(false) const showQuizModal = ref(false)
const showQuiz = ref(false) const showQuiz = ref(false)
const showQuizLoader = ref(false)
const quizLoadTimer = ref(0)
const currentQuiz = ref(null) const currentQuiz = ref(null)
const nextQuiz = ref({}) const nextQuiz = ref({})
@@ -175,12 +205,24 @@ const updateCurrentTime = () => {
playing.value = false playing.value = false
videoRef.value.onTimeupdate = null videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true quizLoadTimer.value = 7
} }
} }
}, 0) }, 0)
} }
watch(quizLoadTimer, () => {
if (quizLoadTimer.value > 0) {
showQuizLoader.value = true
setTimeout(() => {
quizLoadTimer.value -= 1
}, 1000)
} else {
showQuizLoader.value = false
showQuiz.value = true
}
})
const resumeVideo = (restart = false) => { const resumeVideo = (restart = false) => {
showQuiz.value = false showQuiz.value = false
currentQuiz.value = null currentQuiz.value = null
@@ -259,6 +301,13 @@ const toggleFullscreen = () => {
videoContainer.value.requestFullscreen() videoContainer.value.requestFullscreen()
} }
} }
const getQuizMarkerStyle = (time) => {
const percentage = ((time - 7) / Math.ceil(duration.value)) * 100
return {
left: `${percentage}%`,
}
}
</script> </script>
<style scoped> <style scoped>
@@ -278,11 +327,10 @@ iframe {
} }
.duration-slider { .duration-slider {
flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
border-radius: 10px; border-radius: 10px;
background-color: theme('colors.gray.100'); background-color: theme('colors.gray.600');
cursor: pointer; cursor: pointer;
} }
@@ -290,20 +338,20 @@ iframe {
width: 2px; width: 2px;
border-radius: 50%; border-radius: 50%;
-webkit-appearance: none; -webkit-appearance: none;
background-color: theme('colors.gray.500'); background-color: theme('colors.white');
} }
@media screen and (-webkit-min-device-pixel-ratio: 0) { @media screen and (-webkit-min-device-pixel-ratio: 0) {
input[type='range'] { input[type='range'] {
overflow: hidden; overflow: hidden;
width: 150px; width: 100%;
-webkit-appearance: none; -webkit-appearance: none;
} }
input[type='range']::-webkit-slider-thumb { input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
cursor: pointer; cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.600'); box-shadow: -500px 0 0 500px theme('colors.white');
} }
} }
</style> </style>

View File

@@ -88,6 +88,7 @@
<CourseCardOverlay :course="course" /> <CourseCardOverlay :course="course" />
</div> </div>
</div> </div>
<RelatedCourses :courseName="course.data.name" />
</div> </div>
</div> </div>
</template> </template>
@@ -99,7 +100,7 @@ import {
Tooltip, Tooltip,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed } from 'vue' import { computed, watch } from 'vue'
import { Users, Star } from 'lucide-vue-next' import { Users, Star } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue' import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
@@ -107,6 +108,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue' import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -120,12 +122,21 @@ const props = defineProps({
const course = createResource({ const course = createResource({
url: 'lms.lms.utils.get_course_details', url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName], cache: ['course', props.courseName],
params: { makeParams() {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: true, auto: true,
}) })
watch(
() => props.courseName,
() => {
course.reload()
}
)
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }] let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({ items.push({

View File

@@ -199,6 +199,21 @@
) )
" "
/> />
<MultiSelect
v-model="related_courses"
doctype="LMS Course"
:label="__('Related Courses')"
:filters="{ name: ['!=', courseResource.data?.name] }"
:onCreate="
(close) => {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
}
"
/>
</div> </div>
<div class="px-10 pb-5 space-y-5 border-b"> <div class="px-10 pb-5 space-y-5 border-b">
@@ -319,6 +334,7 @@ const newTag = ref('')
const { brand } = sessionStore() const { brand } = sessionStore()
const router = useRouter() const router = useRouter()
const instructors = ref([]) const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance() const app = getCurrentInstance()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties const { $dialog } = app.appContext.config.globalProperties
@@ -400,6 +416,9 @@ const courseCreationResource = createResource({
instructors: instructors.value.map((instructor) => ({ instructors: instructors.value.map((instructor) => ({
instructor: instructor, instructor: instructor,
})), })),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...values, ...values,
}, },
} }
@@ -418,6 +437,9 @@ const courseEditResource = createResource({
instructors: instructors.value.map((instructor) => ({ instructors: instructors.value.map((instructor) => ({
instructor: instructor, instructor: instructor,
})), })),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...course, ...course,
}, },
} }
@@ -440,6 +462,11 @@ const courseResource = createResource({
data.instructors.forEach((instructor) => { data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor) instructors.value.push(instructor.instructor)
}) })
} else if (key == 'related_courses') {
related_courses.value = []
data.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key] } else if (Object.hasOwn(course, key)) course[key] = data[key]
}) })
let checkboxes = [ let checkboxes = [

View File

@@ -122,9 +122,6 @@ onMounted(() => {
const jobs = createResource({ const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities', url: 'lms.lms.api.get_job_opportunities',
cache: ['jobs'], cache: ['jobs'],
onSuccess(data) {
jobCount.value = data.length
},
}) })
const updateJobs = () => { const updateJobs = () => {
@@ -169,6 +166,10 @@ watch(country, (val) => {
updateJobs() updateJobs()
}) })
watch(jobs, () => {
jobCount.value = jobs.data?.length || 0
})
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
'', '',

View File

@@ -72,7 +72,7 @@ const persona = reactive({
const submitPersona = () => { const submitPersona = () => {
let responses = { let responses = {
site: user.data?.sitename, site: user.data?.sitename,
no_of_students: persona.noOfStudents, role: persona.role,
use_case: persona.useCase, use_case: persona.useCase,
} }
call('lms.lms.api.capture_user_persona', { call('lms.lms.api.capture_user_persona', {

View File

@@ -197,6 +197,14 @@ export function getEditorTools() {
window.innerWidth < 640 ? '15rem' : '30rem' window.innerWidth < 640 ? '15rem' : '30rem'
};" frameborder="0" allowfullscreen></iframe>`, };" frameborder="0" allowfullscreen></iframe>`,
}, },
bunnyStream: {
regex: /https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)/,
embedUrl:
'https://iframe.mediadelivery.net/embed/<%= remote_id %>',
html: `<iframe style="width:100%; height: ${
window.innerWidth < 640 ? '15rem' : '30rem'
};" frameborder="0" allowfullscreen></iframe>`,
},
codepen: true, codepen: true,
aparat: { aparat: {
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/, regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,

View File

@@ -3,6 +3,7 @@ import VideoBlock from '@/components/VideoBlock.vue'
import UploadPlugin from '@/components/UploadPlugin.vue' import UploadPlugin from '@/components/UploadPlugin.vue'
import { h, createApp } from 'vue' import { h, createApp } from 'vue'
import { Upload as UploadIcon } from 'lucide-vue-next' import { Upload as UploadIcon } from 'lucide-vue-next'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from '../translation' import translationPlugin from '../translation'
export class Upload { export class Upload {
@@ -54,6 +55,7 @@ export class Upload {
}, },
}) })
app.use(translationPlugin) app.use(translationPlugin)
app.config.globalProperties.$dialog = createDialog
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} else if (this.isAudio(file.file_type)) { } else if (this.isAudio(file.file_type)) {

View File

@@ -1 +1 @@
__version__ = "2.30.0" __version__ = "2.31.0"

View File

@@ -182,6 +182,7 @@ def get_lesson_icon(body, content):
"youtube", "youtube",
"vimeo", "vimeo",
"cloudflareStream", "cloudflareStream",
"bunnyStream",
]: ]:
return "icon-youtube" return "icon-youtube"
@@ -2171,5 +2172,17 @@ def get_palette(full_name):
return palette[idx % 8] return palette[idx % 8]
@frappe.whitelist(allow_guest=True)
def get_related_courses(course):
related_course_details = []
related_courses = frappe.get_all(
"Related Courses", {"parent": course}, order_by="idx", pluck="course"
)
for related_course in related_courses:
related_course_details.append(get_course_details(related_course))
return related_course_details
def persona_captured(): def persona_captured():
frappe.db.set_single_value("LMS Settings", "persona_captured", 1) frappe.db.set_single_value("LMS Settings", "persona_captured", 1)

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Frappe LMS VERSION\n" "Project-Id-Version: Frappe LMS VERSION\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n" "Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2025-06-06 16:04+0000\n" "POT-Creation-Date: 2025-06-13 16:04+0000\n"
"PO-Revision-Date: 2025-06-06 16:04+0000\n" "PO-Revision-Date: 2025-06-13 16:04+0000\n"
"Last-Translator: jannat@frappe.io\n" "Last-Translator: jannat@frappe.io\n"
"Language-Team: jannat@frappe.io\n" "Language-Team: jannat@frappe.io\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -410,7 +410,7 @@ msgstr ""
msgid "Archived" msgid "Archived"
msgstr "" msgstr ""
#: frontend/src/components/UpcomingEvaluations.vue:169 #: frontend/src/components/UpcomingEvaluations.vue:172
msgid "Are you sure you want to cancel this evaluation? This action cannot be undone." msgid "Are you sure you want to cancel this evaluation? This action cannot be undone."
msgstr "" msgstr ""
@@ -760,12 +760,12 @@ msgstr ""
msgid "CGPA/4" msgid "CGPA/4"
msgstr "" msgstr ""
#: frontend/src/components/UpcomingEvaluations.vue:60 #: frontend/src/components/UpcomingEvaluations.vue:57
#: frontend/src/components/UpcomingEvaluations.vue:174 #: frontend/src/components/UpcomingEvaluations.vue:177
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
#: frontend/src/components/UpcomingEvaluations.vue:168 #: frontend/src/components/UpcomingEvaluations.vue:171
msgid "Cancel this evaluation?" msgid "Cancel this evaluation?"
msgstr "" msgstr ""
@@ -1274,7 +1274,7 @@ msgid "Continue Learning"
msgstr "" msgstr ""
#. Option for the 'Type' (Select) field in DocType 'Job Opportunity' #. Option for the 'Type' (Select) field in DocType 'Job Opportunity'
#: frontend/src/pages/Jobs.vue:177 #: frontend/src/pages/Jobs.vue:178
#: lms/job/doctype/job_opportunity/job_opportunity.json #: lms/job/doctype/job_opportunity/job_opportunity.json
msgid "Contract" msgid "Contract"
msgstr "" msgstr ""
@@ -2309,7 +2309,7 @@ msgid "Free"
msgstr "" msgstr ""
#. Option for the 'Type' (Select) field in DocType 'Job Opportunity' #. Option for the 'Type' (Select) field in DocType 'Job Opportunity'
#: frontend/src/pages/Jobs.vue:178 #: frontend/src/pages/Jobs.vue:179
#: lms/job/doctype/job_opportunity/job_opportunity.json #: lms/job/doctype/job_opportunity/job_opportunity.json
msgid "Freelance" msgid "Freelance"
msgstr "" msgstr ""
@@ -2353,7 +2353,7 @@ msgid "Full Name"
msgstr "" msgstr ""
#. Option for the 'Type' (Select) field in DocType 'Job Opportunity' #. Option for the 'Type' (Select) field in DocType 'Job Opportunity'
#: frontend/src/pages/Jobs.vue:175 #: frontend/src/pages/Jobs.vue:176
#: lms/job/doctype/job_opportunity/job_opportunity.json #: lms/job/doctype/job_opportunity/job_opportunity.json
msgid "Full Time" msgid "Full Time"
msgstr "" msgstr ""
@@ -2853,7 +2853,7 @@ msgstr ""
#. Label of the jobs (Check) field in DocType 'LMS Settings' #. Label of the jobs (Check) field in DocType 'LMS Settings'
#: frontend/src/pages/JobDetail.vue:10 frontend/src/pages/Jobs.vue:8 #: frontend/src/pages/JobDetail.vue:10 frontend/src/pages/Jobs.vue:8
#: frontend/src/pages/Jobs.vue:184 #: frontend/src/pages/Jobs.vue:185
#: lms/lms/doctype/lms_settings/lms_settings.json #: lms/lms/doctype/lms_settings/lms_settings.json
msgid "Jobs" msgid "Jobs"
msgstr "" msgstr ""
@@ -2863,7 +2863,7 @@ msgstr ""
msgid "Join" msgid "Join"
msgstr "" msgstr ""
#: frontend/src/components/UpcomingEvaluations.vue:93 #: frontend/src/components/UpcomingEvaluations.vue:90
msgid "Join Call" msgid "Join Call"
msgstr "" msgstr ""
@@ -3350,10 +3350,6 @@ msgstr ""
msgid "Mark all as read" msgid "Mark all as read"
msgstr "" msgstr ""
#: frontend/src/pages/Notifications.vue:40
msgid "Mark as read"
msgstr ""
#. Label of the marks (Int) field in DocType 'LMS Quiz Question' #. Label of the marks (Int) field in DocType 'LMS Quiz Question'
#. Label of the marks (Int) field in DocType 'LMS Quiz Result' #. Label of the marks (Int) field in DocType 'LMS Quiz Result'
#: frontend/src/components/Modals/Question.vue:40 #: frontend/src/components/Modals/Question.vue:40
@@ -3888,7 +3884,7 @@ msgstr ""
msgid "Not Saved" msgid "Not Saved"
msgstr "" msgstr ""
#: frontend/src/pages/Notifications.vue:54 #: frontend/src/pages/Notifications.vue:53
msgid "Nothing to see here." msgid "Nothing to see here."
msgstr "" msgstr ""
@@ -4075,7 +4071,7 @@ msgid "Pan Number"
msgstr "" msgstr ""
#. Option for the 'Type' (Select) field in DocType 'Job Opportunity' #. Option for the 'Type' (Select) field in DocType 'Job Opportunity'
#: frontend/src/pages/Jobs.vue:176 #: frontend/src/pages/Jobs.vue:177
#: lms/job/doctype/job_opportunity/job_opportunity.json #: lms/job/doctype/job_opportunity/job_opportunity.json
msgid "Part Time" msgid "Part Time"
msgstr "" msgstr ""
@@ -4312,7 +4308,7 @@ msgstr ""
msgid "Please prepare well and be on time for the evaluations." msgid "Please prepare well and be on time for the evaluations."
msgstr "" msgstr ""
#: frontend/src/components/UpcomingEvaluations.vue:101 #: frontend/src/components/UpcomingEvaluations.vue:98
msgid "Please schedule an evaluation to get certified." msgid "Please schedule an evaluation to get certified."
msgstr "" msgstr ""
@@ -4927,7 +4923,7 @@ msgid "Schedule"
msgstr "" msgstr ""
#: frontend/src/components/Modals/EvaluationModal.vue:5 #: frontend/src/components/Modals/EvaluationModal.vue:5
#: frontend/src/components/UpcomingEvaluations.vue:14 #: frontend/src/components/UpcomingEvaluations.vue:11
msgid "Schedule Evaluation" msgid "Schedule Evaluation"
msgstr "" msgstr ""
@@ -6073,7 +6069,7 @@ msgstr ""
msgid "Video Embed Link" msgid "Video Embed Link"
msgstr "" msgstr ""
#: frontend/src/pages/Notifications.vue:38 #: frontend/src/pages/Notifications.vue:39
msgid "View" msgid "View"
msgstr "" msgstr ""
@@ -6208,7 +6204,7 @@ msgstr ""
msgid "Write your answer here" msgid "Write your answer here"
msgstr "" msgstr ""
#: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:96 #: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:95
msgid "You already have an evaluation on {0} at {1} for the course {2}." msgid "You already have an evaluation on {0} at {1} for the course {2}."
msgstr "" msgstr ""
@@ -6261,11 +6257,11 @@ msgstr ""
msgid "You cannot change the roles in read-only mode." msgid "You cannot change the roles in read-only mode."
msgstr "" msgstr ""
#: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:116 #: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:115
msgid "You cannot schedule evaluations after {0}." msgid "You cannot schedule evaluations after {0}."
msgstr "" msgstr ""
#: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:105 #: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:104
msgid "You cannot schedule evaluations for past slots." msgid "You cannot schedule evaluations for past slots."
msgstr "" msgstr ""
@@ -6387,7 +6383,7 @@ msgstr ""
msgid "Your evaluation for the course {0} has been scheduled on {1} at {2} {3}." msgid "Your evaluation for the course {0} has been scheduled on {1} at {2} {3}."
msgstr "" msgstr ""
#: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:126 #: lms/lms/doctype/lms_certificate_request/lms_certificate_request.py:125
msgid "Your evaluation slot has been booked" msgid "Your evaluation slot has been booked"
msgstr "" msgstr ""

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n" "Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n" "Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2025-06-06 16:04+0000\n" "POT-Creation-Date: 2025-06-06 16:04+0000\n"
"PO-Revision-Date: 2025-06-09 22:05\n" "PO-Revision-Date: 2025-06-10 22:03\n"
"Last-Translator: jannat@frappe.io\n" "Last-Translator: jannat@frappe.io\n"
"Language-Team: Serbian (Latin)\n" "Language-Team: Serbian (Latin)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -153,7 +153,7 @@ msgstr "Dodaj lekciju"
#: frontend/src/components/VideoBlock.vue:109 #: frontend/src/components/VideoBlock.vue:109
msgid "Add Quiz to Video" msgid "Add Quiz to Video"
msgstr "" msgstr "Dodaj kviz u video-snimak"
#: frontend/src/pages/ProfileEvaluator.vue:89 #: frontend/src/pages/ProfileEvaluator.vue:89
msgid "Add Slot" msgid "Add Slot"
@@ -214,7 +214,7 @@ msgstr "Dodaj obuke u Vašu grupu"
#: frontend/src/components/Modals/QuizInVideo.vue:5 #: frontend/src/components/Modals/QuizInVideo.vue:5
msgid "Add quiz to this video" msgid "Add quiz to this video"
msgstr "" msgstr "Dodaj kviz u ovaj video-snimak"
#: frontend/src/components/AppSidebar.vue:510 #: frontend/src/components/AppSidebar.vue:510
msgid "Add students to your batch" msgid "Add students to your batch"
@@ -3832,7 +3832,7 @@ msgstr "Nema zakazanih onlajn predavanja"
#: frontend/src/components/Modals/QuizInVideo.vue:93 #: frontend/src/components/Modals/QuizInVideo.vue:93
msgid "No quizzes added yet." msgid "No quizzes added yet."
msgstr "" msgstr "Još uvek nisu dodati kvizovi."
#: frontend/src/components/Modals/EvaluationModal.vue:62 #: frontend/src/components/Modals/EvaluationModal.vue:62
msgid "No slots available for this date." msgid "No slots available for this date."
@@ -4275,7 +4275,7 @@ msgstr "Molimo Vas da unesete ispravno vreme u formatu HH:mm."
#: frontend/src/components/Modals/QuizInVideo.vue:181 #: frontend/src/components/Modals/QuizInVideo.vue:181
msgid "Please enter a valid timestamp" msgid "Please enter a valid timestamp"
msgstr "" msgstr "Molimo Vas da unesete važeći vremenski žig"
#: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py:78 #: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py:78
msgid "Please enter the URL for assignment submission." msgid "Please enter the URL for assignment submission."
@@ -4332,7 +4332,7 @@ msgstr "Molimo Vas da izaberete budući datum i vreme."
#: frontend/src/components/Modals/QuizInVideo.vue:186 #: frontend/src/components/Modals/QuizInVideo.vue:186
msgid "Please select a quiz" msgid "Please select a quiz"
msgstr "" msgstr "Molimo Vas da izaberete kviz"
#: frontend/src/components/Modals/LiveClassModal.vue:192 #: frontend/src/components/Modals/LiveClassModal.vue:192
msgid "Please select a time." msgid "Please select a time."
@@ -4694,7 +4694,7 @@ msgstr "Kvizovi"
#: frontend/src/components/Modals/QuizInVideo.vue:35 #: frontend/src/components/Modals/QuizInVideo.vue:35
msgid "Quizzes in this video" msgid "Quizzes in this video"
msgstr "" msgstr "Kvizovi u ovom video-snimku"
#. Label of the rating (Rating) field in DocType 'LMS Certificate Evaluation' #. Label of the rating (Rating) field in DocType 'LMS Certificate Evaluation'
#. Label of the rating (Data) field in DocType 'LMS Course' #. Label of the rating (Data) field in DocType 'LMS Course'
@@ -4808,7 +4808,7 @@ msgstr "CV"
#: frontend/src/components/Quiz.vue:75 frontend/src/components/Quiz.vue:278 #: frontend/src/components/Quiz.vue:75 frontend/src/components/Quiz.vue:278
msgid "Resume Video" msgid "Resume Video"
msgstr "" msgstr "Nastavi video-snimak"
#. Label of the review (Small Text) field in DocType 'LMS Course Review' #. Label of the review (Small Text) field in DocType 'LMS Course Review'
#. Label of a Link in the LMS Workspace #. Label of a Link in the LMS Workspace
@@ -5277,7 +5277,7 @@ msgstr "Početni URL"
#: frontend/src/components/Quiz.vue:71 #: frontend/src/components/Quiz.vue:71
msgid "Start the Quiz" msgid "Start the Quiz"
msgstr "" msgstr "Započni kviz"
#. Option for the 'Company Type' (Select) field in DocType 'User' #. Option for the 'Company Type' (Select) field in DocType 'User'
#: lms/fixtures/custom_field.json #: lms/fixtures/custom_field.json
@@ -5686,7 +5686,7 @@ msgstr "Sajt se ažurira. Trenutno nisu moguće izmene. Pun pristup će uskoro b
#: frontend/src/components/VideoBlock.vue:8 #: frontend/src/components/VideoBlock.vue:8
msgid "This video contains {0} {1}:" msgid "This video contains {0} {1}:"
msgstr "" msgstr "Ovaj video-snimak sadrži {0} {1}:"
#. Option for the 'Day' (Select) field in DocType 'Evaluator Schedule' #. Option for the 'Day' (Select) field in DocType 'Evaluator Schedule'
#. Option for the 'Day' (Select) field in DocType 'LMS Certificate Request' #. Option for the 'Day' (Select) field in DocType 'LMS Certificate Request'
@@ -5710,15 +5710,15 @@ msgstr "Vremenska preferencija"
#: frontend/src/components/Modals/QuizInVideo.vue:13 #: frontend/src/components/Modals/QuizInVideo.vue:13
msgid "Time in Video" msgid "Time in Video"
msgstr "" msgstr "Vreme u video-snimku"
#: frontend/src/components/Modals/QuizInVideo.vue:220 #: frontend/src/components/Modals/QuizInVideo.vue:220
msgid "Time in Video (minutes)" msgid "Time in Video (minutes)"
msgstr "" msgstr "Vreme u video-snimku (u minutima)"
#: frontend/src/components/Modals/QuizInVideo.vue:173 #: frontend/src/components/Modals/QuizInVideo.vue:173
msgid "Time in video exceeds the total duration of the video." msgid "Time in video exceeds the total duration of the video."
msgstr "" msgstr "Vreme u video-snimku prelazi ukupno trajanje video-snimka."
#: frontend/src/components/Modals/LiveClassModal.vue:44 #: frontend/src/components/Modals/LiveClassModal.vue:44
msgid "Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00" msgid "Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00"
@@ -6338,7 +6338,7 @@ msgstr "Neophodno je da se prvo prijavite da biste se upisali na ovu obuku"
#: frontend/src/components/Quiz.vue:7 #: frontend/src/components/Quiz.vue:7
msgid "You will have to complete the quiz to continue the video" msgid "You will have to complete the quiz to continue the video"
msgstr "" msgstr "Neophodno je da završite kviz kako biste nastavili video-snimak"
#: frontend/src/components/Quiz.vue:30 lms/templates/quiz/quiz.html:11 #: frontend/src/components/Quiz.vue:30 lms/templates/quiz/quiz.html:11
#, python-format #, python-format
@@ -6452,7 +6452,7 @@ msgstr "kandidati"
#: frontend/src/components/VideoBlock.vue:16 #: frontend/src/components/VideoBlock.vue:16
msgid "at {0}" msgid "at {0}"
msgstr "" msgstr "u {0}"
#: frontend/src/components/Modals/LiveClassAttendance.vue:39 #: frontend/src/components/Modals/LiveClassAttendance.vue:39
msgid "attended for" msgid "attended for"

958
yarn.lock

File diff suppressed because it is too large Load Diff