Merge pull request #1657 from pateljannat/course-card-gradient

feat: course card gradient
This commit is contained in:
Jannat Patel
2025-07-25 18:56:50 +05:30
committed by GitHub
12 changed files with 1620 additions and 99 deletions

View File

@@ -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");
}); });

View File

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

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

View File

@@ -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);
} }

View File

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

View File

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

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-8"
> >
<router-link <router-link
v-for="course in courses.data" v-for="course in courses.data"

View File

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

View File

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

View File

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

View File

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

1374
yarn.lock Normal file

File diff suppressed because it is too large Load Diff