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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
131
frontend/src/pages/Olympiads/OlympiadCard.vue
Normal file
131
frontend/src/pages/Olympiads/OlympiadCard.vue
Normal 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>
|
||||
452
frontend/src/pages/Olympiads/OlympiadDetail.vue
Normal file
452
frontend/src/pages/Olympiads/OlympiadDetail.vue
Normal 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>
|
||||
130
frontend/src/pages/Olympiads/Olympiads.vue
Normal file
130
frontend/src/pages/Olympiads/Olympiads.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -503,6 +503,12 @@ const getSidebarItems = () => {
|
||||
return userResource?.data
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Olympiads',
|
||||
icon: 'Trophy',
|
||||
to: 'Olympiads',
|
||||
activeFor: ['Olympiads', 'OlympiadDetail'],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
icon: 'Briefcase',
|
||||
|
||||
Reference in New Issue
Block a user