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>

View File

@@ -46,51 +46,63 @@
</template>
</Dropdown>
</header>
<div class="p-5 pb-10">
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
>
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Courses') }}
<!-- Hero search bar -->
<div class="bg-gradient-to-br from-ink-gray-9 to-surface-gray-7 px-5 py-8">
<div class="max-w-xl">
<div class="flex items-center gap-2 mb-1">
<BookOpen class="h-5 w-5 text-ink-blue-3" />
<span class="text-xs font-semibold text-ink-blue-3 uppercase tracking-wider">
{{ __('Courses') }}
</span>
</div>
<div
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
>
<TabButtons :buttons="courseTabs" v-model="currentTab" class="w-fit" />
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="title"
:placeholder="__('Search')"
type="text"
class="w-full lg:min-w-0 lg:w-32 xl:w-40"
@input="updateCourses()"
/>
<div class="w-full lg:min-w-0 lg:w-32 xl:w-40">
<Select
v-if="categories.length"
v-model="currentCategory"
:options="categories"
:placeholder="__('Category')"
@update:modelValue="updateCourses()"
/>
</div>
</div>
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateCourses()"
<h1 class="text-2xl font-bold text-white mb-4">{{ __('Explore Our Courses') }}</h1>
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-gray-5" />
<input
v-model="title"
type="text"
:placeholder="__('Search by title...')"
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-white border border-outline-gray-2 text-sm text-ink-gray-9 focus:outline-none focus:ring-2 focus:ring-ink-blue-3"
@input="updateCourses()"
/>
</div>
</div>
</div>
<div class="p-5 pb-10">
<!-- Filters row -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<TabButtons :buttons="courseTabs" v-model="currentTab" class="w-fit" />
<div class="flex items-center gap-3 flex-wrap">
<div class="min-w-0 w-36">
<Select
v-if="categories.length"
v-model="currentCategory"
:options="categories"
:placeholder="__('Category')"
@update:modelValue="updateCourses()"
/>
</div>
<FormControl
v-model="certification"
:label="__('Certified')"
type="checkbox"
@change="updateCourses()"
/>
<span class="text-sm text-ink-gray-5 whitespace-nowrap">
{{ courses.data?.length || 0 }} {{ __('courses') }}
</span>
</div>
</div>
<div
v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
>
<router-link
v-for="course in courses.data"
:key="course.name"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
@@ -99,9 +111,9 @@
<EmptyState v-else-if="!courses.list.loading" type="Courses" />
<div
v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5"
class="flex justify-center mt-6"
>
<Button @click="courses.next()">
<Button variant="subtle" @click="courses.next()">
{{ __('Load More') }}
</Button>
</div>
@@ -125,7 +137,7 @@ import {
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { ChevronDown, Plus } from 'lucide-vue-next'
import { BookOpen, ChevronDown, Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'

View File

@@ -0,0 +1,131 @@
<template>
<div
class="flex flex-col h-full rounded-xl overflow-hidden border border-outline-gray-2 bg-surface-white transition-all duration-200 group-hover:shadow-lg group-hover:-translate-y-0.5"
>
<!-- Image / gradient header -->
<div
class="relative h-36 bg-cover bg-center"
:style="
olympiad.image
? { backgroundImage: `url('${encodeURI(olympiad.image)}')` }
: { background: statusGradient }
"
>
<!-- Status badge -->
<div class="absolute top-3 left-3">
<span
class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full"
:class="statusBadgeClass"
>
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass" />
{{ __(olympiad.status) }}
</span>
</div>
<!-- Featured star -->
<div v-if="olympiad.featured" class="absolute top-3 right-3">
<span class="inline-flex items-center gap-1 text-xs 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-3 w-3 stroke-2" />
{{ __('Featured') }}
</span>
</div>
<!-- Title overlay when no image -->
<div
v-if="!olympiad.image"
class="flex items-end h-full px-4 pb-3"
>
<h3 class="text-white font-bold text-lg leading-tight drop-shadow">
{{ olympiad.title }}
</h3>
</div>
</div>
<!-- Body -->
<div class="flex flex-col flex-1 p-4">
<div v-if="olympiad.image" class="font-semibold text-base text-ink-gray-9 mb-1">
{{ olympiad.title }}
</div>
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-3 flex-wrap">
<span v-if="olympiad.subject" class="flex items-center gap-1">
<BookOpen class="h-3.5 w-3.5" />
{{ olympiad.subject }}
</span>
<span v-if="olympiad.grade_levels" class="flex items-center gap-1">
<GraduationCap class="h-3.5 w-3.5" />
{{ olympiad.grade_levels }}
</span>
</div>
<p
v-if="olympiad.short_description"
class="text-sm text-ink-gray-6 leading-relaxed line-clamp-2 mb-3"
>
{{ olympiad.short_description }}
</p>
<!-- Dates -->
<div class="mt-auto space-y-1 text-xs text-ink-gray-5">
<div v-if="olympiad.registration_end && olympiad.status === 'Registration Open'" class="flex items-center gap-1 text-ink-orange-3 font-medium">
<Clock class="h-3.5 w-3.5" />
{{ __('Registration until') }} {{ formatDate(olympiad.registration_end) }}
</div>
<div v-else-if="olympiad.start_date" class="flex items-center gap-1">
<Calendar class="h-3.5 w-3.5" />
{{ formatDate(olympiad.start_date) }}
<span v-if="olympiad.end_date"> {{ formatDate(olympiad.end_date) }}</span>
</div>
<div v-if="olympiad.participants_count" class="flex items-center gap-1">
<Users class="h-3.5 w-3.5" />
{{ olympiad.participants_count }}
{{ olympiad.max_participants ? ` / ${olympiad.max_participants}` : '' }}
{{ __('participants') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BookOpen, Calendar, Clock, GraduationCap, Star, Trophy, Users } from 'lucide-vue-next'
import dayjs from '@/utils/dayjs'
const props = defineProps<{ olympiad: any }>()
const formatDate = (date: string) => dayjs(date).format('DD MMM YYYY')
const statusGradient = computed(() => {
const map: Record<string, string> = {
'Registration Open': 'linear-gradient(135deg, #1e3a5f, #2563eb)',
'In Progress': 'linear-gradient(135deg, #14532d, #16a34a)',
'Completed': 'linear-gradient(135deg, #3b0764, #7c3aed)',
'Cancelled': 'linear-gradient(135deg, #1f2937, #6b7280)',
'Draft': 'linear-gradient(135deg, #1f2937, #374151)',
}
return map[props.olympiad.status] || 'linear-gradient(135deg, #1e3a5f, #2563eb)'
})
const statusBadgeClass = computed(() => {
const map: Record<string, string> = {
'Registration Open': 'bg-surface-blue-1 text-ink-blue-3 border border-outline-blue-1',
'In Progress': 'bg-surface-green-1 text-ink-green-3 border border-outline-green-1',
'Completed': 'bg-surface-gray-2 text-ink-gray-7 border border-outline-gray-2',
'Cancelled': 'bg-surface-red-1 text-ink-red-3 border border-outline-red-1',
'Draft': 'bg-surface-gray-2 text-ink-gray-5 border border-outline-gray-2',
}
return map[props.olympiad.status] || 'bg-surface-gray-2 text-ink-gray-7'
})
const statusDotClass = computed(() => {
const map: Record<string, string> = {
'Registration Open': 'bg-ink-blue-3',
'In Progress': 'bg-ink-green-3',
'Completed': 'bg-ink-gray-5',
'Cancelled': 'bg-ink-red-3',
'Draft': 'bg-ink-gray-4',
}
return map[props.olympiad.status] || 'bg-ink-gray-4'
})
</script>

View File

@@ -0,0 +1,452 @@
<template>
<header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div v-if="olympiad.loading" class="p-10 flex justify-center">
<LoadingIndicator class="h-8 w-8" />
</div>
<div v-else-if="olympiad.data">
<!-- Hero -->
<div
class="relative px-5 py-12 text-white overflow-hidden"
:style="heroStyle"
>
<div class="absolute inset-0 bg-black/50" />
<div class="relative z-10 max-w-3xl">
<div class="flex items-center gap-2 mb-3">
<span
class="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full"
:class="statusBadgeClass"
>
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass" />
{{ __(olympiad.data.status) }}
</span>
<span v-if="olympiad.data.featured" class="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-full bg-surface-amber-2 text-ink-amber-3">
<Star class="h-3 w-3 stroke-2" />
{{ __('Featured') }}
</span>
</div>
<h1 class="text-4xl font-bold mb-3">{{ olympiad.data.title }}</h1>
<p class="text-base text-white/80 mb-6 max-w-xl">
{{ olympiad.data.short_description }}
</p>
<!-- Meta chips -->
<div class="flex flex-wrap gap-3 text-sm">
<div v-if="olympiad.data.subject" class="flex items-center gap-1.5 bg-white/10 px-3 py-1.5 rounded-lg">
<BookOpen class="h-4 w-4" />
{{ olympiad.data.subject }}
</div>
<div v-if="olympiad.data.grade_levels" class="flex items-center gap-1.5 bg-white/10 px-3 py-1.5 rounded-lg">
<GraduationCap class="h-4 w-4" />
{{ olympiad.data.grade_levels }}
</div>
<div v-if="olympiad.data.participants_count" class="flex items-center gap-1.5 bg-white/10 px-3 py-1.5 rounded-lg">
<Users class="h-4 w-4" />
{{ olympiad.data.participants_count }}
{{ olympiad.data.max_participants ? ` / ${olympiad.data.max_participants}` : '' }}
{{ __('participants') }}
</div>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-5 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main content -->
<div class="lg:col-span-2 space-y-8">
<!-- Description -->
<div v-if="olympiad.data.description" class="bg-surface-white border rounded-xl p-6">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">{{ __('About') }}</h2>
<div class="prose prose-sm text-ink-gray-7 leading-relaxed whitespace-pre-line">
{{ olympiad.data.description }}
</div>
</div>
<!-- Tabs: Results / Participants -->
<div v-if="olympiad.data.status === 'Completed' || olympiad.data.status === 'In Progress'">
<div class="flex border-b mb-6">
<button
v-for="tab in detailTabs"
:key="tab.value"
class="px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors"
:class="activeTab === tab.value
? 'border-ink-gray-9 text-ink-gray-9'
: 'border-transparent text-ink-gray-5 hover:text-ink-gray-7'"
@click="activeTab = tab.value"
>
{{ tab.label }}
</button>
</div>
<!-- Leaderboard -->
<div v-if="activeTab === 'leaderboard'">
<div v-if="leaderboard.loading" class="space-y-2">
<div v-for="i in 5" :key="i" class="h-12 rounded-lg bg-surface-gray-2 animate-pulse" />
</div>
<div v-else-if="leaderboard.data?.length" class="space-y-2">
<div
v-for="result in leaderboard.data"
:key="result.member"
class="flex items-center gap-4 p-3 rounded-xl border transition-colors hover:bg-surface-gray-1"
:class="result.member === currentUser ? 'bg-surface-blue-1 border-outline-blue-1' : 'border-outline-gray-2'"
>
<!-- Rank medal -->
<div class="flex-shrink-0 w-8 text-center">
<span v-if="result.rank === 1" class="text-xl">🥇</span>
<span v-else-if="result.rank === 2" class="text-xl">🥈</span>
<span v-else-if="result.rank === 3" class="text-xl">🥉</span>
<span v-else class="text-sm font-bold text-ink-gray-5">#{{ result.rank }}</span>
</div>
<!-- Avatar -->
<img
v-if="result.user_image"
:src="result.user_image"
class="h-8 w-8 rounded-full object-cover flex-shrink-0"
/>
<div v-else class="h-8 w-8 rounded-full bg-surface-gray-3 flex items-center justify-center flex-shrink-0">
<span class="text-xs font-bold text-ink-gray-6">
{{ result.full_name?.[0]?.toUpperCase() }}
</span>
</div>
<!-- Name + school -->
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-ink-gray-9 truncate">
{{ result.full_name || result.member }}
</div>
<div v-if="result.school" class="text-xs text-ink-gray-5 truncate">
{{ result.school }}
<span v-if="result.grade"> · {{ result.grade }}</span>
</div>
</div>
<!-- Score / prize -->
<div class="flex-shrink-0 text-right">
<div class="font-semibold text-sm text-ink-gray-9">
{{ result.percentage != null ? result.percentage + '%' : '—' }}
</div>
<div
v-if="result.prize"
class="text-xs font-medium"
:class="prizeColor(result.prize)"
>
{{ __(result.prize) }}
</div>
</div>
</div>
</div>
<EmptyState v-else type="Results" />
</div>
<!-- My result -->
<div v-if="activeTab === 'my_result' && olympiad.data.my_result">
<div class="bg-surface-blue-1 border border-outline-blue-1 rounded-xl p-6 text-center">
<div class="text-5xl font-bold text-ink-gray-9 mb-1">
{{ olympiad.data.my_result.percentage }}%
</div>
<div class="text-ink-gray-6 mb-4">{{ __('Your Score') }}</div>
<div class="flex justify-center gap-6 text-sm">
<div>
<div class="text-ink-gray-5">{{ __('Rank') }}</div>
<div class="font-semibold text-ink-gray-9">#{{ olympiad.data.my_result.rank || '—' }}</div>
</div>
<div>
<div class="text-ink-gray-5">{{ __('Status') }}</div>
<div
class="font-semibold"
:class="olympiad.data.my_result.passed ? 'text-ink-green-3' : 'text-ink-red-3'"
>
{{ olympiad.data.my_result.passed ? __('Passed') : __('Not Passed') }}
</div>
</div>
<div v-if="olympiad.data.my_result.prize">
<div class="text-ink-gray-5">{{ __('Prize') }}</div>
<div class="font-semibold" :class="prizeColor(olympiad.data.my_result.prize)">
{{ __(olympiad.data.my_result.prize) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-5">
<!-- Registration card -->
<div class="bg-surface-white border rounded-xl p-5">
<h3 class="text-base font-semibold text-ink-gray-9 mb-4">{{ __('Registration') }}</h3>
<!-- Already registered -->
<div
v-if="olympiad.data.is_registered"
class="flex items-center gap-2 p-3 rounded-lg bg-surface-green-1 border border-outline-green-1 mb-4"
>
<CheckCircle class="h-4 w-4 text-ink-green-3 flex-shrink-0" />
<span class="text-sm text-ink-green-3 font-medium">{{ __('You are registered') }}</span>
</div>
<!-- My result quick view -->
<div
v-if="olympiad.data.my_result"
class="flex items-center gap-2 p-3 rounded-lg bg-surface-blue-1 border border-outline-blue-1 mb-4"
>
<Trophy class="h-4 w-4 text-ink-blue-3 flex-shrink-0" />
<span class="text-sm text-ink-blue-3 font-medium">
{{ __('Score') }}: {{ olympiad.data.my_result.percentage }}% · #{{ olympiad.data.my_result.rank }}
</span>
</div>
<!-- Registration form -->
<div
v-if="!olympiad.data.is_registered && canRegister && !olympiad.data.my_result"
class="space-y-3 mb-4"
>
<FormControl
v-model="regForm.grade"
:label="__('Grade / Class')"
type="text"
:placeholder="__('e.g. 9A')"
/>
<FormControl
v-model="regForm.school"
:label="__('School')"
type="text"
:placeholder="__('School name')"
/>
<FormControl
v-model="regForm.teacher_name"
:label="__('Teacher / Supervisor')"
type="text"
:placeholder="__('Teacher full name')"
/>
</div>
<Button
v-if="!olympiad.data.is_registered && canRegister"
variant="solid"
class="w-full"
:loading="registering"
@click="registerForOlympiad"
>
<template #prefix>
<UserPlus class="h-4 w-4" />
</template>
{{ __('Register') }}
</Button>
<Button
v-if="olympiad.data.is_registered && olympiad.data.status === 'Registration Open'"
variant="subtle"
theme="red"
class="w-full"
:loading="unregistering"
@click="unregister"
>
{{ __('Cancel Registration') }}
</Button>
<p
v-if="!canRegister && !olympiad.data.is_registered"
class="text-sm text-ink-gray-5 text-center"
>
{{ registrationClosedMessage }}
</p>
</div>
<!-- Dates card -->
<div class="bg-surface-white border rounded-xl p-5 space-y-3">
<h3 class="text-base font-semibold text-ink-gray-9 mb-2">{{ __('Timeline') }}</h3>
<div v-if="olympiad.data.registration_start" class="flex gap-3 text-sm">
<Calendar class="h-4 w-4 text-ink-gray-5 mt-0.5 flex-shrink-0" />
<div>
<div class="text-ink-gray-5 text-xs">{{ __('Registration') }}</div>
<div class="text-ink-gray-9">
{{ formatDate(olympiad.data.registration_start) }}
<span v-if="olympiad.data.registration_end">
{{ formatDate(olympiad.data.registration_end) }}
</span>
</div>
</div>
</div>
<div v-if="olympiad.data.start_date" class="flex gap-3 text-sm">
<Trophy class="h-4 w-4 text-ink-gray-5 mt-0.5 flex-shrink-0" />
<div>
<div class="text-ink-gray-5 text-xs">{{ __('Olympiad') }}</div>
<div class="text-ink-gray-9">
{{ formatDate(olympiad.data.start_date) }}
<span v-if="olympiad.data.end_date">
{{ formatDate(olympiad.data.end_date) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="p-10 text-center text-ink-gray-5">
{{ __('Olympiad not found.') }}
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
Breadcrumbs,
Button,
FormControl,
LoadingIndicator,
call,
createResource,
toast,
usePageMeta,
} from 'frappe-ui'
import {
BookOpen,
Calendar,
CheckCircle,
GraduationCap,
Star,
Trophy,
UserPlus,
Users,
} from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import EmptyState from '@/components/EmptyState.vue'
import dayjs from '@/utils/dayjs'
const props = defineProps<{ olympiadName: string }>()
const { user } = sessionStore()
const currentUser = user
const activeTab = ref<'leaderboard' | 'my_result'>('leaderboard')
const registering = ref(false)
const unregistering = ref(false)
const regForm = ref({ grade: '', school: '', teacher_name: '' })
const formatDate = (date: string) => dayjs(date).format('DD MMM YYYY')
const olympiad = createResource({
url: 'lms.lms.api.get_olympiad_detail',
params: { name: props.olympiadName },
auto: true,
})
const leaderboard = createResource({
url: 'lms.lms.api.get_olympiad_leaderboard',
params: { olympiad: props.olympiadName, page_length: 50 },
})
onMounted(() => {
leaderboard.reload()
})
const detailTabs = computed(() => {
const tabs = [{ label: __('Leaderboard'), value: 'leaderboard' }]
if (olympiad.data?.my_result) {
tabs.push({ label: __('My Result'), value: 'my_result' })
}
return tabs
})
const canRegister = computed(() => {
const status = olympiad.data?.status
return status === 'Registration Open' || status === 'In Progress'
})
const registrationClosedMessage = computed(() => {
const status = olympiad.data?.status
if (status === 'Completed') return __('This olympiad has ended.')
if (status === 'Draft') return __('Registration is not open yet.')
if (status === 'Cancelled') return __('This olympiad has been cancelled.')
return __('Registration is closed.')
})
const heroStyle = computed(() => {
if (olympiad.data?.image) {
return { backgroundImage: `url('${encodeURI(olympiad.data.image)}')`, backgroundSize: 'cover', backgroundPosition: 'center' }
}
const gradients: Record<string, string> = {
'Registration Open': 'linear-gradient(135deg, #1e3a5f, #2563eb)',
'In Progress': 'linear-gradient(135deg, #14532d, #16a34a)',
'Completed': 'linear-gradient(135deg, #3b0764, #7c3aed)',
'Cancelled': 'linear-gradient(135deg, #1f2937, #6b7280)',
}
return { background: gradients[olympiad.data?.status] || 'linear-gradient(135deg, #1e3a5f, #2563eb)' }
})
const statusBadgeClass = computed(() => {
const map: Record<string, string> = {
'Registration Open': 'bg-white/20 text-white',
'In Progress': 'bg-white/20 text-white',
'Completed': 'bg-white/20 text-white',
}
return map[olympiad.data?.status] || 'bg-white/20 text-white'
})
const statusDotClass = computed(() => {
const map: Record<string, string> = {
'Registration Open': 'bg-blue-300',
'In Progress': 'bg-green-300',
'Completed': 'bg-gray-300',
}
return map[olympiad.data?.status] || 'bg-white'
})
const prizeColor = (prize: string) => {
return {
Gold: 'text-ink-amber-3',
Silver: 'text-ink-gray-6',
Bronze: 'text-orange-600',
Participant: 'text-ink-gray-5',
}[prize] || 'text-ink-gray-5'
}
const registerForOlympiad = async () => {
registering.value = true
try {
await call('lms.lms.api.register_for_olympiad', {
olympiad: props.olympiadName,
grade: regForm.value.grade,
school: regForm.value.school,
teacher_name: regForm.value.teacher_name,
})
toast.success(__('Successfully registered!'))
olympiad.reload()
} catch (e: any) {
toast.error(e?.messages?.[0] || __('Registration failed.'))
} finally {
registering.value = false
}
}
const unregister = async () => {
unregistering.value = true
try {
await call('lms.lms.api.unregister_from_olympiad', { olympiad: props.olympiadName })
toast.success(__('Registration cancelled.'))
olympiad.reload()
} catch (e: any) {
toast.error(e?.messages?.[0] || __('Error cancelling registration.'))
} finally {
unregistering.value = false
}
}
const breadcrumbs = computed(() => [
{ label: __('Olympiads'), route: { name: 'Olympiads' } },
{ label: olympiad.data?.title || props.olympiadName, route: { name: 'OlympiadDetail' } },
])
usePageMeta(() => ({
title: olympiad.data?.title || __('Olympiad'),
}))
</script>

View File

@@ -0,0 +1,130 @@
<template>
<header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<!-- Hero banner -->
<div class="bg-gradient-to-br from-ink-gray-9 to-surface-gray-7 px-5 py-10 text-white">
<div class="max-w-2xl">
<div class="flex items-center gap-2 mb-2">
<Trophy class="h-6 w-6 text-ink-amber-3" />
<span class="text-sm font-medium text-ink-amber-3 uppercase tracking-wider">
{{ __('Olympiads') }}
</span>
</div>
<h1 class="text-3xl font-bold mb-3">{{ __('Academic Olympiads') }}</h1>
<p class="text-base text-surface-gray-3 mb-6">
{{ __('Test your knowledge, compete with peers and earn recognition.') }}
</p>
<div class="relative max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-gray-5" />
<input
v-model="searchQuery"
type="text"
:placeholder="__('Search olympiads...')"
class="w-full pl-9 pr-4 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 text-sm"
@input="debouncedSearch"
/>
</div>
</div>
</div>
<div class="p-5 pb-10">
<!-- Tab filters -->
<div class="flex items-center justify-between mb-6 flex-wrap gap-3">
<TabButtons :buttons="statusTabs" v-model="currentStatus" class="w-fit" />
<div class="text-sm text-ink-gray-5">
{{ filteredOlympiads.length }} {{ __('olympiads') }}
</div>
</div>
<!-- Grid -->
<div
v-if="filteredOlympiads.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<router-link
v-for="olympiad in filteredOlympiads"
:key="olympiad.name"
:to="{ name: 'OlympiadDetail', params: { olympiadName: olympiad.name } }"
class="group"
>
<OlympiadCard :olympiad="olympiad" />
</router-link>
</div>
<EmptyState v-else-if="!olympiads.loading" type="Olympiads" />
<div v-if="olympiads.loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="i in 6"
:key="i"
class="h-52 rounded-xl bg-surface-gray-2 animate-pulse"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Breadcrumbs, TabButtons, createResource, usePageMeta } from 'frappe-ui'
import { Trophy, Search } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import EmptyState from '@/components/EmptyState.vue'
import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue'
const { brand } = sessionStore()
const searchQuery = ref('')
const currentStatus = ref('all')
let searchTimer: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
olympiads.reload()
}, 300)
}
const olympiads = createResource({
url: 'lms.lms.api.get_olympiads',
auto: true,
})
onMounted(() => {
olympiads.reload()
})
const statusTabs = computed(() => [
{ label: __('All'), value: 'all' },
{ label: __('Registration Open'), value: 'Registration Open' },
{ label: __('In Progress'), value: 'In Progress' },
{ label: __('Completed'), value: 'Completed' },
])
const filteredOlympiads = computed(() => {
let list: any[] = olympiads.data || []
if (currentStatus.value !== 'all') {
list = list.filter((o: any) => o.status === currentStatus.value)
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(
(o: any) =>
o.title?.toLowerCase().includes(q) ||
o.subject?.toLowerCase().includes(q)
)
}
return list
})
const breadcrumbs = computed(() => [
{ label: __('Olympiads'), route: { name: 'Olympiads' } },
])
usePageMeta(() => ({
title: __('Olympiads'),
icon: brand.favicon,
}))
</script>

View File

@@ -266,6 +266,17 @@ const routes = [
name: 'Search',
component: () => import('@/pages/Search/Search.vue'),
},
{
path: '/olympiads',
name: 'Olympiads',
component: () => import('@/pages/Olympiads/Olympiads.vue'),
},
{
path: '/olympiads/:olympiadName',
name: 'OlympiadDetail',
component: () => import('@/pages/Olympiads/OlympiadDetail.vue'),
props: true,
},
{
path: '/data-import',
name: 'DataImportList',

View File

@@ -503,6 +503,12 @@ const getSidebarItems = () => {
return userResource?.data
},
},
{
label: 'Olympiads',
icon: 'Trophy',
to: 'Olympiads',
activeFor: ['Olympiads', 'OlympiadDetail'],
},
{
label: 'Jobs',
icon: 'Briefcase',