Merge pull request #1657 from pateljannat/course-card-gradient
feat: course card gradient
This commit is contained in:
@@ -107,7 +107,7 @@ describe("Course Creation", () => {
|
|||||||
cy.get("div").contains(
|
cy.get("div").contains(
|
||||||
"Test Course Short Introduction to test the UI"
|
"Test Course Short Introduction to test the UI"
|
||||||
);
|
);
|
||||||
cy.get(".course-image")
|
cy.get(".bg-cover")
|
||||||
.invoke("css", "background-image")
|
.invoke("css", "background-image")
|
||||||
.should("include", "/files/profile");
|
.should("include", "/files/profile");
|
||||||
});
|
});
|
||||||
|
|||||||
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -40,6 +40,7 @@ declare module 'vue' {
|
|||||||
Code: typeof import('./src/components/Controls/Code.vue')['default']
|
Code: typeof import('./src/components/Controls/Code.vue')['default']
|
||||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||||
|
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||||
|
|||||||
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<Popover placement="bottom" class="!block">
|
||||||
|
<template #target="{ togglePopover, isOpen }">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="__('Set Color')"
|
||||||
|
@focus="togglePopover"
|
||||||
|
:modelValue="modelValue"
|
||||||
|
@update:modelValue="(val: string) => emit('update:modelValue', val)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div
|
||||||
|
class="size-4 rounded-full"
|
||||||
|
:style="
|
||||||
|
modelValue
|
||||||
|
? {
|
||||||
|
backgroundColor:
|
||||||
|
theme.backgroundColor[modelValue.toLowerCase()][400],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Palette
|
||||||
|
v-if="!modelValue"
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<X
|
||||||
|
class="size-3 text-ink-gray-5"
|
||||||
|
@click="emit('update:modelValue', null)"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body="{ close }">
|
||||||
|
<div class="rounded-lg bg-surface-white p-3 border w-fit mt-2">
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1.5">
|
||||||
|
{{ __('Swatches') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
class="size-5 rounded-full cursor-pointer"
|
||||||
|
:style="{
|
||||||
|
backgroundColor:
|
||||||
|
theme.backgroundColor[color.toLowerCase()][400],
|
||||||
|
}"
|
||||||
|
@click="
|
||||||
|
(e) => {
|
||||||
|
emit('update:modelValue', color)
|
||||||
|
close()
|
||||||
|
emit('change', color)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
<div class="text-sm text-ink-gray-5 mt-2">
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Palette, X } from 'lucide-vue-next'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const colors = computed(() => {
|
||||||
|
return [
|
||||||
|
'Red',
|
||||||
|
'Blue',
|
||||||
|
'Green',
|
||||||
|
'Amber',
|
||||||
|
'Purple',
|
||||||
|
'Cyan',
|
||||||
|
'Orange',
|
||||||
|
'Violet',
|
||||||
|
'Pink',
|
||||||
|
'Teal',
|
||||||
|
'Gray',
|
||||||
|
'Yellow',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,41 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="course.title"
|
v-if="course.title"
|
||||||
class="flex flex-col h-full rounded-md border-2 overflow-auto"
|
class="flex flex-col h-full rounded-md border-2 overflow-auto hover:border hover:border-outline-gray-3 text-ink-gray-9"
|
||||||
style="min-height: 350px"
|
style="min-height: 350px"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="course-image"
|
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
|
||||||
:class="{ 'default-image': !course.image }"
|
:style="
|
||||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
course.image
|
||||||
|
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||||
|
: {
|
||||||
|
backgroundImage: getGradientColor(),
|
||||||
|
backgroundBlendMode: 'screen',
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||||
<Badge
|
<div
|
||||||
v-if="course.featured"
|
v-if="course.featured"
|
||||||
variant="subtle"
|
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||||
theme="green"
|
|
||||||
size="md"
|
|
||||||
class="mb-1 mr-1"
|
|
||||||
>
|
>
|
||||||
{{ __('Featured') }}
|
<Star class="size-3 stroke-2" />
|
||||||
</Badge>
|
<span>
|
||||||
|
{{ __('Featured') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
|
class="text-xs bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!course.image" class="image-placeholder">
|
<div
|
||||||
{{ course.title[0] }}
|
v-if="!course.image"
|
||||||
|
class="flex items-center justify-center text-white flex-1 font-extrabold text-2xl my-auto"
|
||||||
|
:class="course.tags ? 'h-[80%]' : 'h-full'"
|
||||||
|
>
|
||||||
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-auto p-4">
|
<div class="flex flex-col flex-auto p-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div v-if="course.lessons">
|
<div v-if="course.lessons">
|
||||||
<Tooltip :text="__('Lessons')">
|
<Tooltip :text="__('Lessons')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.lessons }}
|
{{ course.lessons }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,8 +54,8 @@
|
|||||||
|
|
||||||
<div v-if="course.enrollments">
|
<div v-if="course.enrollments">
|
||||||
<Tooltip :text="__('Enrolled Students')">
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<Users class="h-4 w-4 stroke-1. mr-1" />
|
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.enrollments }}
|
{{ course.enrollments }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -53,14 +63,14 @@
|
|||||||
|
|
||||||
<div v-if="course.rating">
|
<div v-if="course.rating">
|
||||||
<Tooltip :text="__('Average Rating')">
|
<Tooltip :text="__('Average Rating')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.rating }}
|
{{ course.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
<!-- <div v-if="course.status != 'Approved'">
|
||||||
<Badge
|
<Badge
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||||
@@ -68,14 +78,14 @@
|
|||||||
>
|
>
|
||||||
{{ course.status }}
|
{{ course.status }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
|
<div v-if="course.image" class="text-xl font-semibold leading-6">
|
||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="short-introduction text-ink-gray-7 text-sm">
|
<div class="short-introduction text-sm">
|
||||||
{{ course.short_introduction }}
|
{{ course.short_introduction }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,11 +94,8 @@
|
|||||||
:progress="course.membership.progress"
|
:progress="course.membership.progress"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
|
||||||
v-if="user && course.membership"
|
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
|
||||||
class="text-sm text-ink-gray-7 mt-2 mb-4"
|
|
||||||
>
|
|
||||||
{{ Math.ceil(course.membership.progress) }}% completed
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-auto">
|
<div class="flex items-center justify-between mt-auto">
|
||||||
@@ -108,21 +115,23 @@
|
|||||||
<div v-if="course.paid_course" class="font-semibold">
|
<div v-if="course.paid_course" class="font-semibold">
|
||||||
{{ course.price }}
|
{{ course.price }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
|
<Tooltip
|
||||||
v-if="course.paid_certificate || course.enable_certification"
|
v-if="course.paid_certificate || course.enable_certification"
|
||||||
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
|
:text="__('Get Certified')"
|
||||||
>
|
>
|
||||||
{{ __('Certification') }}
|
<GraduationCap class="size-5 stroke-1.5" />
|
||||||
</div>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
import { BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Badge, Tooltip } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
@@ -134,16 +143,24 @@ const props = defineProps({
|
|||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getGradientColor = () => {
|
||||||
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||||
|
let colorMap = theme.backgroundColor[color]
|
||||||
|
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||||
|
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||||
|
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||||
|
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||||
|
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||||
|
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.course-image {
|
|
||||||
height: 168px;
|
|
||||||
width: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card-pills {
|
.course-card-pills {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
@@ -157,14 +174,6 @@ const props = defineProps({
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-image {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
background-color: theme('colors.green.100');
|
|
||||||
color: theme('colors.green.600');
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -173,14 +182,7 @@ const props = defineProps({
|
|||||||
.avatar-group .avatar {
|
.avatar-group .avatar {
|
||||||
transition: margin 0.1s ease-in-out;
|
transition: margin 0.1s ease-in-out;
|
||||||
}
|
}
|
||||||
.image-placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
font-size: 5rem;
|
|
||||||
color: theme('colors.gray.700');
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.avatar-group.overlap .avatar + .avatar {
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
margin-left: calc(-8px);
|
margin-left: calc(-8px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-ink-gray-7">
|
<div class="">
|
||||||
<span v-if="instructors?.length == 1">
|
<span v-if="instructors?.length == 1">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
>
|
>
|
||||||
{{ instructors[0].first_name }}
|
{{ instructors[0].first_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
and
|
{{ __('and') }}
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
>
|
>
|
||||||
{{ instructors[0].first_name }}
|
{{ instructors[0].first_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
and {{ instructors?.length - 1 }} others
|
{{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -47,9 +47,16 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
<div class="text-xs text-ink-gray-5">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="newTag"
|
||||||
|
:placeholder="__('Add a keyword and then press enter')"
|
||||||
|
:class="['w-full', 'flex-1', 'my-1']"
|
||||||
|
@keyup.enter="updateTags()"
|
||||||
|
id="tags"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center flex-wrap gap-2">
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -64,37 +71,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
|
||||||
v-model="newTag"
|
|
||||||
:placeholder="__('Add a keyword and then press enter')"
|
|
||||||
:class="[
|
|
||||||
'w-full',
|
|
||||||
'flex-1',
|
|
||||||
{ 'mt-2': course.tags?.length },
|
|
||||||
]"
|
|
||||||
@keyup.enter="updateTags()"
|
|
||||||
id="tags"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5">
|
||||||
<FormControl
|
|
||||||
v-model="course.short_introduction"
|
|
||||||
type="textarea"
|
|
||||||
:rows="5"
|
|
||||||
:label="__('Short Introduction')"
|
|
||||||
:placeholder="
|
|
||||||
__(
|
|
||||||
'A one line introduction to the course that appears on the course card'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-xs text-ink-gray-5 mb-2">
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
{{ __('Course Image') }}
|
{{ __('Course Image') }}
|
||||||
<span class="text-ink-red-3">*</span>
|
|
||||||
</div>
|
</div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!course.course_image"
|
v-if="!course.course_image"
|
||||||
@@ -144,6 +127,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ColorSwatches
|
||||||
|
v-model="course.card_gradient"
|
||||||
|
:label="__('Color')"
|
||||||
|
:description="__('Choose a color for the course card')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,6 +175,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('About the Course') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="course.short_introduction"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:label="__('Short Introduction')"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'A one line introduction to the course that appears on the course card'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Course Description') }}
|
{{ __('Course Description') }}
|
||||||
@@ -342,6 +347,7 @@ import {
|
|||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
import ColorSwatches from '@/components/Controls/ColorSwatches.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
@@ -365,6 +371,7 @@ const course = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
video_link: '',
|
video_link: '',
|
||||||
course_image: null,
|
course_image: null,
|
||||||
|
card_gradient: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
category: '',
|
category: '',
|
||||||
published: false,
|
published: false,
|
||||||
|
|||||||
@@ -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-8"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
|
|||||||
@@ -16,13 +16,14 @@
|
|||||||
"field_order": [
|
"field_order": [
|
||||||
"title",
|
"title",
|
||||||
"video_link",
|
"video_link",
|
||||||
|
"tags",
|
||||||
"column_break_3",
|
"column_break_3",
|
||||||
"instructors",
|
"instructors",
|
||||||
"tags",
|
|
||||||
"column_break_htgn",
|
|
||||||
"image",
|
|
||||||
"category",
|
"category",
|
||||||
"status",
|
"status",
|
||||||
|
"column_break_htgn",
|
||||||
|
"image",
|
||||||
|
"card_gradient",
|
||||||
"section_break_7",
|
"section_break_7",
|
||||||
"published",
|
"published",
|
||||||
"published_on",
|
"published_on",
|
||||||
@@ -98,8 +99,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "image",
|
"fieldname": "image",
|
||||||
"fieldtype": "Attach Image",
|
"fieldtype": "Attach Image",
|
||||||
"label": "Preview Image",
|
"label": "Preview Image"
|
||||||
"reqd": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "tags",
|
"fieldname": "tags",
|
||||||
@@ -272,6 +272,12 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Evaluator",
|
"label": "Evaluator",
|
||||||
"options": "Course Evaluator"
|
"options": "Course Evaluator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "card_gradient",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Color",
|
||||||
|
"options": "Red\nBlue\nGreen\nAmber\nCyan\nOrange\nPink\nPurple\nTeal\nViolet\nYellow\nGray"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"is_published_field": "published",
|
"is_published_field": "published",
|
||||||
@@ -290,8 +296,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2025-05-29 12:38:01.002898",
|
"modified": "2025-07-25 17:50:44.983391",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class LMSCourse(Document):
|
|||||||
self.validate_certification()
|
self.validate_certification()
|
||||||
self.validate_amount_and_currency()
|
self.validate_amount_and_currency()
|
||||||
self.image = validate_image(self.image)
|
self.image = validate_image(self.image)
|
||||||
|
self.validate_card_gradient()
|
||||||
|
|
||||||
def validate_published(self):
|
def validate_published(self):
|
||||||
if self.published and not self.published_on:
|
if self.published and not self.published_on:
|
||||||
@@ -73,6 +74,24 @@ class LMSCourse(Document):
|
|||||||
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
|
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
|
||||||
frappe.throw(_("Amount and currency are required for paid certificates."))
|
frappe.throw(_("Amount and currency are required for paid certificates."))
|
||||||
|
|
||||||
|
def validate_card_gradient(self):
|
||||||
|
if not self.image and not self.card_gradient:
|
||||||
|
colors = [
|
||||||
|
"Red",
|
||||||
|
"Blue",
|
||||||
|
"Green",
|
||||||
|
"Yellow",
|
||||||
|
"Orange",
|
||||||
|
"Pink",
|
||||||
|
"Amber",
|
||||||
|
"Violet",
|
||||||
|
"Cyan",
|
||||||
|
"Teal",
|
||||||
|
"Gray",
|
||||||
|
"Purple",
|
||||||
|
]
|
||||||
|
self.card_gradient = random.choice(colors)
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
if not self.upcoming and self.has_value_changed("upcoming"):
|
if not self.upcoming and self.has_value_changed("upcoming"):
|
||||||
self.send_email_to_interested_users()
|
self.send_email_to_interested_users()
|
||||||
|
|||||||
@@ -565,10 +565,13 @@ def get_courses_under_review():
|
|||||||
|
|
||||||
def validate_image(path):
|
def validate_image(path):
|
||||||
if path and "/private" in path:
|
if path and "/private" in path:
|
||||||
file = frappe.get_doc("File", {"file_url": path})
|
frappe.db.set_value(
|
||||||
file.is_private = 0
|
"File",
|
||||||
file.save()
|
{"file_url": path},
|
||||||
return file.file_url
|
"is_private",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return path.replace("/private", "")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
@@ -1097,6 +1100,7 @@ def get_course_fields():
|
|||||||
"title",
|
"title",
|
||||||
"tags",
|
"tags",
|
||||||
"image",
|
"image",
|
||||||
|
"card_gradient",
|
||||||
"short_introduction",
|
"short_introduction",
|
||||||
"published",
|
"published",
|
||||||
"upcoming",
|
"upcoming",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/frappe/lms#readme",
|
"homepage": "https://github.com/frappe/lms#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cypress": "^13.9.0",
|
"cypress": "^14.5.2",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
"cypress-real-events": "^1.14.0"
|
"cypress-real-events": "^1.14.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user