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:
2026-03-16 05:50:39 +03:00
parent ebcf9d5042
commit 3882787e50
18 changed files with 1741 additions and 155 deletions

View File

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