Merge branch 'develop' of https://github.com/frappe/lms into refactor-charts

This commit is contained in:
Jannat Patel
2025-04-25 18:27:50 +05:30
28 changed files with 3603 additions and 5873 deletions

View File

@@ -71,6 +71,7 @@ declare module 'vue' {
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default'] NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.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'] ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default'] Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default'] Quiz: typeof import('./src/components/Quiz.vue')['default']

View File

@@ -31,6 +31,7 @@
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"typescript": "^5.7.2", "typescript": "^5.7.2",

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border-2 hover:bg-surface-gray-2 rounded-md p-4 h-full" class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="h-full"> <div class="">
<div <div
v-if="title && (outline.data?.length || allowEdit)" v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between space-x-2 mb-4 px-2" 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()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }} {{ __('Add Chapter') }}
</Button> </Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div> </div>
<div <div
:class="{ :class="{

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

View File

@@ -1,22 +1,35 @@
<template> <template>
<div class="flex flex-col border rounded-md p-4 h-full"> <div
<div class="flex space-x-4 mb-2"> class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
<img :src="job.company_logo" class="size-8 rounded-full object-contain" /> >
<div class="flex flex-col space-y-1 flex-1"> <div class="flex space-x-4 mb-4">
<div class="flex items-center justify-between"> <div class="flex flex-col space-y-2 flex-1">
<span class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ job.job_title }}
</span>
</div>
<div class="text-xs text-ink-gray-5">
{{ job.company_name }} {{ job.company_name }}
</div> </div>
<span class="font-medium text-ink-gray-7 leading-5">
{{ job.job_title }}
</span>
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
<MapPin class="size-3" />
<span>
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
</span>
</div>
<div
v-if="job.applicants"
class="flex items-center space-x-1 text-sm text-ink-gray-7"
>
<User class="size-3" />
<span>
{{ job.applicants }}
{{ job.applicants > 1 ? __('applicants') : __('applicant') }}
</span>
</div>
</div> </div>
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
</div> </div>
<div class="space-x-4 mt-auto"> <div class="space-x-2 mt-auto">
<Badge>
{{ job.location }}
</Badge>
<Badge> <Badge>
{{ job.type }} {{ job.type }}
</Badge> </Badge>
@@ -24,11 +37,16 @@
{{ dayjs(job.creation).fromNow() }} {{ dayjs(job.creation).fromNow() }}
</Badge> </Badge>
</div> </div>
<!-- <div
class="description text-ink-gray-9 text-sm"
v-html="job.description"
></div> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue' import { inject } from 'vue'
import { Badge } from 'frappe-ui' import { Badge } from 'frappe-ui'
import { MapPin, User } from 'lucide-vue-next'
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
@@ -38,3 +56,15 @@ const props = defineProps({
}, },
}) })
</script> </script>
<style>
.description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin-top: auto;
line-height: 1.5;
}
</style>

View File

@@ -653,3 +653,8 @@ const getSubmissionColumns = () => {
] ]
} }
</script> </script>
<style>
p {
line-height: 1.5rem;
}
</style>

View File

@@ -1,32 +1,53 @@
<template> <template>
<div ref="videoContainer" class="video-block group relative"> <div ref="videoContainer" class="video-block relative group">
<video <video
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
@click="togglePlay" @click="togglePlay"
oncontextmenu="return false" oncontextmenu="return false"
class="rounded-lg border border-gray-100 group cursor-pointer" class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef" ref="videoRef"
> >
<source :src="fileURL" :type="type" /> <source :src="fileURL" :type="type" />
</video> </video>
<div <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"> <Button variant="ghost">
<template #icon> <template #icon>
<Play <Play
v-if="!playing" v-if="!playing"
@click="playVideo" @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> </template>
</Button> </Button>
<Button variant="ghost" @click="toggleMute"> <Button variant="ghost" @click="toggleMute">
<template #icon> <template #icon>
<Volume2 v-if="!muted" class="w-4 h-4 text-ink-gray-9" /> <Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" /> <VolumeX v-else class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<input <input
@@ -38,12 +59,12 @@
@input="changeCurrentTime" @input="changeCurrentTime"
class="duration-slider w-full h-1" class="duration-slider w-full h-1"
/> />
<span class="text-xs font-medium"> <span class="text-sm font-semibold">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span> </span>
<Button variant="ghost" @click="toggleFullscreen"> <Button variant="ghost" @click="toggleFullscreen">
<template #icon> <template #icon>
<Maximize class="w-4 h-4 text-ink-gray-9" /> <Maximize class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
</div> </div>
@@ -51,8 +72,9 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' 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 { Button } from 'frappe-ui'
import Play from '@/components/Icons/Play.vue'
const videoRef = ref(null) const videoRef = ref(null)
const videoContainer = ref(null) const videoContainer = ref(null)
@@ -147,7 +169,6 @@ const toggleFullscreen = () => {
<style scoped> <style scoped>
.video-block { .video-block {
width: 100%; width: 100%;
max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
@@ -165,15 +186,16 @@ iframe {
flex: 1; flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-color: theme('colors.gray.400'); border-radius: 10px;
background-color: theme('colors.gray.100');
cursor: pointer; cursor: pointer;
} }
.duration-slider::-webkit-slider-thumb { .duration-slider::-webkit-slider-thumb {
height: 10px; width: 2px;
width: 10px; border-radius: 50%;
-webkit-appearance: none; -webkit-appearance: none;
background-color: theme('colors.gray.900'); background-color: theme('colors.gray.500');
} }
@media screen and (-webkit-min-device-pixel-ratio: 0) { @media screen and (-webkit-min-device-pixel-ratio: 0) {
@@ -186,7 +208,7 @@ iframe {
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.900'); box-shadow: -500px 0 0 500px theme('colors.gray.600');
} }
} }
</style> </style>

View File

@@ -69,7 +69,7 @@
<CourseCardOverlay :course="course" class="md:hidden mb-4" /> <CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div <div
v-html="course.data.description" 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>
<div class="mt-10"> <div class="mt-10">
<CourseOutline <CourseOutline

View File

@@ -57,7 +57,7 @@
</div> </div>
<div <div
v-if="courses.data?.length" 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 <router-link
v-for="course in courses.data" v-for="course in courses.data"

View File

@@ -16,11 +16,11 @@
}, },
]" ]"
/> />
<div v-if="user.data?.name" class="flex space-x-2"> <div v-if="user.data?.name" class="flex items-center space-x-2">
<router-link <router-link
v-if="user.data.name == job.data?.owner" v-if="user.data.name == job.data?.owner"
:to="{ :to="{
name: 'JobCreation', name: 'JobForm',
params: { jobName: job.data?.name }, params: { jobName: job.data?.name },
}" }"
> >
@@ -47,6 +47,12 @@
</template> </template>
{{ __('Apply') }} {{ __('Apply') }}
</Button> </Button>
<Badge v-else variant="subtle" theme="green" size="lg">
<template #prefix>
<Check class="h-4 w-4" />
</template>
{{ __('You have applied') }}
</Badge>
</div> </div>
<div v-else> <div v-else>
<Button @click="redirectToLogin(job.data?.name)"> <Button @click="redirectToLogin(job.data?.name)">
@@ -56,13 +62,13 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="job.data" class="max-w-3xl mx-auto"> <div v-if="job.data" class="max-w-3xl mx-auto pt-5">
<div class="p-4"> <div class="p-4">
<div class="space-y-5 mb-10"> <div class="space-y-5 mb-10">
<div class="flex items-center"> <div class="flex items-center">
<img <img
:src="job.data.company_logo" :src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain cursor-pointer mr-4" class="size-10 rounded-lg object-contain cursor-pointer mr-4"
:alt="job.data.company_name" :alt="job.data.company_name"
@click="redirectToWebsite(job.data.company_website)" @click="redirectToWebsite(job.data.company_website)"
/> />
@@ -75,7 +81,7 @@
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
> >
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<Building2 class="h-4 w-4 text-ink-green-2" /> <Building2 class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Organisation') }} {{ __('Organisation') }}
@@ -86,20 +92,20 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<MapPin class="size-4 text-ink-red-3" /> <MapPin class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Location') }} {{ __('Location') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
{{ job.data.location }} {{ job.data.location }}, {{ job.data.country }}
</span> </span>
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<ClipboardType class="h-4 w-4 text-yellow-500" /> <ClipboardType class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Category') }} {{ __('Category') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
@@ -108,9 +114,9 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<CalendarDays class="h-4 w-4 text-ink-blue-2" /> <CalendarDays class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Posted on') }} {{ __('Posted on') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
@@ -122,9 +128,9 @@
v-if="applicationCount.data" v-if="applicationCount.data"
class="flex items-center space-x-4" class="flex items-center space-x-4"
> >
<SquareUserRound class="h-4 w-4 text-purple-500" /> <SquareUserRound class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> <div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase"> <span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Applications Received') }} {{ __('Applications Received') }}
</span> </span>
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
@@ -149,12 +155,19 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Button, Breadcrumbs, createResource, usePageMeta } from 'frappe-ui' import {
Badge,
Button,
Breadcrumbs,
createResource,
usePageMeta,
} from 'frappe-ui'
import { inject, ref } from 'vue' import { inject, ref } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue' import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import { import {
MapPin, MapPin,
Check,
SendHorizonal, SendHorizonal,
Pencil, Pencil,
Building2, Building2,

View File

@@ -13,17 +13,22 @@
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Job Details') }} {{ __('Job Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-5">
<div> <div class="space-y-4">
<FormControl <FormControl
v-model="job.job_title" v-model="job.job_title"
:label="__('Title')" :label="__('Title')"
class="mb-4"
:required="true" :required="true"
/> />
<FormControl <FormControl
v-model="job.location" v-model="job.location"
:label="__('Location')" :label="__('City')"
:required="true"
/>
<Link
v-model="job.country"
doctype="Country"
:label="__('Country')"
:required="true" :required="true"
/> />
</div> </div>
@@ -45,25 +50,12 @@
/> />
</div> </div>
</div> </div>
<div class="mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div> </div>
<div class="container mb-4 pb-4"> <div class="container border-b mb-4 pb-4">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Company Details') }} {{ __('Company Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-5">
<div> <div>
<FormControl <FormControl
v-model="job.company_name" v-model="job.company_name"
@@ -128,6 +120,19 @@
</div> </div>
</div> </div>
</div> </div>
<div class="container mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -217,6 +222,7 @@ const imageResource = createResource({
const job = reactive({ const job = reactive({
job_title: '', job_title: '',
location: '', location: '',
country: '',
type: 'Full Time', type: 'Full Time',
status: 'Open', status: 'Open',
company_name: '', company_name: '',
@@ -317,7 +323,7 @@ const breadcrumbs = computed(() => {
}, },
{ {
label: props.jobName == 'new' ? 'New Job' : 'Edit Job', label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
route: { name: 'JobCreation' }, route: { name: 'JobForm' },
}, },
] ]
return crumbs return crumbs

View File

@@ -10,7 +10,7 @@
<router-link <router-link
v-if="user.data?.name" v-if="user.data?.name"
:to="{ :to="{
name: 'JobCreation', name: 'JobForm',
params: { params: {
jobName: 'new', jobName: 'new',
}, },
@@ -25,40 +25,48 @@
</router-link> </router-link>
</header> </header>
<div> <div>
<div v-if="jobs.data?.length" class="p-5"> <div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
>
<div <div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" v-if="jobCount"
class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"
> >
<div class="text-xl text-ink-gray-9 font-semibold"> {{ __('{0} Open Jobs').format(jobCount) }}
{{ __('Find the perfect job for you') }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5"> <FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<Link
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
/>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div>
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<router-link <router-link
v-for="job in jobs.data" v-for="job in jobs.data"
:to="{ :to="{
@@ -73,18 +81,17 @@
</div> </div>
<div <div
v-else v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48" class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-56"
> >
<Laptop class="size-10 mx-auto stroke-1 text-ink-gray-4" /> <Laptop class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1"> <div class="text-lg font-medium mb-1">
{{ __('No jobs found') }} {{ __('No jobs found') }}
</div> </div>
<div class="leading-5 w-2/5 text-center"> <div class="leading-5 w-2/5 text-center">
{{ {{ __('There are no jobs available at the moment.') }}
__( </div>
'There are no jobs available at the moment. Open a job opportunity or check here again later.' <div class="leading-5 w-1/5 text-center">
) {{ __('Post a new job or check again later.') }}
}}
</div> </div>
</div> </div>
</div> </div>
@@ -94,21 +101,25 @@
import { import {
Button, Button,
Breadcrumbs, Breadcrumbs,
call,
createResource, createResource,
FormControl, FormControl,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { Laptop, Plus, Search } from 'lucide-vue-next' import { Laptop, Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { inject, computed, ref, onMounted } from 'vue' import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue' import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue'
const user = inject('$user') const user = inject('$user')
const jobType = ref(null) const jobType = ref(null)
const { brand } = sessionStore() const { brand } = sessionStore()
const searchQuery = ref('') const searchQuery = ref('')
const country = ref(null)
const filters = ref({}) const filters = ref({})
const orFilters = ref({}) const orFilters = ref({})
const jobCount = ref(0)
onMounted(() => { onMounted(() => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
@@ -116,6 +127,7 @@ onMounted(() => {
jobType.value = queries.get('type') jobType.value = queries.get('type')
} }
updateJobs() updateJobs()
getJobCount()
}) })
const jobs = createResource({ const jobs = createResource({
@@ -153,8 +165,30 @@ const updateFilters = () => {
} else { } else {
orFilters.value = {} orFilters.value = {}
} }
if (country.value) {
filters.value.country = country.value
} else {
delete filters.value.country
}
} }
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => {
updateJobs()
})
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
'', '',

View File

@@ -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" 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" /> <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> </header>
<div class="grid md:grid-cols-[70%,30%] h-screen"> <div class="grid md:grid-cols-[70%,30%] h-screen">
<div v-if="lesson.data.no_preview" class="border-r"> <div v-if="lesson.data.no_preview" class="border-r">
@@ -33,146 +42,187 @@
</Button> </Button>
</div> </div>
</div> </div>
<div
<div v-else class="border-r container pt-5 pb-10 px-5"> v-else
<div class="flex flex-col md:flex-row md:items-center justify-between"> ref="lessonContainer"
<div class="text-3xl font-semibold text-ink-gray-9"> class="bg-surface-white"
{{ lesson.data.title }} :class="{
</div> 'overflow-y-auto': zenModeEnabled,
<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 <div
v-if=" class="border-r container pt-5 pb-10 px-5 h-full"
lesson.data.instructor_content && :class="{
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 && 'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
allowInstructorContent() }"
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
> >
<div class="text-ink-gray-5 font-medium"> <div
{{ __('Instructor Notes') }} 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>
<div <div
id="instructor-content" 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" 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> >
</div> <LessonContent :content="lesson.data.instructor_notes" />
<div </div>
v-else-if="lesson.data.instructor_notes" <div
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" 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"
<LessonContent :content="lesson.data.instructor_notes" /> >
</div> <div id="editor"></div>
<div </div>
v-if="lesson.data.content" <div
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" 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"
<div id="editor"></div> >
</div> <LessonContent
<div v-if="lesson.data?.body"
v-else :content="lesson.data.body"
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" :youtube="lesson.data.youtube"
> :quizId="lesson.data.quiz_id"
<LessonContent />
v-if="lesson.data?.body" </div>
:content="lesson.data.body" <div class="mt-20" ref="discussionsContainer">
:youtube="lesson.data.youtube" <Discussions
:quizId="lesson.data.quiz_id" v-if="allowDiscussions"
/> :title="'Questions'"
</div> :doctype="'Course Lesson'"
<div class="mt-20"> :docname="lesson.data.name"
<Discussions :key="lesson.data.name"
v-if="allowDiscussions" />
:title="'Questions'" </div>
:doctype="'Course Lesson'"
:docname="lesson.data.name"
:key="lesson.data.name"
/>
</div> </div>
</div> </div>
<div class="sticky top-10"> <div class="sticky top-10">
@@ -202,8 +252,22 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs, Button, usePageMeta } from 'frappe-ui' import {
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue' 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 CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
@@ -212,6 +276,9 @@ import {
ChevronRight, ChevronRight,
LockKeyholeIcon, LockKeyholeIcon,
LogIn, LogIn,
Focus,
Info,
MessageCircleQuestion,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import { getEditorTools } from '../utils' import { getEditorTools } from '../utils'
@@ -221,6 +288,8 @@ import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
@@ -229,6 +298,10 @@ const allowDiscussions = ref(false)
const editor = ref(null) const editor = ref(null)
const instructorEditor = ref(null) const instructorEditor = ref(null)
const lessonProgress = ref(0) 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 timer = ref(0)
const { brand } = sessionStore() const { brand } = sessionStore()
let timerInterval let timerInterval
@@ -250,11 +323,59 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() 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({ const lesson = createResource({
url: 'lms.lms.utils.get_lesson', url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
makeParams(values) { makeParams(values) {
return { return {
course: props.courseName, course: props.courseName,
@@ -263,36 +384,37 @@ const lesson = createResource({
} }
}, },
auto: true, 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) => { const renderEditor = (holder, content) => {
// empty the holder // empty the holder
if (document.getElementById(holder)) if (document.getElementById(holder))
@@ -362,10 +484,18 @@ watch(
clearInterval(timerInterval) clearInterval(timerInterval)
timer.value = 0 timer.value = 0
startTimer() startTimer()
enablePlyr()
} }
} }
) )
watch(
() => lesson.data,
(data) => {
setupLesson(data)
}
)
const startTimer = () => { const startTimer = () => {
timerInterval = setInterval(() => { timerInterval = setInterval(() => {
timer.value++ timer.value++
@@ -381,13 +511,13 @@ onBeforeUnmount(() => {
}) })
const checkIfDiscussionsAllowed = () => { const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => { JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
if (block.type === 'quiz') quizPresent = true if (block.type === 'quiz') hasQuiz.value = true
}) })
if ( if (
!quizPresent && !hasQuiz.value &&
!zenModeEnabled.value &&
(lesson.data?.membership || (lesson.data?.membership ||
user.data?.is_moderator || user.data?.is_moderator ||
user.data?.is_instructor) 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 = () => { const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}` window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
} }
@@ -604,4 +765,30 @@ usePageMeta(() => {
.tc-table { .tc-table {
border-left: 1px solid #e8e8eb; 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> </style>

View File

@@ -92,14 +92,17 @@ import {
inject, inject,
ref, ref,
onBeforeUnmount, onBeforeUnmount,
watch,
} from 'vue' } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue' 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 { createToast, getEditorTools } from '@/utils'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const { brand } = sessionStore() const { brand } = sessionStore()
const editor = ref(null) const editor = ref(null)
@@ -133,6 +136,7 @@ onMounted(() => {
editor.value = renderEditor('content') editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes') instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
enablePlyr()
}) })
const renderEditor = (holder) => { const renderEditor = (holder) => {
@@ -141,6 +145,9 @@ const renderEditor = (holder) => {
tools: getEditorTools(true), tools: getEditorTools(true),
autofocus: true, autofocus: true,
defaultBlock: 'markdown', 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(() => { const breadcrumbs = computed(() => {
let crumbs = [ let crumbs = [
{ {
@@ -639,4 +677,30 @@ iframe {
.ce-popover-item[data-item-name='markdown'] { .ce-popover-item[data-item-name='markdown'] {
display: none !important; 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> </style>

View File

@@ -87,7 +87,6 @@ import { sessionStore } from '@/stores/session'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const { brand } = sessionStore() const { brand } = sessionStore()
console.log(user.data?.sitename)
const persona = reactive({ const persona = reactive({
role: null, role: null,

View File

@@ -134,8 +134,8 @@ const routes = [
}, },
{ {
path: '/job-opening/:jobName/edit', path: '/job-opening/:jobName/edit',
name: 'JobCreation', name: 'JobForm',
component: () => import('@/pages/JobCreation.vue'), component: () => import('@/pages/JobForm.vue'),
props: true, props: true,
}, },
{ {

View File

@@ -199,64 +199,24 @@ export function getEditorTools() {
services: { services: {
youtube: { youtube: {
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/, regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
embedUrl: embedUrl: '<%= remote_id %>',
'https://www.youtube.com/embed/<%= remote_id %>', /* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&amp;iv_load_policy=3&amp;modestbranding=1&amp;playsinline=1&amp;showinfo=0&amp;rel=0&amp;enablejsapi=1' */
html: `<iframe style="width:100%; height: ${ html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
window.innerWidth < 640 ? '15rem' : '30rem' id: ([id]) => id,
};" 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('&')
},
}, },
vimeo: { vimeo: {
regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/, regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
embedUrl: '<%= remote_id %>',
html: `<div class="video-player" data-plyr-provider="vimeo"></div>`,
id: ([id]) => id,
},
cloudflareStream: {
regex: /https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch/,
embedUrl: embedUrl:
'https://player.vimeo.com/video/<%= remote_id %>', 'https://iframe.videodelivery.net/<%= remote_id %>',
html: `<iframe style="width:100%; height: ${ html: `<iframe style="width:100%; height: ${
window.innerWidth < 640 ? '15rem' : '30rem' window.innerWidth < 640 ? '15rem' : '30rem'
};" frameborder="0" allowfullscreen></iframe>`, };" frameborder="0" allowfullscreen></iframe>`,
id: ([id]) => id,
}, },
codepen: true, codepen: true,
aparat: { aparat: {

View File

@@ -40,6 +40,7 @@ export default defineConfig({
'engine.io-client', 'engine.io-client',
'tailwind.config.js', 'tailwind.config.js',
'highlight.js', 'highlight.js',
'plyr',
], ],
}, },
}) })

2838
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,19 @@
"field_order": [ "field_order": [
"job_title", "job_title",
"location", "location",
"disabled", "country",
"column_break_5", "column_break_5",
"type", "type",
"status", "status",
"disabled",
"section_break_6", "section_break_6",
"description",
"company_details_section",
"company_name", "company_name",
"company_website", "company_website",
"column_break_11", "column_break_phkm",
"company_logo", "company_logo",
"company_email_address" "company_email_address",
"company_details_section",
"description"
], ],
"fields": [ "fields": [
{ {
@@ -36,7 +37,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Location", "label": "City",
"reqd": 1 "reqd": 1
}, },
{ {
@@ -62,7 +63,8 @@
}, },
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
"fieldtype": "Section Break" "fieldtype": "Section Break",
"label": "Company Details"
}, },
{ {
"fieldname": "description", "fieldname": "description",
@@ -72,8 +74,7 @@
}, },
{ {
"fieldname": "company_details_section", "fieldname": "company_details_section",
"fieldtype": "Section Break", "fieldtype": "Section Break"
"label": "Company Details"
}, },
{ {
"fieldname": "company_name", "fieldname": "company_name",
@@ -89,10 +90,6 @@
"label": "Company Website", "label": "Company Website",
"reqd": 1 "reqd": 1
}, },
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{ {
"fieldname": "company_logo", "fieldname": "company_logo",
"fieldtype": "Attach Image", "fieldtype": "Attach Image",
@@ -111,13 +108,30 @@
"label": "Company Email Address", "label": "Company Email Address",
"options": "Email", "options": "Email",
"reqd": 1 "reqd": 1
},
{
"fieldname": "column_break_phkm",
"fieldtype": "Column Break"
},
{
"fieldname": "country",
"fieldtype": "Link",
"label": "Country",
"options": "Country",
"reqd": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
{
"link_doctype": "LMS Job Application",
"link_fieldname": "job"
}
],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-01-17 12:38:57.134919", "modified": "2025-04-24 14:34:35.920242",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "Job", "module": "Job",
"name": "Job Opportunity", "name": "Job Opportunity",
"owner": "Administrator", "owner": "Administrator",
@@ -157,6 +171,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -281,6 +281,7 @@ def get_job_details(job):
[ [
"job_title", "job_title",
"location", "location",
"country",
"type", "type",
"company_name", "company_name",
"company_logo", "company_logo",
@@ -306,14 +307,20 @@ def get_job_opportunities(filters=None, orFilters=None):
fields=[ fields=[
"job_title", "job_title",
"location", "location",
"country",
"type", "type",
"company_name", "company_name",
"company_logo", "company_logo",
"name", "name",
"creation", "creation",
"description",
], ],
order_by="creation desc", order_by="creation desc",
) )
for job in jobs:
job.description = frappe.utils.strip_html_tags(job.description)
job.applicants = frappe.db.count("LMS Job Application", {"job": job.name})
return jobs return jobs

View File

@@ -91,7 +91,7 @@
"fetch_from": "member.username", "fetch_from": "member.username",
"fieldname": "member_username", "fieldname": "member_username",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Memeber Username", "label": "Member Username",
"read_only": 1 "read_only": 1
}, },
{ {
@@ -145,10 +145,11 @@
"options": "LMS Certificate" "options": "LMS Certificate"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-02-21 17:11:37.986157", "modified": "2025-04-25 10:06:25.824119",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Enrollment", "name": "LMS Enrollment",
"owner": "Administrator", "owner": "Administrator",
@@ -192,6 +193,7 @@
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",

View File

@@ -181,6 +181,7 @@ def get_lesson_icon(body, content):
if block.get("type") == "embed" and block.get("data").get("service") in [ if block.get("type") == "embed" and block.get("data").get("service") in [
"youtube", "youtube",
"vimeo", "vimeo",
"cloudflareStream",
]: ]:
return "icon-youtube" return "icon-youtube"
@@ -1313,6 +1314,9 @@ def get_lesson(course, chapter, lesson):
else: else:
progress = get_progress(course, lesson_details.name) 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) lesson_details.rendered_content = render_html(lesson_details)
neighbours = get_neighbour_lesson(course, chapter, lesson) neighbours = get_neighbour_lesson(course, chapter, lesson)
lesson_details.next = neighbours["next"] lesson_details.next = neighbours["next"]

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-04-18 16:04+0000\n" "POT-Creation-Date: 2025-04-18 16:04+0000\n"
"PO-Revision-Date: 2025-04-21 06:38\n" "PO-Revision-Date: 2025-04-24 08:07\n"
"Last-Translator: jannat@frappe.io\n" "Last-Translator: jannat@frappe.io\n"
"Language-Team: Chinese Simplified\n" "Language-Team: Chinese Simplified\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -465,7 +465,7 @@ msgstr "作业标题"
#: frontend/src/components/Modals/AssignmentForm.vue:112 #: frontend/src/components/Modals/AssignmentForm.vue:112
msgid "Assignment created successfully" msgid "Assignment created successfully"
msgstr "" msgstr "作业创建成功"
#: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py:24 #: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py:24
msgid "Assignment for Lesson {0} by {1} already exists." msgid "Assignment for Lesson {0} by {1} already exists."
@@ -473,7 +473,7 @@ msgstr "学员{1}的课时{0}作业已存在。"
#: frontend/src/components/Modals/AssignmentForm.vue:129 #: frontend/src/components/Modals/AssignmentForm.vue:129
msgid "Assignment updated successfully" msgid "Assignment updated successfully"
msgstr "" msgstr "作业更新成功"
#. Description of the 'Question' (Small Text) field in DocType 'Course Lesson' #. Description of the 'Question' (Small Text) field in DocType 'Course Lesson'
#: lms/lms/doctype/course_lesson/course_lesson.json #: lms/lms/doctype/course_lesson/course_lesson.json
@@ -831,7 +831,7 @@ msgstr "已认证"
#: frontend/src/pages/CertifiedParticipants.vue:196 #: frontend/src/pages/CertifiedParticipants.vue:196
#: frontend/src/pages/CertifiedParticipants.vue:203 #: frontend/src/pages/CertifiedParticipants.vue:203
msgid "Certified Members" msgid "Certified Members"
msgstr "" msgstr "认证成员"
#. Label of the certified_participants (Check) field in DocType 'LMS Settings' #. Label of the certified_participants (Check) field in DocType 'LMS Settings'
#: lms/lms/doctype/lms_settings/lms_settings.json lms/www/lms.py:290 #: lms/lms/doctype/lms_settings/lms_settings.json lms/www/lms.py:290
@@ -1458,7 +1458,7 @@ msgstr "新建试题"
#: frontend/src/components/Modals/AssignmentForm.vue:7 #: frontend/src/components/Modals/AssignmentForm.vue:7
msgid "Create an Assignment" msgid "Create an Assignment"
msgstr "" msgstr "创建作业"
#: frontend/src/components/AppSidebar.vue:460 #: frontend/src/components/AppSidebar.vue:460
msgid "Create your first batch" msgid "Create your first batch"
@@ -1754,7 +1754,7 @@ msgstr "编辑"
#: frontend/src/components/Modals/AssignmentForm.vue:8 #: frontend/src/components/Modals/AssignmentForm.vue:8
msgid "Edit Assignment" msgid "Edit Assignment"
msgstr "" msgstr "编辑作业"
#: frontend/src/components/CourseOutline.vue:52 #: frontend/src/components/CourseOutline.vue:52
#: frontend/src/components/Modals/ChapterModal.vue:5 #: frontend/src/components/Modals/ChapterModal.vue:5
@@ -3584,11 +3584,11 @@ msgstr "无证书"
#: frontend/src/pages/CertifiedParticipants.vue:110 #: frontend/src/pages/CertifiedParticipants.vue:110
msgid "No certified members" msgid "No certified members"
msgstr "" msgstr "无认证成员"
#: frontend/src/pages/CertifiedParticipants.vue:114 #: frontend/src/pages/CertifiedParticipants.vue:114
msgid "No certified members found. Please check again later or get certified yourself." msgid "No certified members found. Please check again later or get certified yourself."
msgstr "" msgstr "未找到认证成员,请稍后再试或自行申请认证。"
#: frontend/src/components/BatchCourses.vue:67 #: frontend/src/components/BatchCourses.vue:67
msgid "No courses added" msgid "No courses added"
@@ -4077,11 +4077,11 @@ msgstr "请输入答案"
#: lms/lms/doctype/lms_batch/lms_batch.py:58 #: lms/lms/doctype/lms_batch/lms_batch.py:58
msgid "Please install the Payments App to create a paid batch. Refer to the documentation for more details. {0}" msgid "Please install the Payments App to create a paid batch. Refer to the documentation for more details. {0}"
msgstr "" msgstr "请安装支付应用以创建付费班级,详情请参阅文档{0}"
#: lms/lms/doctype/lms_course/lms_course.py:55 #: lms/lms/doctype/lms_course/lms_course.py:55
msgid "Please install the Payments App to create a paid course. Refer to the documentation for more details. {0}" msgid "Please install the Payments App to create a paid course. Refer to the documentation for more details. {0}"
msgstr "" msgstr "请安装支付应用以创建付费课程,详情请参阅文档{0}"
#: frontend/src/pages/Billing.vue:254 #: frontend/src/pages/Billing.vue:254
msgid "Please let us know where you heard about us from." msgid "Please let us know where you heard about us from."
@@ -4184,7 +4184,7 @@ msgstr "发布于"
#: frontend/src/components/AppSidebar.vue:92 #: frontend/src/components/AppSidebar.vue:92
msgid "Powered by Learning" msgid "Powered by Learning"
msgstr "" msgstr "技术支持:学习平台"
#. Name of a DocType #. Name of a DocType
#: lms/lms/doctype/preferred_function/preferred_function.json #: lms/lms/doctype/preferred_function/preferred_function.json
@@ -5135,7 +5135,7 @@ msgstr "提交列表"
#: frontend/src/components/Modals/AssignmentForm.vue:30 #: frontend/src/components/Modals/AssignmentForm.vue:30
msgid "Submission Type" msgid "Submission Type"
msgstr "" msgstr "提交类型"
#: frontend/src/components/Assignment.vue:13 #: frontend/src/components/Assignment.vue:13
#: frontend/src/components/Assignment.vue:16 #: frontend/src/components/Assignment.vue:16
@@ -6139,7 +6139,7 @@ msgstr "证书"
#: frontend/src/pages/CertifiedParticipants.vue:21 #: frontend/src/pages/CertifiedParticipants.vue:21
msgid "certified members" msgid "certified members"
msgstr "" msgstr "认证成员"
#: frontend/src/pages/Lesson.vue:178 #: frontend/src/pages/Lesson.vue:178
msgid "completed" msgid "completed"

View File

@@ -102,3 +102,4 @@ lms.patches.v2_0.migrate_batch_student_data #10-02-2025
lms.patches.v2_0.delete_old_enrollment_doctypes lms.patches.v2_0.delete_old_enrollment_doctypes
lms.patches.v2_0.delete_unused_custom_fields lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.update_certificate_request_status lms.patches.v2_0.update_certificate_request_status
lms.patches.v2_0.update_job_city_and_country

View File

@@ -0,0 +1,28 @@
import frappe
def execute():
jobs = frappe.get_all("Job Opportunity", fields=["name", "location"])
for job in jobs:
if "," in job.location:
city, country = job.location.split(",", 1)
city = city.strip()
country = country.strip()
save_country(country, job)
frappe.db.set_value("Job Opportunity", job.name, "location", city)
else:
save_country(job.location, job)
def save_country(country, job):
if frappe.db.exists("Country", country):
frappe.db.set_value("Job Opportunity", job.name, "country", country)
else:
country_mapping = {
"US": "United States",
"USA": "United States",
"UAE": "United Arab Emirates",
}
country = country_mapping.get(country, country)
frappe.db.set_value("Job Opportunity", job.name, "country", country)

5501
yarn.lock

File diff suppressed because it is too large Load Diff