feat: add Olympiads module and redesign course UI
- Add LMS Olympiad, Participant, Result doctypes with full API - Add Olympiads pages (list, detail, leaderboard, registration) - Redesign CourseCard with hover animations, badges, progress bar - Improve Courses page with hero banner and filters - Add Olympiads route and sidebar navigation entry - Add CLAUDE.md project documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,146 +1,162 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
||||
style="min-height: 350px"
|
||||
class="flex flex-col h-full rounded-xl overflow-hidden border border-outline-gray-2 bg-surface-white transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 group"
|
||||
style="min-height: 320px"
|
||||
>
|
||||
<!-- Cover image / gradient -->
|
||||
<div
|
||||
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat border-t border-x rounded-t-md"
|
||||
class="relative w-full h-40 bg-cover bg-center bg-no-repeat flex-shrink-0"
|
||||
:style="
|
||||
course.image
|
||||
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||
: {
|
||||
backgroundImage: getGradientColor(),
|
||||
backgroundBlendMode: 'screen',
|
||||
}
|
||||
: { backgroundImage: getGradientColor() }
|
||||
"
|
||||
>
|
||||
<!-- <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||
<div
|
||||
v-if="course.featured"
|
||||
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||
<!-- Top badges row -->
|
||||
<div class="absolute top-2.5 left-2.5 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-if="course.upcoming"
|
||||
class="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-surface-amber-2 text-ink-amber-3 border border-outline-amber-1"
|
||||
>
|
||||
<Star class="size-3 stroke-2" />
|
||||
<span>
|
||||
{{ __('Featured') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||
<Clock class="h-2.5 w-2.5" />
|
||||
{{ __('Upcoming') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isNew"
|
||||
class="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-surface-green-1 text-ink-green-3 border border-outline-green-1"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div> -->
|
||||
<div
|
||||
v-if="!course.image"
|
||||
class="flex items-center justify-center text-white flex-1 font-extrabold my-auto px-5 text-center leading-6 h-full"
|
||||
:class="
|
||||
course.title.length > 32
|
||||
? 'text-lg'
|
||||
: course.title.length > 20
|
||||
? 'text-xl'
|
||||
: 'text-2xl'
|
||||
"
|
||||
>
|
||||
{{ course.title }}
|
||||
<Sparkles class="h-2.5 w-2.5" />
|
||||
{{ __('New') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4 border-x-2 border-b-2 rounded-b-md">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ formatAmount(course.enrollments) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2.5 right-2.5 flex items-center gap-1">
|
||||
<Tooltip v-if="course.featured" :text="__('Featured')">
|
||||
<Award class="size-4 stroke-2 text-ink-amber-3" />
|
||||
<span class="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-surface-amber-2 text-ink-amber-3 border border-outline-amber-1">
|
||||
<Star class="h-2.5 w-2.5 stroke-2" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Title fallback when no image -->
|
||||
<div
|
||||
v-if="!course.image"
|
||||
class="flex items-end h-full px-4 pb-3"
|
||||
>
|
||||
<h3
|
||||
class="text-white font-bold leading-tight drop-shadow"
|
||||
:class="course.title.length > 40 ? 'text-base' : course.title.length > 24 ? 'text-lg' : 'text-xl'"
|
||||
>
|
||||
{{ course.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Progress overlay -->
|
||||
<div
|
||||
v-if="user && course.membership"
|
||||
class="absolute bottom-0 left-0 right-0 h-1 bg-black/20"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-ink-green-3 transition-all duration-500"
|
||||
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="flex flex-col flex-1 p-4">
|
||||
<!-- Title (when image present) -->
|
||||
<div
|
||||
v-if="course.image"
|
||||
class="font-semibold leading-6"
|
||||
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
||||
class="font-semibold text-ink-gray-9 leading-snug mb-1"
|
||||
:class="course.title.length > 40 ? 'text-sm' : 'text-base'"
|
||||
>
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
<div class="short-introduction text-sm">
|
||||
<!-- Short intro -->
|
||||
<p class="text-xs text-ink-gray-5 leading-relaxed line-clamp-2 mb-3 mt-0.5">
|
||||
{{ course.short_introduction }}
|
||||
</p>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-3 flex-wrap">
|
||||
<span v-if="course.lessons" class="flex items-center gap-1">
|
||||
<BookOpen class="h-3.5 w-3.5" />
|
||||
{{ course.lessons }} {{ __('lessons') }}
|
||||
</span>
|
||||
<span v-if="course.enrollments" class="flex items-center gap-1">
|
||||
<Users class="h-3.5 w-3.5" />
|
||||
{{ formatAmount(course.enrollments) }}
|
||||
</span>
|
||||
<span v-if="course.rating" class="flex items-center gap-1 text-ink-amber-3">
|
||||
<Star class="h-3.5 w-3.5 fill-current" />
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
v-if="user && course.membership"
|
||||
:progress="course.membership.progress"
|
||||
/>
|
||||
|
||||
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
|
||||
<!-- Progress text -->
|
||||
<div v-if="user && course.membership" class="text-xs text-ink-gray-5 mb-3">
|
||||
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||
>
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between mt-auto pt-3 border-t border-outline-gray-1">
|
||||
<!-- Instructors -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex -space-x-1.5">
|
||||
<UserAvatar
|
||||
v-for="instructor in course.instructors"
|
||||
v-for="instructor in course.instructors.slice(0, 3)"
|
||||
:key="instructor.name"
|
||||
:user="instructor"
|
||||
class="ring-1 ring-white"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
<CourseInstructors
|
||||
v-if="course.instructors.length === 1"
|
||||
:instructors="course.instructors"
|
||||
class="text-xs text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
<!-- Right side: price + cert -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="course.paid_course" class="text-sm font-semibold text-ink-gray-9">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
|
||||
</span>
|
||||
<Tooltip
|
||||
v-if="course.paid_certificate || course.enable_certification"
|
||||
:text="__('Get Certified')"
|
||||
>
|
||||
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
|
||||
<span class="inline-flex items-center gap-0.5 text-[10px] font-medium text-ink-blue-3 bg-surface-blue-1 px-1.5 py-0.5 rounded border border-outline-blue-1">
|
||||
<GraduationCap class="h-3 w-3" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Award,
|
||||
BookOpen,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
Sparkles,
|
||||
Star,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
import dayjs from '@/utils/dayjs'
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
@@ -151,48 +167,29 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// Course is "new" if published in the last 60 days
|
||||
const isNew = computed(() => {
|
||||
if (!props.course.published_on) return false
|
||||
return dayjs().diff(dayjs(props.course.published_on), 'day') <= 60
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = colors[theme][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
return `linear-gradient(to bottom right, #111827, ${colorMap[500]})`
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.course-card-pills {
|
||||
background: #ffffff;
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
padding: 3.5px 8px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.011em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
}
|
||||
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user