Compare commits

..

11 Commits

Author SHA1 Message Date
9e31d45492 feat: redesign leaderboard with podium, progress bars, colored avatars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:19:20 +03:00
80ef2023b2 fix: card spacing between desc and stats, youtube embed origin param 2026-03-16 07:32:37 +03:00
b95b1828dc fix: switch admin home stream from Twitch to YouTube (lofi girl) 2026-03-16 07:14:39 +03:00
7e444922f2 fix: symmetric card footer padding, add enlightrussia.ru to Twitch parent
- CourseCard: card body pb-0, footer py-3 — equal spacing above/below border
- StreamEmbed: add enlightrussia.ru as explicit Twitch parent domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:06:59 +03:00
a26f02065a fix: increase card image height, fix title overflow, fix Twitch error 2000
- CourseCard: raise image from h-40 to h-52 (less cropping)
- CourseCard: use -webkit-line-clamp inline style to hard-clip title overflow
- CourseCard: reduce footer bottom padding (pb-1)
- StreamEmbed: pass both current hostname and localhost as Twitch parent
  params to fix error 2000 in local/dev environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 06:59:34 +03:00
4d80c84775 feat: fix leaderboard, redesign student profile, olympiad admin, ru i18n
LeaderBoard:
- Fix bug: allUsers now loaded before logsResource on mount
- Fallback: show users with 0 points when Energy Point Log is empty
- Replace FontAwesome with lucide-vue-next (Crown, Medal, Trophy, User)
- Replace all hardcoded hex colors with Frappe UI CSS variables

StudentProfile:
- Remove ~40 debug console.log statements
- Increase avatar to 96px, use amber CSS token gradient
- All scoped CSS colors converted to rgb(var(--color-*)) tokens

Olympiads admin:
- Add OlympiadForm.vue for create/edit with all fields
- Add routes /olympiads/new and /olympiads/:name/edit
- Add "Create Olympiad" button for moderators/instructors
- Add sidebar entry for admin users

Russian translations:
- Add Olympiad module strings to lms/locale/ru.po

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 06:52:15 +03:00
c9dbc12a44 feat: fullscreen stream on admin home, fix card titles 2-line clamp
- Remove greeting for admin view, render stream fullscreen (no scroll)
- AdminHome: stream fills full viewport height via flex layout
- StreamEmbed: fills parent height instead of fixed aspect-ratio
- Set Twitch channel to lofigirl
- CourseCard: clamp title to 2 lines with fixed min-height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 06:47:10 +03:00
bea5f78b6e feat: add StreamEmbed widget, move course progress to footer corner
- Add StreamEmbed component supporting Twitch and YouTube embeds
  with live badge, platform icon, aspect-ratio 16:9, external link
- Replace AudioPlayer on AdminHome with StreamEmbed (configure
  channel name in streamConfig ref)
- Move course progress % to bottom-right footer corner alongside
  price/cert badge; remove separate progress row above footer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 06:32:25 +03:00
ab329301bf feat: add AudioPlayer widget on home, fix CourseCard stats alignment
- Add AudioPlayer component with playlist, seek, volume, animated EQ
- Replace "Courses Created" section on AdminHome with AudioPlayer
- Fix CourseCard stats row: use flex-1 spacer so stats + footer are
  always anchored to the bottom regardless of description length

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 06:26:30 +03:00
e9b26ee639 fix: remove hero banners, fix theme tokens, align course card stats
- Remove dark gradient hero sections from Courses and Olympiads pages
- Move search input inline with tab filters
- Fix StudentProfile scoped styles to use Frappe UI CSS variables
  instead of hardcoded hex colors (fixes white/dark theme compatibility)
- Fix CourseCard stats row jumping by adding min-height to description
  and stats container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 06:15:55 +03:00
3882787e50 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>
2026-03-16 05:50:39 +03:00
26 changed files with 3809 additions and 1168 deletions

81
CLAUDE.md Normal file
View File

@@ -0,0 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Frappe LMS — full-stack Learning Management System. Fork of the official Frappe LMS app.
- **Backend:** Frappe Framework (Python 3.10+), 77 Doctypes
- **Frontend:** Vue 3 + Vite + TailwindCSS + Frappe UI
- **Testing:** Cypress E2E
## Commands
### Frontend Development
```bash
cd frontend
yarn dev # Dev server
yarn build # Build to /lms/public/frontend/
yarn lint # ESLint
```
### Root Level
```bash
npm run dev # Same as frontend yarn dev
npm run build # Same as frontend yarn build
npm test-local # Open Cypress test runner
```
### Backend (requires Frappe bench)
```bash
bench start # Start all services (Frappe, Redis, Worker)
bench --site [site] migrate # Run DB migrations after pulling
bench --site [site] build # Rebuild frontend assets
bench run-tests --app lms # Run backend tests
```
### Docker (development)
```bash
docker compose up -d # Start all services
```
## Architecture
### Frontend (`frontend/src/`)
- **`pages/`** — Page-level components (CourseDetail, Lesson, Batch, Quiz, etc.)
- **`components/`** — Reusable UI components
- **`stores/`** — Pinia state: `user`, `session`, `settings`, `sidebar`
- **`utils/`** — Helper functions
- Vue Router base path: `/lms`
**Data fetching:** Frappe UI's `createResource` / `createListResource` for all API calls. No manual fetch() calls.
### Backend (`lms/lms/`)
- **`doctype/`** — 77 Frappe Doctypes (Course, LMS Batch, Course Lesson, Quiz, LMS Assignment, Certification, etc.)
- **`api.py`** — All `@frappe.whitelist()` API endpoints callable from frontend
- **`utils.py`** — Shared backend helpers
- **`hooks.py`** — Frappe app configuration (permissions, integrations, scheduled tasks)
- **`plugins.py`** — Plugin system for customization
- **`patches/`** — DB migration patches (v0_0, v1_0, v2_0)
### Key Concepts
**DocType hierarchy:** `Course → Course Chapter → Course Lesson`
**Enrollment:** `Batch Enrollment` links user to `LMS Batch`
**Assessments:** Quiz, LMS Assignment, LMS Assessment are separate doctypes
**User roles:** Learner, Instructor, Evaluator, Course Creator, Batch Observer
## Commit Convention
Uses semantic commits (enforced by commitlint):
```
feat: add new feature
fix: bug fix
chore: maintenance
docs: documentation
```
## Build Output
Vite builds frontend to `/lms/public/frontend/` and copies entry to `/lms/www/lms.html`.

View File

@@ -0,0 +1,255 @@
<template>
<div class="border border-outline-gray-2 rounded-xl bg-surface-white overflow-hidden">
<!-- Track header -->
<div class="flex items-center gap-4 p-4 border-b border-outline-gray-1">
<!-- Cover art -->
<div
class="w-14 h-14 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden"
:style="currentTrack.cover
? { backgroundImage: `url(${currentTrack.cover})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: 'linear-gradient(135deg, #f97316, #f59e0b)' }"
>
<Music v-if="!currentTrack.cover" class="w-6 h-6 text-white" />
</div>
<!-- Track info -->
<div class="flex-1 min-w-0">
<div class="font-semibold text-ink-gray-9 text-sm truncate">
{{ currentTrack.title || __('Unknown Track') }}
</div>
<div class="text-xs text-ink-gray-5 truncate mt-0.5">
{{ currentTrack.artist || __('Unknown Artist') }}
</div>
</div>
<!-- Volume -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<button @click="toggleMute" class="text-ink-gray-5 hover:text-ink-gray-9 transition-colors">
<VolumeX v-if="muted || volume === 0" class="w-4 h-4" />
<Volume2 v-else class="w-4 h-4" />
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
:value="muted ? 0 : volume"
@input="setVolume"
class="w-16 h-1 accent-orange-500 cursor-pointer"
/>
</div>
</div>
<!-- Progress bar -->
<div class="px-4 pt-3 pb-1">
<div
class="relative w-full h-1.5 bg-surface-gray-3 rounded-full cursor-pointer group"
@click="seek"
ref="progressBar"
>
<div
class="h-full bg-orange-500 rounded-full transition-all duration-100"
:style="{ width: progressPercent + '%' }"
/>
<div
class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-orange-500 rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity"
:style="{ left: `calc(${progressPercent}% - 6px)` }"
/>
</div>
<div class="flex justify-between text-[10px] text-ink-gray-5 mt-1">
<span>{{ formatTime(currentTime) }}</span>
<span>{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Controls -->
<div class="flex items-center justify-center gap-4 px-4 pb-4">
<button
@click="prevTrack"
:disabled="tracks.length <= 1"
class="text-ink-gray-5 hover:text-ink-gray-9 transition-colors disabled:opacity-30"
>
<SkipBack class="w-5 h-5" />
</button>
<button
@click="togglePlay"
class="w-10 h-10 rounded-full bg-orange-500 hover:bg-orange-600 active:bg-orange-700 flex items-center justify-center text-white transition-colors shadow-sm"
>
<Pause v-if="isPlaying" class="w-5 h-5" />
<Play v-else class="w-5 h-5 ml-0.5" />
</button>
<button
@click="nextTrack"
:disabled="tracks.length <= 1"
class="text-ink-gray-5 hover:text-ink-gray-9 transition-colors disabled:opacity-30"
>
<SkipForward class="w-5 h-5" />
</button>
</div>
<!-- Playlist -->
<div v-if="tracks.length > 1" class="border-t border-outline-gray-1">
<div
v-for="(track, i) in tracks"
:key="i"
@click="selectTrack(i)"
class="flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors"
:class="i === currentIndex
? 'bg-surface-amber-1 text-ink-amber-4'
: 'hover:bg-surface-gray-1 text-ink-gray-7'"
>
<div class="w-5 flex-shrink-0 text-center">
<span v-if="i === currentIndex && isPlaying">
<div class="flex items-end gap-0.5 justify-center h-3.5">
<span class="w-0.5 bg-orange-500 rounded-full animate-bounce" style="height: 60%; animation-delay: 0ms" />
<span class="w-0.5 bg-orange-500 rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms" />
<span class="w-0.5 bg-orange-500 rounded-full animate-bounce" style="height: 40%; animation-delay: 75ms" />
</div>
</span>
<span v-else class="text-[10px] text-ink-gray-4">{{ i + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium truncate">{{ track.title }}</div>
<div class="text-[10px] text-ink-gray-4 truncate mt-0.5">{{ track.artist }}</div>
</div>
<div class="text-[10px] text-ink-gray-4 flex-shrink-0">
{{ track.durationLabel || '' }}
</div>
</div>
</div>
<!-- Hidden audio element -->
<audio
ref="audioEl"
:src="currentTrack.src"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
@ended="onEnded"
@error="onError"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { Music, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-vue-next'
interface Track {
title: string
artist?: string
src: string
cover?: string
durationLabel?: string
}
const props = withDefaults(defineProps<{
tracks?: Track[]
}>(), {
tracks: () => [],
})
const audioEl = ref<HTMLAudioElement | null>(null)
const progressBar = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(0.8)
const muted = ref(false)
const currentIndex = ref(0)
const currentTrack = computed(() => props.tracks[currentIndex.value] || {} as Track)
const progressPercent = computed(() => duration.value ? (currentTime.value / duration.value) * 100 : 0)
const togglePlay = () => {
if (!audioEl.value || !currentTrack.value.src) return
if (isPlaying.value) {
audioEl.value.pause()
isPlaying.value = false
} else {
audioEl.value.play().catch(() => {})
isPlaying.value = true
}
}
const seek = (e: MouseEvent) => {
if (!audioEl.value || !progressBar.value || !duration.value) return
const rect = progressBar.value.getBoundingClientRect()
const ratio = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1)
audioEl.value.currentTime = ratio * duration.value
}
const setVolume = (e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value)
volume.value = v
muted.value = false
if (audioEl.value) audioEl.value.volume = v
}
const toggleMute = () => {
muted.value = !muted.value
if (audioEl.value) audioEl.value.muted = muted.value
}
const selectTrack = (i: number) => {
currentIndex.value = i
isPlaying.value = false
currentTime.value = 0
duration.value = 0
}
const nextTrack = () => {
if (props.tracks.length <= 1) return
currentIndex.value = (currentIndex.value + 1) % props.tracks.length
isPlaying.value = false
}
const prevTrack = () => {
if (props.tracks.length <= 1) return
currentIndex.value = (currentIndex.value - 1 + props.tracks.length) % props.tracks.length
isPlaying.value = false
}
const onTimeUpdate = () => {
if (audioEl.value) currentTime.value = audioEl.value.currentTime
}
const onLoadedMetadata = () => {
if (audioEl.value) {
duration.value = audioEl.value.duration
audioEl.value.volume = volume.value
}
}
const onEnded = () => {
isPlaying.value = false
if (props.tracks.length > 1) {
nextTrack()
}
}
const onError = () => {
isPlaying.value = false
}
const formatTime = (sec: number) => {
if (!sec || isNaN(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
// Auto-play when track changes and was already playing
watch(currentIndex, async () => {
await new Promise(r => setTimeout(r, 50))
if (audioEl.value) {
audioEl.value.load()
if (isPlaying.value) audioEl.value.play().catch(() => {})
}
})
onUnmounted(() => {
if (audioEl.value) audioEl.value.pause()
})
</script>

View File

@@ -1,146 +1,166 @@
<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: 340px"
>
<!-- 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-52 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 px-4 pt-4 pb-0">
<!-- Title (when image present): max 2 lines -->
<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 text-sm"
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.5rem"
>
{{ course.title }}
</div>
<div class="short-introduction text-sm">
<!-- Short intro: clamp to 2 lines, fixed height -->
<p class="text-xs text-ink-gray-5 leading-relaxed line-clamp-2 mt-0.5" style="height: 2.5rem; overflow: hidden">
{{ course.short_introduction }}
</p>
<!-- Spacer: pushes stats + footer to the bottom -->
<div class="flex-1" />
<!-- Stats row -->
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-3 mt-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">
{{ 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 py-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: progress % / price / cert -->
<div class="flex items-center gap-2">
<span
v-if="user && course.membership"
class="text-xs font-medium text-ink-green-3"
>
{{ Math.ceil(course.membership.progress) }}%
</span>
<span v-else-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 +171,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

@@ -0,0 +1,120 @@
<template>
<div class="w-full h-full flex flex-col overflow-hidden bg-black">
<!-- Stream header -->
<div class="flex items-center justify-between px-4 py-3 bg-surface-gray-9 border-b border-outline-gray-7">
<div class="flex items-center gap-2.5">
<!-- Live badge -->
<span
v-if="isLive"
class="flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wide bg-red-600 text-white"
>
<span class="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
LIVE
</span>
<span
v-else
class="flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-semibold uppercase tracking-wide bg-surface-gray-7 text-ink-gray-4"
>
OFFLINE
</span>
<!-- Platform icon + channel -->
<div class="flex items-center gap-1.5">
<!-- Twitch icon -->
<svg v-if="platform === 'twitch'" class="w-4 h-4 text-purple-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/>
</svg>
<!-- YouTube icon -->
<svg v-else-if="platform === 'youtube'" class="w-4 h-4 text-red-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
<span class="text-sm font-semibold text-white">{{ channelDisplay }}</span>
</div>
</div>
<!-- Open in new tab -->
<a
:href="externalUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 text-xs text-ink-gray-4 hover:text-white transition-colors"
>
<ExternalLink class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ __('Open') }}</span>
</a>
</div>
<!-- Embed iframe: fills remaining height -->
<div class="relative flex-1 min-h-0 w-full">
<iframe
v-if="embedUrl"
:src="embedUrl"
class="absolute inset-0 w-full h-full"
frameborder="0"
allowfullscreen
allow="autoplay; fullscreen"
:title="channelDisplay"
/>
<!-- Placeholder when no channel configured -->
<div
v-else
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-surface-gray-9"
>
<Radio class="w-12 h-12 text-ink-gray-6 stroke-1" />
<p class="text-sm text-ink-gray-5">{{ __('Stream channel not configured') }}</p>
<p class="text-xs text-ink-gray-4">{{ __('Set channel prop to a Twitch or YouTube channel') }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ExternalLink, Radio } from 'lucide-vue-next'
const props = withDefaults(defineProps<{
/** 'twitch' or 'youtube' */
platform?: 'twitch' | 'youtube'
/** Twitch: channel name. YouTube: video/stream ID or channel handle */
channel?: string
/** Show as live */
isLive?: boolean
}>(), {
platform: 'twitch',
channel: '',
isLive: true,
})
const channelDisplay = computed(() => props.channel || '—')
const embedUrl = computed(() => {
if (!props.channel) return ''
if (props.platform === 'twitch') {
// Twitch requires parent= for every domain the embed runs on.
const hosts = new Set<string>([
'enlightrussia.ru',
'localhost',
window.location.hostname,
].filter(Boolean))
const parentParams = [...hosts].map(h => `parent=${h}`).join('&')
return `https://player.twitch.tv/?channel=${props.channel}&${parentParams}&autoplay=false`
}
if (props.platform === 'youtube') {
// Supports both video ID and live stream
const origin = encodeURIComponent(window.location.origin)
return `https://www.youtube.com/embed/${props.channel}?autoplay=0&rel=0&origin=${origin}`
}
return ''
})
const externalUrl = computed(() => {
if (!props.channel) return '#'
if (props.platform === 'twitch') return `https://twitch.tv/${props.channel}`
if (props.platform === 'youtube') return `https://youtube.com/watch?v=${props.channel}`
return '#'
})
</script>

View File

@@ -46,51 +46,52 @@
</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') }}
</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
<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">
<div class="flex items-center gap-2 flex-wrap">
<TabButtons :buttons="courseTabs" v-model="currentTab" class="w-fit" />
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-ink-gray-4 pointer-events-none" />
<input
v-model="title"
:placeholder="__('Search')"
type="text"
class="w-full lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Search...')"
class="pl-8 pr-3 py-1.5 rounded-md border border-outline-gray-2 text-sm text-ink-gray-9 bg-surface-white focus:outline-none focus:ring-1 focus:ring-ink-blue-3 w-44"
@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>
</div>
<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="__('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 +100,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 +126,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

@@ -1,266 +1,30 @@
<template>
<div>
<div v-if="createdCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
{{ __('Courses Created') }}
</span>
<router-link
:to="{
name: 'Courses',
}"
>
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
<span>
{{ __('See all') }}
</span>
<MoveRight class="size-3 stroke-1.5" />
</span>
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
<router-link
v-for="course in createdCourses.data"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
</router-link>
</div>
</div>
<div v-if="createdBatches.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
{{ __('Upcoming Batches') }}
</span>
<router-link
:to="{
name: 'Batches',
}"
>
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
<span>
{{ __('See all') }}
</span>
<MoveRight class="size-3 stroke-1.5" />
</span>
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<router-link
v-for="batch in createdBatches.data"
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
>
<BatchCard :batch="batch" />
</router-link>
</div>
</div>
<div
v-if="!createdCourses.data?.length && !createdBatches.data?.length"
class="flex flex-col items-center justify-center mt-60"
>
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-1.5">
{{ __('No courses created') }}
</div>
<div
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
>
{{
__(
'There are no courses currently. Create your first course to get started!'
)
}}
</div>
<router-link
:to="{ name: 'Courses', query: { newCourse: '1' } }"
class="mt-4"
>
<Button>
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Create Course') }}
</Button>
</router-link>
</div>
<div class="grid grid-cols-2 gap-5 mt-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="evaluation in evals?.data"
class="border hover:border-outline-gray-3 rounded-md p-3 flex flex-col h-full cursor-pointer"
@click="redirectToProfile()"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ evaluation.course_title }}
</div>
<div class="text-ink-gray-7 text-sm">
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evaluation.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evaluation.member_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="cls in liveClasses?.data"
class="border hover:border-outline-gray-3 rounded-md p-3"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
<!-- Fullscreen stream: fills all available space, no scroll -->
<div class="w-full h-full flex flex-col overflow-hidden">
<!-- Stream fills remaining height -->
<div class="flex-1 min-h-0 relative bg-black">
<StreamEmbed
:platform="streamConfig.platform"
:channel="streamConfig.channel"
:is-live="streamConfig.isLive"
class="h-full"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, createResource, Tooltip } from 'frappe-ui'
import { inject } from 'vue'
import { useRouter } from 'vue-router'
import {
Calendar,
Clock,
GraduationCap,
Info,
Monitor,
MoveRight,
Plus,
Video,
} from 'lucide-vue-next'
import { formatTime } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
const user = inject<any>('$user')
const dayjs = inject<any>('$dayjs')
const router = useRouter()
<script setup lang="ts">
import { ref } from 'vue'
import StreamEmbed from '@/components/StreamEmbed.vue'
const props = defineProps<{
liveClasses?: { data?: any[] }
evals?: { data?: any[] }
}>()
const createdCourses = createResource({
url: 'lms.lms.api.get_created_courses',
auto: true,
const streamConfig = ref({
platform: 'youtube' as 'twitch' | 'youtube',
channel: 'jfKfPfyJRdk',
isLive: true,
})
const createdBatches = createResource({
url: 'lms.lms.api.get_created_batches',
auto: true,
})
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const canAccessClass = (cls: {
date: string
time: string
duration: number
}) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const hasClassEnded = (cls: {
date: string
time: string
duration: number
}) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const redirectToProfile = () => {
router.push({
name: 'ProfileEvaluationSchedule',
params: { username: user.data?.username },
})
}
</script>

View File

@@ -1,39 +1,40 @@
<template>
<div class="w-full px-5 pt-5 pb-10">
<div class="space-y-2">
<!-- Admin view: fullscreen stream, no header, no scroll -->
<div
v-if="isAdmin && currentTab === 'instructor'"
class="w-full h-full overflow-hidden"
>
<AdminHome
:liveClasses="adminLiveClasses"
:evals="adminEvals"
/>
</div>
<!-- Student view: greeting + content -->
<div v-else class="w-full px-5 pt-5 pb-10">
<div class="space-y-2 mb-6">
<div class="flex items-center justify-between">
<div class="text-xl font-bold text-ink-gray-9">
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
</div>
<div>
<div
v-if="!isAdmin"
@click="showStreakModal = true"
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
>
<span> 🔥 </span>
<span class="text-ink-gray-9">
{{ streakInfo.data?.current_streak }}
</span>
</div>
<div
@click="showStreakModal = true"
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
>
<span> 🔥 </span>
<span class="text-ink-gray-9">
{{ streakInfo.data?.current_streak }}
</span>
</div>
</div>
<div class="text-lg text-ink-gray-6 leading-6">
<div class="text-base text-ink-gray-6 leading-6">
{{ subtitle }}
</div>
</div>
<AdminHome
v-if="isAdmin && currentTab === 'instructor'"
:liveClasses="adminLiveClasses"
:evals="adminEvals"
/>
<StudentHome
v-else-if="currentTab === 'student'"
:myLiveClasses="myLiveClasses"
/>
<StudentHome :myLiveClasses="myLiveClasses" />
</div>
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
</template>
<script setup lang="ts">

File diff suppressed because it is too large Load Diff

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,345 @@
<template>
<div>
<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 class="olympiad-form-page">
<div class="form-card">
<div class="form-card-header">
<h2 class="form-title">{{ isEdit ? __('Edit Olympiad') : __('New Olympiad') }}</h2>
<p class="form-subtitle">{{ __('Fill in the olympiad details below') }}</p>
</div>
<div class="form-body">
<!-- Title -->
<div class="field-group field-full">
<FormControl
v-model="form.title"
:label="__('Title')"
type="text"
:placeholder="__('Olympiad title')"
required
/>
</div>
<!-- Subject -->
<div class="field-group field-full">
<FormControl
v-model="form.subject"
:label="__('Subject')"
type="text"
:placeholder="__('e.g. Mathematics, Physics')"
/>
</div>
<!-- Description -->
<div class="field-group field-full">
<label class="field-label">{{ __('Description') }}</label>
<textarea
v-model="form.description"
class="form-textarea"
rows="4"
:placeholder="__('Describe the olympiad...')"
></textarea>
</div>
<!-- Registration dates -->
<div class="field-group">
<label class="field-label">{{ __('Registration Start') }}</label>
<DatePicker v-model="form.registration_start" class="w-full" />
</div>
<div class="field-group">
<label class="field-label">{{ __('Registration End') }}</label>
<DatePicker v-model="form.registration_end" class="w-full" />
</div>
<!-- Olympiad dates -->
<div class="field-group">
<label class="field-label">{{ __('Olympiad Start') }}</label>
<DatePicker v-model="form.olympiad_start" class="w-full" />
</div>
<div class="field-group">
<label class="field-label">{{ __('Olympiad End') }}</label>
<DatePicker v-model="form.olympiad_end" class="w-full" />
</div>
<!-- Max participants -->
<div class="field-group">
<FormControl
v-model="form.max_participants"
:label="__('Max Participants')"
type="number"
:placeholder="__('Leave empty for unlimited')"
min="1"
/>
</div>
<!-- Passing score -->
<div class="field-group">
<FormControl
v-model="form.passing_score"
:label="__('Passing Score')"
type="number"
placeholder="0"
min="0"
/>
</div>
<!-- Status -->
<div class="field-group field-full">
<label class="field-label">{{ __('Status') }}</label>
<Select
v-model="form.status"
:options="statusOptions"
class="w-full"
/>
</div>
<!-- Actions -->
<div class="form-actions">
<Button
variant="solid"
@click="saveOlympiad"
:loading="saving"
:disabled="!form.title"
>
{{ saving ? __('Saving...') : (isEdit ? __('Save Changes') : __('Create Olympiad')) }}
</Button>
<Button variant="subtle" @click="router.back()">
{{ __('Cancel') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Breadcrumbs, FormControl, Button, DatePicker, Select, createResource, toast } from 'frappe-ui'
const router = useRouter()
const route = useRoute()
const props = defineProps<{
olympiadName?: string
}>()
const isEdit = computed(() => !!props.olympiadName)
const saving = ref(false)
const form = ref({
title: '',
subject: '',
description: '',
registration_start: '',
registration_end: '',
olympiad_start: '',
olympiad_end: '',
max_participants: '',
passing_score: '',
status: 'Draft',
})
const statusOptions = computed(() => [
{ label: __('Draft'), value: 'Draft' },
{ label: __('Registration Open'), value: 'Registration Open' },
{ label: __('In Progress'), value: 'In Progress' },
{ label: __('Completed'), value: 'Completed' },
])
const breadcrumbs = computed(() => [
{ label: __('Olympiads'), route: { name: 'Olympiads' } },
{
label: isEdit.value ? __('Edit Olympiad') : __('New Olympiad'),
route: isEdit.value
? { name: 'OlympiadEdit', params: { olympiadName: props.olympiadName } }
: { name: 'OlympiadNew' },
},
])
const existingDoc = createResource({
url: 'frappe.client.get',
auto: false,
onSuccess(data: any) {
form.value.title = data.title || ''
form.value.subject = data.subject || ''
form.value.description = data.description || ''
form.value.registration_start = data.registration_start || ''
form.value.registration_end = data.registration_end || ''
form.value.olympiad_start = data.olympiad_start || ''
form.value.olympiad_end = data.olympiad_end || ''
form.value.max_participants = data.max_participants || ''
form.value.passing_score = data.passing_score || ''
form.value.status = data.status || 'Draft'
},
onError(error: any) {
toast.error(__('Failed to load olympiad: ') + (error.message || ''))
},
})
async function saveOlympiad() {
if (!form.value.title.trim()) {
toast.error(__('Title is required'))
return
}
saving.value = true
try {
const doc: Record<string, any> = {
doctype: 'Olympiad',
title: form.value.title,
subject: form.value.subject || '',
description: form.value.description || '',
status: form.value.status,
}
if (form.value.registration_start) doc.registration_start = form.value.registration_start
if (form.value.registration_end) doc.registration_end = form.value.registration_end
if (form.value.olympiad_start) doc.olympiad_start = form.value.olympiad_start
if (form.value.olympiad_end) doc.olympiad_end = form.value.olympiad_end
if (form.value.max_participants) doc.max_participants = parseInt(String(form.value.max_participants))
if (form.value.passing_score) doc.passing_score = parseInt(String(form.value.passing_score))
if (isEdit.value) {
await createResource({
url: 'frappe.client.save',
params: { doc: { ...doc, name: props.olympiadName } },
}).submit()
toast.success(__('Olympiad updated'))
} else {
const result: any = await createResource({
url: 'frappe.client.insert',
params: { doc },
}).submit()
toast.success(__('Olympiad created'))
const newName = result?.name
if (newName) {
router.push({ name: 'OlympiadDetail', params: { olympiadName: newName } })
return
}
}
router.push({ name: 'Olympiads' })
} catch (e: any) {
toast.error(__('Error saving olympiad: ') + (e?.message || ''))
} finally {
saving.value = false
}
}
onMounted(() => {
if (isEdit.value && props.olympiadName) {
existingDoc.update({
params: {
doctype: 'Olympiad',
name: props.olympiadName,
},
})
existingDoc.reload()
}
})
</script>
<style scoped>
.olympiad-form-page {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.form-card {
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 10px;
overflow: hidden;
}
.form-card-header {
padding: 20px 24px 16px;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
background: rgb(var(--color-surface-gray-1));
}
.form-title {
font-size: 18px;
font-weight: 700;
color: rgb(var(--color-ink-gray-9));
margin: 0 0 4px;
}
.form-subtitle {
font-size: 13px;
color: rgb(var(--color-ink-gray-5));
margin: 0;
}
.form-body {
padding: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-full {
grid-column: 1 / -1;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: rgb(var(--color-ink-gray-7));
}
.form-textarea {
width: 100%;
border: 1px solid rgb(var(--color-outline-gray-3));
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
color: rgb(var(--color-ink-gray-9));
background: rgb(var(--color-surface-white));
resize: vertical;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
font-family: inherit;
}
.form-textarea:focus {
border-color: rgb(var(--color-ink-blue-3));
box-shadow: 0 0 0 3px rgba(var(--color-surface-blue-2), 0.4);
}
.form-actions {
grid-column: 1 / -1;
display: flex;
gap: 12px;
padding-top: 8px;
border-top: 1px solid rgb(var(--color-outline-gray-1));
}
@media (max-width: 640px) {
.form-body {
grid-template-columns: 1fr;
}
.field-full {
grid-column: 1;
}
.form-actions {
grid-column: 1;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,155 @@
<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 class="p-5 pb-10">
<!-- Tab filters -->
<div class="flex items-center justify-between mb-6 flex-wrap gap-3">
<div class="flex items-center gap-2 flex-wrap">
<TabButtons :buttons="statusTabs" v-model="currentStatus" class="w-fit" />
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-ink-gray-4 pointer-events-none" />
<input
v-model="searchQuery"
type="text"
:placeholder="__('Search...')"
class="pl-8 pr-3 py-1.5 rounded-md border border-outline-gray-2 text-sm text-ink-gray-9 bg-surface-white focus:outline-none focus:ring-1 focus:ring-ink-blue-3 w-44"
@input="debouncedSearch"
/>
</div>
</div>
<div class="flex items-center gap-3">
<div class="text-sm text-ink-gray-5">
{{ filteredOlympiads.length }} {{ __('olympiads') }}
</div>
<button
v-if="canCreateOlympiad"
@click="router.push({ name: 'OlympiadNew' })"
class="create-olympiad-btn"
>
<Plus class="w-4 h-4" />
{{ __('Create Olympiad') }}
</button>
</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, Plus } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import EmptyState from '@/components/EmptyState.vue'
import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue'
const { brand } = sessionStore()
const { userResource } = usersStore()
const router = useRouter()
const canCreateOlympiad = computed(() => {
return userResource?.data?.is_moderator || userResource?.data?.is_instructor
})
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>
<style scoped>
.create-olympiad-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgb(var(--color-surface-blue-3));
color: rgb(var(--color-ink-on-blue));
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
}
.create-olympiad-btn:hover {
opacity: 0.88;
}
</style>

View File

@@ -47,7 +47,7 @@
</div>
</div>
<!-- VIEW MODE -->
<!-- VIEW MODE -->
<div v-if="!editMode" class="section-grid">
<!-- Empty profile -->
<div v-if="schoolProfileNotFound" class="empty-card">
@@ -370,7 +370,6 @@
/* ===== PAGE ===== */
.student-profile-page {
min-height: 100vh;
background: #f8f9fa;
}
.profile-content {
max-width: 960px;
@@ -383,8 +382,8 @@
/* ===== HERO CARD ===== */
.hero-card {
background: #fff;
border: 1px solid #e5e7eb;
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
@@ -407,44 +406,43 @@
}
}
.hero-avatar {
width: 64px;
height: 64px;
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(135deg, #f97316, #f59e0b);
color: #fff;
font-size: 26px;
background: linear-gradient(135deg, rgb(var(--color-surface-amber-2)), rgb(var(--color-surface-amber-3)));
color: rgb(var(--color-ink-on-amber));
font-size: 38px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.25);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.hero-info {
flex: 1;
min-width: 0;
}
.hero-name {
font-size: 20px;
font-size: 22px;
font-weight: 700;
color: #111827;
color: rgb(var(--color-ink-gray-9));
line-height: 1.3;
}
.hero-subtitle {
font-size: 13px;
color: #6b7280;
margin-top: 2px;
color: rgb(var(--color-ink-gray-5));
margin-top: 4px;
}
.hero-bio {
margin-top: 4px;
font-size: 13px;
color: #4b5563;
color: rgb(var(--color-ink-gray-7));
}
.hero-actions {
flex-shrink: 0;
}
/* ===== SECTION GRID ===== */
.section-grid {
display: grid;
@@ -463,8 +461,8 @@
/* ===== INFO CARDS ===== */
.info-card {
background: #fff;
border: 1px solid #e5e7eb;
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 8px;
transition: box-shadow 0.15s ease;
}
@@ -476,20 +474,20 @@
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #f3f4f6;
background: #fafafa;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
background: rgb(var(--color-surface-gray-1));
border-radius: 8px 8px 0 0;
}
.card-header-icon {
width: 20px;
height: 20px;
color: #f97316;
color: rgb(var(--color-ink-amber-3));
flex-shrink: 0;
}
.card-header-title {
font-size: 14px;
font-weight: 600;
color: #111827;
color: rgb(var(--color-ink-gray-9));
}
.card-body {
padding: 16px 18px;
@@ -498,7 +496,7 @@
}
.text-body {
font-size: 14px;
color: #374151;
color: rgb(var(--color-ink-gray-7));
line-height: 1.65;
white-space: pre-line;
overflow-wrap: anywhere;
@@ -510,7 +508,7 @@
display: flex;
align-items: baseline;
padding: 7px 0;
border-bottom: 1px solid #f9fafb;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
gap: 8px;
}
.info-row:last-child {
@@ -520,7 +518,7 @@
width: 130px;
flex-shrink: 0;
font-size: 13px;
color: #6b7280;
color: rgb(var(--color-ink-gray-5));
font-weight: 500;
}
@media (max-width: 639px) {
@@ -529,35 +527,29 @@
}
.info-value {
font-size: 14px;
color: #111827;
color: rgb(var(--color-ink-gray-9));
word-break: break-word;
}
.text-body {
font-size: 14px;
color: #374151;
line-height: 1.65;
white-space: pre-line;
}
/* ===== TELEGRAM LINK ===== */
.tg-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #2563eb;
color: rgb(var(--color-ink-blue-3));
font-weight: 500;
font-size: 14px;
transition: color 0.15s;
}
.tg-link:hover { color: #1d4ed8; }
.tg-link:hover { color: rgb(var(--color-ink-blue-4)); }
/* ===== BADGES ===== */
.badge-yes {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
background: rgb(var(--color-surface-green-1));
color: rgb(var(--color-ink-green-3));
font-size: 12px;
font-weight: 600;
}
@@ -565,8 +557,8 @@
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #f3f4f6;
color: #6b7280;
background: rgb(var(--color-surface-gray-2));
color: rgb(var(--color-ink-gray-5));
font-size: 12px;
font-weight: 500;
}
@@ -574,8 +566,8 @@
/* ===== EMPTY CARD ===== */
.empty-card {
grid-column: 1 / -1;
background: #fff;
border: 1px solid #e5e7eb;
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 8px;
overflow: hidden;
}
@@ -589,8 +581,8 @@
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, #fff7ed, #ffedd5);
color: #f97316;
background: rgb(var(--color-surface-amber-1));
color: rgb(var(--color-ink-amber-3));
display: flex;
align-items: center;
justify-content: center;
@@ -599,19 +591,19 @@
.empty-title {
font-size: 20px;
font-weight: 700;
color: #111827;
color: rgb(var(--color-ink-gray-9));
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #6b7280;
color: rgb(var(--color-ink-gray-5));
line-height: 1.6;
margin-bottom: 20px;
}
.empty-benefits {
text-align: left;
background: #fafafa;
border: 1px solid #f3f4f6;
background: rgb(var(--color-surface-gray-1));
border: 1px solid rgb(var(--color-outline-gray-1));
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 24px;
@@ -621,14 +613,14 @@
align-items: center;
gap: 10px;
font-size: 13px;
color: #374151;
color: rgb(var(--color-ink-gray-7));
padding: 5px 0;
}
.benefit-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f97316;
background: rgb(var(--color-ink-amber-3));
flex-shrink: 0;
}
@@ -637,58 +629,58 @@
margin-top: 0;
}
.edit-card {
background: #fff;
border: 1px solid #e5e7eb;
background: rgb(var(--color-surface-white));
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 8px;
overflow: hidden;
}
.edit-section-title {
font-size: 14px;
font-weight: 600;
color: #374151;
color: rgb(var(--color-ink-gray-7));
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
margin-bottom: 4px;
}
.edit-input {
width: 100%;
border: 1px solid #d1d5db;
border: 1px solid rgb(var(--color-outline-gray-3));
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
color: #111827;
background: #fff;
color: rgb(var(--color-ink-gray-9));
background: rgb(var(--color-surface-white));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.edit-input:focus {
border-color: #f97316;
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
border-color: rgb(var(--color-ink-amber-3));
box-shadow: 0 0 0 3px rgba(var(--color-surface-amber-2), 0.4);
}
.dropdown-list {
margin-top: 4px;
border: 1px solid #e5e7eb;
border: 1px solid rgb(var(--color-outline-gray-2));
border-radius: 6px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
background: #fff;
background: rgb(var(--color-surface-white));
max-height: 220px;
overflow-y: auto;
}
.dropdown-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid #f9fafb;
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
transition: background 0.1s;
}
.dropdown-item:last-child { border-bottom: none; }
.dropdown-item:hover { background: #fff7ed; }
.dropdown-item:hover { background: rgb(var(--color-surface-amber-1)); }
/* ===== SCROLLBAR ===== */
.dropdown-list::-webkit-scrollbar { width: 5px; }
.dropdown-list::-webkit-scrollbar-track { background: #f9fafb; }
.dropdown-list::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
.dropdown-list::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
.dropdown-list::-webkit-scrollbar-track { background: rgb(var(--color-surface-gray-1)); }
.dropdown-list::-webkit-scrollbar-thumb { background: rgb(var(--color-outline-gray-3)); border-radius: 3px; }
.dropdown-list::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-outline-gray-4)); }
</style>
<script setup>
@@ -706,13 +698,6 @@ const { user } = sessionStore();
const $user = inject('$user');
const schoolProfileNotFound = ref(false);
// Логирование инициализации
console.log('[DEBUG] Инициализация компонента:', {
user: user,
$user: $user.data,
username: $user.data?.username,
});
const props = defineProps({
username: {
type: String,
@@ -722,9 +707,7 @@ const props = defineProps({
});
const effectiveUsername = computed(() => {
const username = props.username || $user.data?.username || '';
console.log('[DEBUG] Вычисление effectiveUsername:', { propsUsername: props.username, sessionUsername: $user.data?.username, result: username });
return username;
return props.username || $user.data?.username || '';
});
const editMode = ref(false);
@@ -734,17 +717,12 @@ const profile = createResource({
url: 'frappe.client.get',
makeParams(values) {
const username = effectiveUsername.value;
console.log('[DEBUG] Запрос profile:', { doctype: 'User', filters: { username } });
return {
doctype: 'User',
filters: { username },
};
},
onSuccess(data) {
console.log('[DEBUG] Профиль загружен:', data);
},
onError(error) {
console.error('[DEBUG] Ошибка загрузки профиля:', error);
window.frappe?.msgprint({
title: 'Ошибка',
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
@@ -760,23 +738,17 @@ const schoolProfile = createResource({
filters: { user:user },
},
auto: false,
onSuccess(data) {
console.log('[DEBUG] Профиль школьника загружен:', data);
onError(error) {
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
schoolProfileNotFound.value = true;
} else {
window.frappe?.msgprint({
title: 'Ошибка',
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
indicator: 'red',
});
}
},
onError(error) {
// Проверяем, является ли ошибка "не найдено"
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
console.log('[DEBUG] Профиль школьника не найден, создаем новый');
schoolProfileNotFound.value = true;
} else {
console.error('[DEBUG] Ошибка загрузки профиля школьника:', error);
window.frappe?.msgprint({
title: 'Ошибка',
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
indicator: 'red',
});
}
},
});
const form = ref({
@@ -800,7 +772,7 @@ const form = ref({
const breadcrumbs = computed(() => {
const username = effectiveUsername.value;
const crumbs = [
return [
{
label: 'People',
route: { name: 'People' },
@@ -813,35 +785,20 @@ const breadcrumbs = computed(() => {
} : undefined,
},
];
console.log('[DEBUG] Хлебные крошки:', crumbs);
return crumbs;
});
const pageMeta = computed(() => {
const meta = {
title: profile.data?.full_name || 'Профиль',
description: profile.data?.headline || '',
};
console.log('[DEBUG] Мета-данные страницы:', meta);
return meta;
});
const pageMeta = computed(() => ({
title: profile.data?.full_name || 'Профиль',
description: profile.data?.headline || '',
}));
const displayName = computed(() => {
if (!profile.data) {
console.log('[DEBUG] displayName: profile.data не загружен');
return 'Загрузка...';
}
const name = profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
console.log('[DEBUG] Отображаемое имя:', name);
return name;
if (!profile.data) return 'Загрузка...';
return profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
});
const isSessionUser = () => {
const sessionUser = $user.data?.username;
const profileUser = effectiveUsername.value;
const isSession = sessionUser === profileUser;
console.log('[DEBUG] Проверка isSessionUser:', { sessionUser, profileUser, isSession });
return isSession;
return $user.data?.username === effectiveUsername.value;
};
function formattedDate(d) {
@@ -849,7 +806,6 @@ function formattedDate(d) {
try {
return new Date(d).toLocaleDateString('ru-RU');
} catch (e) {
console.error('[DEBUG] Ошибка форматирования даты:', e, { date: d });
return d;
}
}
@@ -870,11 +826,6 @@ function formatTelegram(t) {
}
function fillFormFromProfile() {
console.log('[DEBUG] Заполнение формы:', {
schoolProfile: schoolProfile.data,
profile: profile.data,
currentForm: JSON.stringify(form.value, null, 2),
});
form.value.first_name = schoolProfile.data?.first_name || profile.data?.first_name || '';
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
form.value.middle_name = schoolProfile.data?.middle_name || '';
@@ -891,38 +842,20 @@ function fillFormFromProfile() {
form.value.interests = schoolProfile.data?.interests || '';
form.value.about_me = schoolProfile.data?.about_me || '';
form.value.dreams = schoolProfile.data?.dreams || '';
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
}
function toggleEdit() {
editMode.value = !editMode.value;
if (editMode.value) fillFormFromProfile();
console.log('[DEBUG] Переключение режима редактирования:', { editMode: editMode.value });
}
function validateExams(exams) {
console.log('[DEBUG] Валидация exams:', { exams, validOptions: examOptions });
return exams.every(exam => examOptions.includes(exam));
}
function validateLearnSubjects(subjects) {
console.log('[DEBUG] Валидация learn_subjects:', { subjects, validOptions: learnOptions });
return subjects.every(subject => learnOptions.includes(subject));
}
async function saveProfile() {
console.log('[DEBUG] Сохранение профиля:', { form: form.value });
saving.value = true;
try {
// Создаём копию данных формы
const formData = { ...form.value };
console.log('[DEBUG] Копия formData:', JSON.stringify(formData, null, 2));
// Обновление full_name в User, если нужно
if (formData.first_name || formData.last_name) {
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
console.log('[DEBUG] Обновление User.full_name:', { name: profile.data?.name, fullName });
await createResource({
url: 'frappe.client.set_value',
params: {
@@ -934,18 +867,14 @@ async function saveProfile() {
}).submit();
}
// Получаем docname
let docname = '';
try {
await schoolProfile.reload();
console.log('[DEBUG] Schoolprofile:', { schoolProfile });
docname = schoolProfile?.data?.name;
console.log('[DEBUG] Выбранное имя документа:', docname);
} catch (error) {
console.log('[DEBUG] Ошибка загрузки schoolProfile, продолжаем с profile:', error.message);
// профиль не найден, создаём новый
}
// Формируем payload из копии данных формы
let payload = {
doctype: 'Schoolchildren Profile',
user: profile.data?.name,
@@ -967,9 +896,7 @@ async function saveProfile() {
dreams: formData.dreams,
last_updated: new Date().toISOString(),
};
console.log('[DEBUG] Сохранение Schoolchildren Profile (payload):', { docname, payload });
// Сохранение или создание документа
if (docname) {
await createResource({
url: 'frappe.client.save',
@@ -983,11 +910,9 @@ async function saveProfile() {
}
editMode.value = false;
schoolProfileNotFound.value = false;
schoolProfileNotFound.value = false;
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
console.log('[DEBUG] Профиль успешно сохранён');
} catch (e) {
console.error('[DEBUG] Ошибка при сохранении профиля:', e);
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
title: 'Ошибка',
message: (e && e.message) || 'Ошибка при сохранении',
@@ -1008,7 +933,6 @@ async function searchSchool(q) {
return;
}
try {
console.log('[DEBUG] Поиск школы:', { query: q });
const res = await createResource({
url: 'frappe.client.get_list',
params: {
@@ -1019,10 +943,8 @@ async function searchSchool(q) {
},
}).submit();
schoolResults.value = res || [];
console.log('[DEBUG] Результаты поиска школы:', schoolResults.value);
} catch (e) {
schoolResults.value = [];
console.error('[DEBUG] Ошибка поиска школы:', e);
}
}
@@ -1030,11 +952,8 @@ const debouncedSearchSchool = debounce(() => searchSchool(schoolQuery.value), 30
function selectSchool(s) {
form.value.school = s.school;
//form.value.school_name = s.school_name;
schoolResults.value = [];
schoolQuery.value = s.school;
console.log('[DEBUG] Выбрана школа:', { school: s });
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
}
const majorQuery = ref('');
@@ -1046,7 +965,6 @@ async function searchMajor(q) {
return;
}
try {
console.log('[DEBUG] Поиск направления:', { query: q });
const res = await createResource({
url: 'frappe.client.get_list',
params: {
@@ -1057,10 +975,8 @@ async function searchMajor(q) {
},
}).submit();
majorResults.value = res || [];
console.log('[DEBUG] Результаты поиска направления:', majorResults.value);
} catch (e) {
majorResults.value = [];
console.error('[DEBUG] Ошибка поиска направления:', e);
}
}
@@ -1068,40 +984,27 @@ const debouncedSearchMajor = debounce(() => searchMajor(majorQuery.value), 300);
function selectMajor(m) {
form.value.major = m.major_name;
//form.value.school_name = s.school_name;
majorResults.value = [];
majorQuery.value = m.major_name;
console.log('[DEBUG] Выбрана школа:', { major: m });
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
}
onMounted(() => {
console.log('[DEBUG] Компонент смонтирован:', {
propsUsername: props.username,
sessionUsername: $user.data?.username,
user: user,
$user: $user.data,
});
if ($user.data) {
console.log('[DEBUG] Запуск profile.reload()');
profile.reload();
}
});
watch(
() => props.username,
(newUsername, oldUsername) => {
console.log('[DEBUG] Изменение props.username:', { old: oldUsername, new: newUsername });
() => {
profile.reload();
}
);
watch(
() => profile.data,
(newData, oldData) => {
console.log('[DEBUG] Изменение profile.data:', { old: oldData, new: newData });
(newData) => {
if (newData) {
console.log('[DEBUG] Запуск schoolProfile.reload()');
schoolProfile.reload();
}
}
@@ -1109,10 +1012,8 @@ watch(
watch(
() => schoolProfile.data,
(newData, oldData) => {
console.log('[DEBUG] Изменение schoolProfile.data:', { old: oldData, new: newData });
(newData) => {
if (newData && !editMode.value && !schoolProfileNotFound.value) {
console.log('[DEBUG] Заполнение формы из schoolProfile');
fillFormFromProfile();
}
}
@@ -1121,7 +1022,6 @@ watch(
watch(
() => effectiveUsername.value,
(newUsername) => {
console.log('[DEBUG] Изменение effectiveUsername для schoolProfile:', newUsername);
schoolProfile.update({
params: {
doctype: 'Schoolchildren Profile',

View File

@@ -266,6 +266,28 @@ const routes = [
name: 'Search',
component: () => import('@/pages/Search/Search.vue'),
},
{
path: '/olympiads',
name: 'Olympiads',
component: () => import('@/pages/Olympiads/Olympiads.vue'),
},
{
path: '/olympiads/new',
name: 'OlympiadNew',
component: () => import('@/pages/Olympiads/OlympiadForm.vue'),
},
{
path: '/olympiads/:olympiadName/edit',
name: 'OlympiadEdit',
component: () => import('@/pages/Olympiads/OlympiadForm.vue'),
props: true,
},
{
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',
@@ -535,6 +541,15 @@ const getSidebarItems = () => {
label: 'Assessments',
hideLabel: true,
items: [
{
label: 'Create Olympiad',
icon: 'PlusCircle',
to: 'OlympiadNew',
condition: () => {
return isAdmin()
},
activeFor: ['OlympiadNew', 'OlympiadEdit'],
},
{
label: 'Quizzes',
icon: 'CircleHelp',

View File

@@ -2227,4 +2227,191 @@ def get_badges(member: str):
["name", "member", "badge", "badge_image", "badge_description", "issued_on"],
)
# ---------------------------------------------------------------------------
# Olympiad API
# ---------------------------------------------------------------------------
@frappe.whitelist(allow_guest=True)
def get_olympiads(status: str = None, featured: int = None, search: str = None):
"""Return list of published olympiads with optional filters."""
filters = {"is_published": 1}
if status:
filters["status"] = status
if featured:
filters["featured"] = 1
fields = [
"name",
"title",
"status",
"subject",
"grade_levels",
"image",
"short_description",
"start_date",
"end_date",
"registration_start",
"registration_end",
"participants_count",
"max_participants",
"featured",
]
olympiads = frappe.get_all(
"LMS Olympiad",
filters=filters,
fields=fields,
order_by="start_date asc",
page_length=50,
)
if search:
search_lower = search.lower()
olympiads = [
o for o in olympiads
if search_lower in (o.get("title") or "").lower()
or search_lower in (o.get("subject") or "").lower()
]
return olympiads
@frappe.whitelist(allow_guest=True)
def get_olympiad_detail(name: str):
"""Return full olympiad data including current user registration status."""
olympiad = frappe.get_doc("LMS Olympiad", name)
if not olympiad.is_published and not has_moderator_role():
frappe.throw(_("Olympiad not found."), frappe.DoesNotExistError)
data = olympiad.as_dict()
# Current user registration
data["is_registered"] = False
data["my_result"] = None
if frappe.session.user != "Guest":
participant = frappe.db.exists(
"LMS Olympiad Participant",
{"olympiad": name, "member": frappe.session.user},
)
data["is_registered"] = bool(participant)
result = frappe.db.get_value(
"LMS Olympiad Result",
{"olympiad": name, "member": frappe.session.user},
["score", "rank", "percentage", "passed", "prize"],
as_dict=True,
)
if result:
data["my_result"] = result
return data
@frappe.whitelist()
def register_for_olympiad(olympiad: str, grade: str = None, school: str = None, teacher_name: str = None):
"""Register current user for an olympiad."""
if frappe.session.user == "Guest":
frappe.throw(_("Please login to register."), frappe.PermissionError)
olympiad_doc = frappe.get_doc("LMS Olympiad", olympiad)
if not olympiad_doc.is_published:
frappe.throw(_("This olympiad is not available."))
if olympiad_doc.status not in ("Registration Open", "In Progress"):
frappe.throw(_("Registration is not open for this olympiad."))
# Check capacity
if olympiad_doc.max_participants and olympiad_doc.participants_count >= olympiad_doc.max_participants:
frappe.throw(_("This olympiad has reached maximum capacity."))
# Check duplicate
existing = frappe.db.exists(
"LMS Olympiad Participant",
{"olympiad": olympiad, "member": frappe.session.user},
)
if existing:
frappe.throw(_("You are already registered for this olympiad."))
user_full_name = frappe.db.get_value("User", frappe.session.user, "full_name")
doc = frappe.get_doc({
"doctype": "LMS Olympiad Participant",
"olympiad": olympiad,
"member": frappe.session.user,
"full_name": user_full_name,
"grade": grade,
"school": school,
"teacher_name": teacher_name,
"status": "Registered",
})
doc.insert(ignore_permissions=True)
frappe.db.commit()
return {"success": True, "message": _("Successfully registered for the olympiad.")}
@frappe.whitelist()
def unregister_from_olympiad(olympiad: str):
"""Cancel registration for current user."""
if frappe.session.user == "Guest":
frappe.throw(_("Please login."), frappe.PermissionError)
participant = frappe.db.get_value(
"LMS Olympiad Participant",
{"olympiad": olympiad, "member": frappe.session.user},
"name",
)
if not participant:
frappe.throw(_("You are not registered for this olympiad."))
frappe.delete_doc("LMS Olympiad Participant", participant, ignore_permissions=True)
frappe.db.commit()
# Update count
frappe.db.sql(
"""
UPDATE `tabLMS Olympiad`
SET participants_count = (
SELECT COUNT(*) FROM `tabLMS Olympiad Participant`
WHERE olympiad = %s
)
WHERE name = %s
""",
(olympiad, olympiad),
)
return {"success": True, "message": _("Registration cancelled.")}
@frappe.whitelist(allow_guest=True)
def get_olympiad_leaderboard(olympiad: str, page_length: int = 20, start: int = 0):
"""Return ranked results for an olympiad."""
results = frappe.db.sql(
"""
SELECT
r.rank,
r.member,
r.score,
r.percentage,
r.passed,
r.prize,
u.full_name,
u.user_image,
p.grade,
p.school
FROM `tabLMS Olympiad Result` r
LEFT JOIN `tabUser` u ON u.name = r.member
LEFT JOIN `tabLMS Olympiad Participant` p ON p.member = r.member AND p.olympiad = r.olympiad
WHERE r.olympiad = %s
ORDER BY r.rank ASC
LIMIT %s OFFSET %s
""",
(olympiad, int(page_length), int(start)),
as_dict=True,
)
return results
return badges

View File

View File

@@ -0,0 +1,218 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:title",
"creation": "2026-03-16 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"status",
"column_break_1",
"subject",
"grade_levels",
"section_break_dates",
"registration_start",
"registration_end",
"column_break_2",
"start_date",
"end_date",
"section_break_details",
"image",
"column_break_3",
"short_description",
"description",
"section_break_settings",
"max_participants",
"passing_score",
"column_break_4",
"is_published",
"featured",
"section_break_stats",
"participants_count",
"organizer"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1,
"unique": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Draft\nRegistration Open\nIn Progress\nCompleted\nCancelled",
"default": "Draft",
"reqd": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "subject",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Subject"
},
{
"fieldname": "grade_levels",
"fieldtype": "Data",
"label": "Grade Levels",
"description": "e.g. 5-9, 10-11"
},
{
"fieldname": "section_break_dates",
"fieldtype": "Section Break",
"label": "Dates"
},
{
"fieldname": "registration_start",
"fieldtype": "Date",
"label": "Registration Start"
},
{
"fieldname": "registration_end",
"fieldtype": "Date",
"label": "Registration End"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"label": "Start Date"
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"label": "End Date"
},
{
"fieldname": "section_break_details",
"fieldtype": "Section Break",
"label": "Details"
},
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Image"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "short_description",
"fieldtype": "Small Text",
"label": "Short Description"
},
{
"fieldname": "description",
"fieldtype": "Long Text",
"label": "Description"
},
{
"fieldname": "section_break_settings",
"fieldtype": "Section Break",
"label": "Settings"
},
{
"fieldname": "max_participants",
"fieldtype": "Int",
"label": "Max Participants",
"description": "0 = unlimited"
},
{
"fieldname": "passing_score",
"fieldtype": "Float",
"label": "Passing Score (%)",
"default": 60
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "is_published",
"fieldtype": "Check",
"label": "Published"
},
{
"fieldname": "featured",
"fieldtype": "Check",
"label": "Featured"
},
{
"fieldname": "section_break_stats",
"fieldtype": "Section Break",
"label": "Statistics"
},
{
"fieldname": "participants_count",
"fieldtype": "Int",
"label": "Participants Count",
"read_only": 1,
"default": 0
},
{
"fieldname": "organizer",
"fieldtype": "Link",
"label": "Organizer",
"options": "User"
}
],
"links": [],
"modified": "2026-03-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Olympiad",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 0,
"read": 1,
"role": "Course Creator",
"write": 1
},
{
"read": 1,
"role": "LMS Student"
},
{
"read": 1,
"role": "LMS Schoolchild"
},
{
"read": 1,
"role": "Guest"
}
],
"sort_field": "start_date",
"sort_order": "DESC",
"title_field": "title",
"track_changes": 1
}

View File

@@ -0,0 +1,45 @@
"""LMS Olympiad DocType controller."""
import frappe
from frappe.model.document import Document
from frappe.utils import getdate, today
class LMSOlympiad(Document):
def validate(self):
self.validate_dates()
self.update_status()
def validate_dates(self):
if self.start_date and self.end_date:
if getdate(self.start_date) > getdate(self.end_date):
frappe.throw("Start Date cannot be after End Date.")
if self.registration_start and self.registration_end:
if getdate(self.registration_start) > getdate(self.registration_end):
frappe.throw("Registration Start cannot be after Registration End.")
def update_status(self):
"""Auto-update status based on dates if still Draft."""
if self.status == "Draft":
return
today_date = getdate(today())
if self.registration_start and self.registration_end:
if getdate(self.registration_start) <= today_date <= getdate(self.registration_end):
if self.status not in ("In Progress", "Completed", "Cancelled"):
self.status = "Registration Open"
if self.start_date:
if today_date >= getdate(self.start_date):
if self.status not in ("Completed", "Cancelled"):
self.status = "In Progress"
if self.end_date:
if today_date > getdate(self.end_date):
if self.status not in ("Cancelled",):
self.status = "Completed"
def after_insert(self):
self.reload()
def on_update(self):
"""Sync participants count."""
count = frappe.db.count("LMS Olympiad Participant", {"olympiad": self.name})
frappe.db.set_value("LMS Olympiad", self.name, "participants_count", count, update_modified=False)

View File

@@ -0,0 +1,116 @@
{
"actions": [],
"creation": "2026-03-16 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"olympiad",
"member",
"column_break_1",
"status",
"registered_on",
"section_break_2",
"full_name",
"grade",
"column_break_2",
"school",
"teacher_name"
],
"fields": [
{
"fieldname": "olympiad",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Olympiad",
"options": "LMS Olympiad",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Registered\nIn Progress\nCompleted\nDisqualified",
"default": "Registered"
},
{
"fieldname": "registered_on",
"fieldtype": "Date",
"label": "Registered On",
"default": "Today"
},
{
"fieldname": "section_break_2",
"fieldtype": "Section Break",
"label": "Participant Info"
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"label": "Full Name"
},
{
"fieldname": "grade",
"fieldtype": "Data",
"label": "Grade/Class"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "school",
"fieldtype": "Data",
"label": "School"
},
{
"fieldname": "teacher_name",
"fieldtype": "Data",
"label": "Teacher / Supervisor"
}
],
"links": [],
"modified": "2026-03-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Olympiad Participant",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Moderator",
"write": 1
},
{
"create": 1,
"read": 1,
"role": "LMS Student",
"write": 0
},
{
"create": 1,
"read": 1,
"role": "LMS Schoolchild",
"write": 0
}
],
"sort_field": "registered_on",
"sort_order": "DESC",
"title_field": "member",
"track_changes": 1
}

View File

@@ -0,0 +1,41 @@
"""LMS Olympiad Participant DocType controller."""
import frappe
from frappe.model.document import Document
from frappe.utils import today
class LMSOlympiadParticipant(Document):
def validate(self):
self.prevent_duplicate_registration()
self.set_registered_on()
def prevent_duplicate_registration(self):
if not self.is_new():
return
existing = frappe.db.exists(
"LMS Olympiad Participant",
{"olympiad": self.olympiad, "member": self.member},
)
if existing:
frappe.throw(
f"You are already registered for this olympiad."
)
def set_registered_on(self):
if not self.registered_on:
self.registered_on = today()
def after_insert(self):
"""Update participants count on the olympiad."""
frappe.db.sql(
"""
UPDATE `tabLMS Olympiad`
SET participants_count = (
SELECT COUNT(*) FROM `tabLMS Olympiad Participant`
WHERE olympiad = %s
)
WHERE name = %s
""",
(self.olympiad, self.olympiad),
)

View File

@@ -0,0 +1,127 @@
{
"actions": [],
"creation": "2026-03-16 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"olympiad",
"member",
"column_break_1",
"score",
"rank",
"section_break_2",
"percentage",
"passed",
"column_break_2",
"prize",
"certificate_issued",
"section_break_3",
"remarks",
"submitted_on"
],
"fields": [
{
"fieldname": "olympiad",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Olympiad",
"options": "LMS Olympiad",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "score",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Score"
},
{
"fieldname": "rank",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Rank"
},
{
"fieldname": "section_break_2",
"fieldtype": "Section Break"
},
{
"fieldname": "percentage",
"fieldtype": "Float",
"label": "Percentage"
},
{
"fieldname": "passed",
"fieldtype": "Check",
"label": "Passed"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "prize",
"fieldtype": "Select",
"label": "Prize",
"options": "\nGold\nSilver\nBronze\nParticipant"
},
{
"fieldname": "certificate_issued",
"fieldtype": "Check",
"label": "Certificate Issued"
},
{
"fieldname": "section_break_3",
"fieldtype": "Section Break"
},
{
"fieldname": "remarks",
"fieldtype": "Small Text",
"label": "Remarks"
},
{
"fieldname": "submitted_on",
"fieldtype": "Datetime",
"label": "Submitted On"
}
],
"links": [],
"modified": "2026-03-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Olympiad Result",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Moderator",
"write": 1
},
{
"read": 1,
"role": "LMS Student"
},
{
"read": 1,
"role": "LMS Schoolchild"
}
],
"sort_field": "rank",
"sort_order": "ASC",
"title_field": "member",
"track_changes": 1
}

View File

@@ -0,0 +1,32 @@
"""LMS Olympiad Result DocType controller."""
import frappe
from frappe.model.document import Document
class LMSOlympiadResult(Document):
def validate(self):
self.compute_percentage()
self.compute_passed()
self.assign_prize()
def compute_percentage(self):
if self.score is not None and self.score >= 0:
self.percentage = self.score
def compute_passed(self):
passing_score = frappe.db.get_value("LMS Olympiad", self.olympiad, "passing_score") or 60
self.passed = 1 if (self.percentage or 0) >= passing_score else 0
def assign_prize(self):
if not self.rank:
return
if not self.prize:
if self.rank == 1:
self.prize = "Gold"
elif self.rank == 2:
self.prize = "Silver"
elif self.rank == 3:
self.prize = "Bronze"
else:
self.prize = "Participant"

View File

@@ -8320,3 +8320,99 @@ msgstr "Чем вы занимаетесь на платформе?"
msgid "Установить приложение IIE"
msgstr "Установить приложение IIE"
msgid "Olympiads"
msgstr "Олимпиады"
msgid "Academic Olympiads"
msgstr "Академические олимпиады"
msgid "Olympiad"
msgstr "Олимпиада"
msgid "Registration Open"
msgstr "Регистрация открыта"
msgid "In Progress"
msgstr "Идёт"
msgid "Completed"
msgstr "Завершена"
msgid "Register"
msgstr "Зарегистрироваться"
msgid "Unregister"
msgstr "Отменить регистрацию"
msgid "Leaderboard"
msgstr "Таблица лидеров"
msgid "Participants"
msgstr "Участники"
msgid "Subject"
msgstr "Предмет"
msgid "Create Olympiad"
msgstr "Создать олимпиаду"
msgid "New Olympiad"
msgstr "Новая олимпиада"
msgid "Edit Olympiad"
msgstr "Редактировать олимпиаду"
msgid "olympiads"
msgstr "олимпиад"
msgid "Draft"
msgstr "Черновик"
msgid "Fill in the olympiad details below"
msgstr "Заполните данные олимпиады ниже"
msgid "Olympiad title"
msgstr "Название олимпиады"
msgid "Describe the olympiad..."
msgstr "Опишите олимпиаду..."
msgid "Registration Start"
msgstr "Начало регистрации"
msgid "Registration End"
msgstr "Конец регистрации"
msgid "Olympiad Start"
msgstr "Начало олимпиады"
msgid "Olympiad End"
msgstr "Конец олимпиады"
msgid "Max Participants"
msgstr "Максимум участников"
msgid "Leave empty for unlimited"
msgstr "Оставьте пустым для неограниченного количества"
msgid "Passing Score"
msgstr "Проходной балл"
msgid "Save Changes"
msgstr "Сохранить изменения"
msgid "Title is required"
msgstr "Название обязательно"
msgid "Olympiad updated"
msgstr "Олимпиада обновлена"
msgid "Olympiad created"
msgstr "Олимпиада создана"
msgid "Failed to load olympiad: "
msgstr "Не удалось загрузить олимпиаду: "
msgid "Error saving olympiad: "
msgstr "Ошибка сохранения олимпиады: "