Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e31d45492 | |||
| 80ef2023b2 | |||
| b95b1828dc | |||
| 7e444922f2 | |||
| a26f02065a | |||
| 4d80c84775 | |||
| c9dbc12a44 | |||
| bea5f78b6e | |||
| ab329301bf | |||
| e9b26ee639 | |||
| 3882787e50 |
81
CLAUDE.md
Normal file
81
CLAUDE.md
Normal 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`.
|
||||
255
frontend/src/components/AudioPlayer.vue
Normal file
255
frontend/src/components/AudioPlayer.vue
Normal 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>
|
||||
@@ -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') }}
|
||||
<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"
|
||||
>
|
||||
<Sparkles class="h-2.5 w-2.5" />
|
||||
{{ __('New') }}
|
||||
</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"
|
||||
>
|
||||
{{ tag }}
|
||||
|
||||
<div class="absolute top-2.5 right-2.5 flex items-center gap-1">
|
||||
<Tooltip v-if="course.featured" :text="__('Featured')">
|
||||
<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>
|
||||
</div> -->
|
||||
|
||||
<!-- Title fallback when no image -->
|
||||
<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'
|
||||
"
|
||||
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 }}
|
||||
</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>
|
||||
</h3>
|
||||
</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>
|
||||
|
||||
<Tooltip v-if="course.featured" :text="__('Featured')">
|
||||
<Award class="size-4 stroke-2 text-ink-amber-3" />
|
||||
</Tooltip>
|
||||
<!-- 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>
|
||||
|
||||
120
frontend/src/components/StreamEmbed.vue
Normal file
120
frontend/src/components/StreamEmbed.vue
Normal 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>
|
||||
@@ -46,27 +46,25 @@
|
||||
</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">
|
||||
</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"
|
||||
@@ -75,22 +73,25 @@
|
||||
@update:modelValue="updateCourses()"
|
||||
/>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<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"
|
||||
>
|
||||
@@ -17,23 +27,14 @@
|
||||
</span>
|
||||
</div>
|
||||
</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
131
frontend/src/pages/Olympiads/OlympiadCard.vue
Normal file
131
frontend/src/pages/Olympiads/OlympiadCard.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full rounded-xl overflow-hidden border border-outline-gray-2 bg-surface-white transition-all duration-200 group-hover:shadow-lg group-hover:-translate-y-0.5"
|
||||
>
|
||||
<!-- Image / gradient header -->
|
||||
<div
|
||||
class="relative h-36 bg-cover bg-center"
|
||||
:style="
|
||||
olympiad.image
|
||||
? { backgroundImage: `url('${encodeURI(olympiad.image)}')` }
|
||||
: { background: statusGradient }
|
||||
"
|
||||
>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute top-3 left-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||
:class="statusBadgeClass"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass" />
|
||||
{{ __(olympiad.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Featured star -->
|
||||
<div v-if="olympiad.featured" class="absolute top-3 right-3">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-surface-amber-2 text-ink-amber-3 border border-outline-amber-1">
|
||||
<Star class="h-3 w-3 stroke-2" />
|
||||
{{ __('Featured') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title overlay when no image -->
|
||||
<div
|
||||
v-if="!olympiad.image"
|
||||
class="flex items-end h-full px-4 pb-3"
|
||||
>
|
||||
<h3 class="text-white font-bold text-lg leading-tight drop-shadow">
|
||||
{{ olympiad.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col flex-1 p-4">
|
||||
<div v-if="olympiad.image" class="font-semibold text-base text-ink-gray-9 mb-1">
|
||||
{{ olympiad.title }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-3 flex-wrap">
|
||||
<span v-if="olympiad.subject" class="flex items-center gap-1">
|
||||
<BookOpen class="h-3.5 w-3.5" />
|
||||
{{ olympiad.subject }}
|
||||
</span>
|
||||
<span v-if="olympiad.grade_levels" class="flex items-center gap-1">
|
||||
<GraduationCap class="h-3.5 w-3.5" />
|
||||
{{ olympiad.grade_levels }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="olympiad.short_description"
|
||||
class="text-sm text-ink-gray-6 leading-relaxed line-clamp-2 mb-3"
|
||||
>
|
||||
{{ olympiad.short_description }}
|
||||
</p>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="mt-auto space-y-1 text-xs text-ink-gray-5">
|
||||
<div v-if="olympiad.registration_end && olympiad.status === 'Registration Open'" class="flex items-center gap-1 text-ink-orange-3 font-medium">
|
||||
<Clock class="h-3.5 w-3.5" />
|
||||
{{ __('Registration until') }} {{ formatDate(olympiad.registration_end) }}
|
||||
</div>
|
||||
<div v-else-if="olympiad.start_date" class="flex items-center gap-1">
|
||||
<Calendar class="h-3.5 w-3.5" />
|
||||
{{ formatDate(olympiad.start_date) }}
|
||||
<span v-if="olympiad.end_date"> — {{ formatDate(olympiad.end_date) }}</span>
|
||||
</div>
|
||||
<div v-if="olympiad.participants_count" class="flex items-center gap-1">
|
||||
<Users class="h-3.5 w-3.5" />
|
||||
{{ olympiad.participants_count }}
|
||||
{{ olympiad.max_participants ? ` / ${olympiad.max_participants}` : '' }}
|
||||
{{ __('participants') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BookOpen, Calendar, Clock, GraduationCap, Star, Trophy, Users } from 'lucide-vue-next'
|
||||
import dayjs from '@/utils/dayjs'
|
||||
|
||||
const props = defineProps<{ olympiad: any }>()
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('DD MMM YYYY')
|
||||
|
||||
const statusGradient = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'Registration Open': 'linear-gradient(135deg, #1e3a5f, #2563eb)',
|
||||
'In Progress': 'linear-gradient(135deg, #14532d, #16a34a)',
|
||||
'Completed': 'linear-gradient(135deg, #3b0764, #7c3aed)',
|
||||
'Cancelled': 'linear-gradient(135deg, #1f2937, #6b7280)',
|
||||
'Draft': 'linear-gradient(135deg, #1f2937, #374151)',
|
||||
}
|
||||
return map[props.olympiad.status] || 'linear-gradient(135deg, #1e3a5f, #2563eb)'
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'Registration Open': 'bg-surface-blue-1 text-ink-blue-3 border border-outline-blue-1',
|
||||
'In Progress': 'bg-surface-green-1 text-ink-green-3 border border-outline-green-1',
|
||||
'Completed': 'bg-surface-gray-2 text-ink-gray-7 border border-outline-gray-2',
|
||||
'Cancelled': 'bg-surface-red-1 text-ink-red-3 border border-outline-red-1',
|
||||
'Draft': 'bg-surface-gray-2 text-ink-gray-5 border border-outline-gray-2',
|
||||
}
|
||||
return map[props.olympiad.status] || 'bg-surface-gray-2 text-ink-gray-7'
|
||||
})
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'Registration Open': 'bg-ink-blue-3',
|
||||
'In Progress': 'bg-ink-green-3',
|
||||
'Completed': 'bg-ink-gray-5',
|
||||
'Cancelled': 'bg-ink-red-3',
|
||||
'Draft': 'bg-ink-gray-4',
|
||||
}
|
||||
return map[props.olympiad.status] || 'bg-ink-gray-4'
|
||||
})
|
||||
</script>
|
||||
452
frontend/src/pages/Olympiads/OlympiadDetail.vue
Normal file
452
frontend/src/pages/Olympiads/OlympiadDetail.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
|
||||
<div v-if="olympiad.loading" class="p-10 flex justify-center">
|
||||
<LoadingIndicator class="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="olympiad.data">
|
||||
<!-- Hero -->
|
||||
<div
|
||||
class="relative px-5 py-12 text-white overflow-hidden"
|
||||
:style="heroStyle"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/50" />
|
||||
<div class="relative z-10 max-w-3xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full"
|
||||
:class="statusBadgeClass"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass" />
|
||||
{{ __(olympiad.data.status) }}
|
||||
</span>
|
||||
<span v-if="olympiad.data.featured" class="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-full bg-surface-amber-2 text-ink-amber-3">
|
||||
<Star class="h-3 w-3 stroke-2" />
|
||||
{{ __('Featured') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold mb-3">{{ olympiad.data.title }}</h1>
|
||||
<p class="text-base text-white/80 mb-6 max-w-xl">
|
||||
{{ olympiad.data.short_description }}
|
||||
</p>
|
||||
|
||||
<!-- Meta chips -->
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
<div v-if="olympiad.data.subject" class="flex items-center gap-1.5 bg-white/10 px-3 py-1.5 rounded-lg">
|
||||
<BookOpen class="h-4 w-4" />
|
||||
{{ olympiad.data.subject }}
|
||||
</div>
|
||||
<div v-if="olympiad.data.grade_levels" class="flex items-center gap-1.5 bg-white/10 px-3 py-1.5 rounded-lg">
|
||||
<GraduationCap class="h-4 w-4" />
|
||||
{{ olympiad.data.grade_levels }}
|
||||
</div>
|
||||
<div v-if="olympiad.data.participants_count" class="flex items-center gap-1.5 bg-white/10 px-3 py-1.5 rounded-lg">
|
||||
<Users class="h-4 w-4" />
|
||||
{{ olympiad.data.participants_count }}
|
||||
{{ olympiad.data.max_participants ? ` / ${olympiad.data.max_participants}` : '' }}
|
||||
{{ __('participants') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-5 py-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="olympiad.data.description" class="bg-surface-white border rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">{{ __('About') }}</h2>
|
||||
<div class="prose prose-sm text-ink-gray-7 leading-relaxed whitespace-pre-line">
|
||||
{{ olympiad.data.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Results / Participants -->
|
||||
<div v-if="olympiad.data.status === 'Completed' || olympiad.data.status === 'In Progress'">
|
||||
<div class="flex border-b mb-6">
|
||||
<button
|
||||
v-for="tab in detailTabs"
|
||||
:key="tab.value"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
:class="activeTab === tab.value
|
||||
? 'border-ink-gray-9 text-ink-gray-9'
|
||||
: 'border-transparent text-ink-gray-5 hover:text-ink-gray-7'"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<div v-if="activeTab === 'leaderboard'">
|
||||
<div v-if="leaderboard.loading" class="space-y-2">
|
||||
<div v-for="i in 5" :key="i" class="h-12 rounded-lg bg-surface-gray-2 animate-pulse" />
|
||||
</div>
|
||||
<div v-else-if="leaderboard.data?.length" class="space-y-2">
|
||||
<div
|
||||
v-for="result in leaderboard.data"
|
||||
:key="result.member"
|
||||
class="flex items-center gap-4 p-3 rounded-xl border transition-colors hover:bg-surface-gray-1"
|
||||
:class="result.member === currentUser ? 'bg-surface-blue-1 border-outline-blue-1' : 'border-outline-gray-2'"
|
||||
>
|
||||
<!-- Rank medal -->
|
||||
<div class="flex-shrink-0 w-8 text-center">
|
||||
<span v-if="result.rank === 1" class="text-xl">🥇</span>
|
||||
<span v-else-if="result.rank === 2" class="text-xl">🥈</span>
|
||||
<span v-else-if="result.rank === 3" class="text-xl">🥉</span>
|
||||
<span v-else class="text-sm font-bold text-ink-gray-5">#{{ result.rank }}</span>
|
||||
</div>
|
||||
<!-- Avatar -->
|
||||
<img
|
||||
v-if="result.user_image"
|
||||
:src="result.user_image"
|
||||
class="h-8 w-8 rounded-full object-cover flex-shrink-0"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-surface-gray-3 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-xs font-bold text-ink-gray-6">
|
||||
{{ result.full_name?.[0]?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Name + school -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm text-ink-gray-9 truncate">
|
||||
{{ result.full_name || result.member }}
|
||||
</div>
|
||||
<div v-if="result.school" class="text-xs text-ink-gray-5 truncate">
|
||||
{{ result.school }}
|
||||
<span v-if="result.grade"> · {{ result.grade }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Score / prize -->
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<div class="font-semibold text-sm text-ink-gray-9">
|
||||
{{ result.percentage != null ? result.percentage + '%' : '—' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="result.prize"
|
||||
class="text-xs font-medium"
|
||||
:class="prizeColor(result.prize)"
|
||||
>
|
||||
{{ __(result.prize) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Results" />
|
||||
</div>
|
||||
|
||||
<!-- My result -->
|
||||
<div v-if="activeTab === 'my_result' && olympiad.data.my_result">
|
||||
<div class="bg-surface-blue-1 border border-outline-blue-1 rounded-xl p-6 text-center">
|
||||
<div class="text-5xl font-bold text-ink-gray-9 mb-1">
|
||||
{{ olympiad.data.my_result.percentage }}%
|
||||
</div>
|
||||
<div class="text-ink-gray-6 mb-4">{{ __('Your Score') }}</div>
|
||||
<div class="flex justify-center gap-6 text-sm">
|
||||
<div>
|
||||
<div class="text-ink-gray-5">{{ __('Rank') }}</div>
|
||||
<div class="font-semibold text-ink-gray-9">#{{ olympiad.data.my_result.rank || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-5">{{ __('Status') }}</div>
|
||||
<div
|
||||
class="font-semibold"
|
||||
:class="olympiad.data.my_result.passed ? 'text-ink-green-3' : 'text-ink-red-3'"
|
||||
>
|
||||
{{ olympiad.data.my_result.passed ? __('Passed') : __('Not Passed') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="olympiad.data.my_result.prize">
|
||||
<div class="text-ink-gray-5">{{ __('Prize') }}</div>
|
||||
<div class="font-semibold" :class="prizeColor(olympiad.data.my_result.prize)">
|
||||
{{ __(olympiad.data.my_result.prize) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-5">
|
||||
<!-- Registration card -->
|
||||
<div class="bg-surface-white border rounded-xl p-5">
|
||||
<h3 class="text-base font-semibold text-ink-gray-9 mb-4">{{ __('Registration') }}</h3>
|
||||
|
||||
<!-- Already registered -->
|
||||
<div
|
||||
v-if="olympiad.data.is_registered"
|
||||
class="flex items-center gap-2 p-3 rounded-lg bg-surface-green-1 border border-outline-green-1 mb-4"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4 text-ink-green-3 flex-shrink-0" />
|
||||
<span class="text-sm text-ink-green-3 font-medium">{{ __('You are registered') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- My result quick view -->
|
||||
<div
|
||||
v-if="olympiad.data.my_result"
|
||||
class="flex items-center gap-2 p-3 rounded-lg bg-surface-blue-1 border border-outline-blue-1 mb-4"
|
||||
>
|
||||
<Trophy class="h-4 w-4 text-ink-blue-3 flex-shrink-0" />
|
||||
<span class="text-sm text-ink-blue-3 font-medium">
|
||||
{{ __('Score') }}: {{ olympiad.data.my_result.percentage }}% · #{{ olympiad.data.my_result.rank }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Registration form -->
|
||||
<div
|
||||
v-if="!olympiad.data.is_registered && canRegister && !olympiad.data.my_result"
|
||||
class="space-y-3 mb-4"
|
||||
>
|
||||
<FormControl
|
||||
v-model="regForm.grade"
|
||||
:label="__('Grade / Class')"
|
||||
type="text"
|
||||
:placeholder="__('e.g. 9A')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="regForm.school"
|
||||
:label="__('School')"
|
||||
type="text"
|
||||
:placeholder="__('School name')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="regForm.teacher_name"
|
||||
:label="__('Teacher / Supervisor')"
|
||||
type="text"
|
||||
:placeholder="__('Teacher full name')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!olympiad.data.is_registered && canRegister"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
:loading="registering"
|
||||
@click="registerForOlympiad"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserPlus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Register') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="olympiad.data.is_registered && olympiad.data.status === 'Registration Open'"
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
class="w-full"
|
||||
:loading="unregistering"
|
||||
@click="unregister"
|
||||
>
|
||||
{{ __('Cancel Registration') }}
|
||||
</Button>
|
||||
|
||||
<p
|
||||
v-if="!canRegister && !olympiad.data.is_registered"
|
||||
class="text-sm text-ink-gray-5 text-center"
|
||||
>
|
||||
{{ registrationClosedMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dates card -->
|
||||
<div class="bg-surface-white border rounded-xl p-5 space-y-3">
|
||||
<h3 class="text-base font-semibold text-ink-gray-9 mb-2">{{ __('Timeline') }}</h3>
|
||||
<div v-if="olympiad.data.registration_start" class="flex gap-3 text-sm">
|
||||
<Calendar class="h-4 w-4 text-ink-gray-5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div class="text-ink-gray-5 text-xs">{{ __('Registration') }}</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ formatDate(olympiad.data.registration_start) }}
|
||||
<span v-if="olympiad.data.registration_end">
|
||||
— {{ formatDate(olympiad.data.registration_end) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="olympiad.data.start_date" class="flex gap-3 text-sm">
|
||||
<Trophy class="h-4 w-4 text-ink-gray-5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div class="text-ink-gray-5 text-xs">{{ __('Olympiad') }}</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ formatDate(olympiad.data.start_date) }}
|
||||
<span v-if="olympiad.data.end_date">
|
||||
— {{ formatDate(olympiad.data.end_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-10 text-center text-ink-gray-5">
|
||||
{{ __('Olympiad not found.') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
FormControl,
|
||||
LoadingIndicator,
|
||||
call,
|
||||
createResource,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
GraduationCap,
|
||||
Star,
|
||||
Trophy,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import dayjs from '@/utils/dayjs'
|
||||
|
||||
const props = defineProps<{ olympiadName: string }>()
|
||||
const { user } = sessionStore()
|
||||
const currentUser = user
|
||||
|
||||
const activeTab = ref<'leaderboard' | 'my_result'>('leaderboard')
|
||||
const registering = ref(false)
|
||||
const unregistering = ref(false)
|
||||
const regForm = ref({ grade: '', school: '', teacher_name: '' })
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('DD MMM YYYY')
|
||||
|
||||
const olympiad = createResource({
|
||||
url: 'lms.lms.api.get_olympiad_detail',
|
||||
params: { name: props.olympiadName },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const leaderboard = createResource({
|
||||
url: 'lms.lms.api.get_olympiad_leaderboard',
|
||||
params: { olympiad: props.olympiadName, page_length: 50 },
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
leaderboard.reload()
|
||||
})
|
||||
|
||||
const detailTabs = computed(() => {
|
||||
const tabs = [{ label: __('Leaderboard'), value: 'leaderboard' }]
|
||||
if (olympiad.data?.my_result) {
|
||||
tabs.push({ label: __('My Result'), value: 'my_result' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
const canRegister = computed(() => {
|
||||
const status = olympiad.data?.status
|
||||
return status === 'Registration Open' || status === 'In Progress'
|
||||
})
|
||||
|
||||
const registrationClosedMessage = computed(() => {
|
||||
const status = olympiad.data?.status
|
||||
if (status === 'Completed') return __('This olympiad has ended.')
|
||||
if (status === 'Draft') return __('Registration is not open yet.')
|
||||
if (status === 'Cancelled') return __('This olympiad has been cancelled.')
|
||||
return __('Registration is closed.')
|
||||
})
|
||||
|
||||
const heroStyle = computed(() => {
|
||||
if (olympiad.data?.image) {
|
||||
return { backgroundImage: `url('${encodeURI(olympiad.data.image)}')`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
}
|
||||
const gradients: Record<string, string> = {
|
||||
'Registration Open': 'linear-gradient(135deg, #1e3a5f, #2563eb)',
|
||||
'In Progress': 'linear-gradient(135deg, #14532d, #16a34a)',
|
||||
'Completed': 'linear-gradient(135deg, #3b0764, #7c3aed)',
|
||||
'Cancelled': 'linear-gradient(135deg, #1f2937, #6b7280)',
|
||||
}
|
||||
return { background: gradients[olympiad.data?.status] || 'linear-gradient(135deg, #1e3a5f, #2563eb)' }
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'Registration Open': 'bg-white/20 text-white',
|
||||
'In Progress': 'bg-white/20 text-white',
|
||||
'Completed': 'bg-white/20 text-white',
|
||||
}
|
||||
return map[olympiad.data?.status] || 'bg-white/20 text-white'
|
||||
})
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'Registration Open': 'bg-blue-300',
|
||||
'In Progress': 'bg-green-300',
|
||||
'Completed': 'bg-gray-300',
|
||||
}
|
||||
return map[olympiad.data?.status] || 'bg-white'
|
||||
})
|
||||
|
||||
const prizeColor = (prize: string) => {
|
||||
return {
|
||||
Gold: 'text-ink-amber-3',
|
||||
Silver: 'text-ink-gray-6',
|
||||
Bronze: 'text-orange-600',
|
||||
Participant: 'text-ink-gray-5',
|
||||
}[prize] || 'text-ink-gray-5'
|
||||
}
|
||||
|
||||
const registerForOlympiad = async () => {
|
||||
registering.value = true
|
||||
try {
|
||||
await call('lms.lms.api.register_for_olympiad', {
|
||||
olympiad: props.olympiadName,
|
||||
grade: regForm.value.grade,
|
||||
school: regForm.value.school,
|
||||
teacher_name: regForm.value.teacher_name,
|
||||
})
|
||||
toast.success(__('Successfully registered!'))
|
||||
olympiad.reload()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.messages?.[0] || __('Registration failed.'))
|
||||
} finally {
|
||||
registering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const unregister = async () => {
|
||||
unregistering.value = true
|
||||
try {
|
||||
await call('lms.lms.api.unregister_from_olympiad', { olympiad: props.olympiadName })
|
||||
toast.success(__('Registration cancelled.'))
|
||||
olympiad.reload()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.messages?.[0] || __('Error cancelling registration.'))
|
||||
} finally {
|
||||
unregistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: __('Olympiads'), route: { name: 'Olympiads' } },
|
||||
{ label: olympiad.data?.title || props.olympiadName, route: { name: 'OlympiadDetail' } },
|
||||
])
|
||||
|
||||
usePageMeta(() => ({
|
||||
title: olympiad.data?.title || __('Olympiad'),
|
||||
}))
|
||||
</script>
|
||||
345
frontend/src/pages/Olympiads/OlympiadForm.vue
Normal file
345
frontend/src/pages/Olympiads/OlympiadForm.vue
Normal 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>
|
||||
155
frontend/src/pages/Olympiads/Olympiads.vue
Normal file
155
frontend/src/pages/Olympiads/Olympiads.vue
Normal 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>
|
||||
@@ -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,16 +738,10 @@ 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')) {
|
||||
console.log('[DEBUG] Профиль школьника не найден, создаем новый');
|
||||
schoolProfileNotFound.value = true;
|
||||
} else {
|
||||
console.error('[DEBUG] Ошибка загрузки профиля школьника:', error);
|
||||
window.frappe?.msgprint({
|
||||
title: 'Ошибка',
|
||||
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
|
||||
@@ -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 = {
|
||||
const pageMeta = computed(() => ({
|
||||
title: profile.data?.full_name || 'Профиль',
|
||||
description: profile.data?.headline || '',
|
||||
};
|
||||
console.log('[DEBUG] Мета-данные страницы:', meta);
|
||||
return meta;
|
||||
});
|
||||
}));
|
||||
|
||||
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',
|
||||
@@ -985,9 +912,7 @@ async function saveProfile() {
|
||||
editMode.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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
187
lms/lms/api.py
187
lms/lms/api.py
@@ -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
|
||||
|
||||
0
lms/lms/doctype/lms_olympiad/__init__.py
Normal file
0
lms/lms/doctype/lms_olympiad/__init__.py
Normal file
218
lms/lms/doctype/lms_olympiad/lms_olympiad.json
Normal file
218
lms/lms/doctype/lms_olympiad/lms_olympiad.json
Normal 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
|
||||
}
|
||||
45
lms/lms/doctype/lms_olympiad/lms_olympiad.py
Normal file
45
lms/lms/doctype/lms_olympiad/lms_olympiad.py
Normal 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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
0
lms/lms/doctype/lms_olympiad_result/__init__.py
Normal file
0
lms/lms/doctype/lms_olympiad_result/__init__.py
Normal file
127
lms/lms/doctype/lms_olympiad_result/lms_olympiad_result.json
Normal file
127
lms/lms/doctype/lms_olympiad_result/lms_olympiad_result.json
Normal 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
|
||||
}
|
||||
32
lms/lms/doctype/lms_olympiad_result/lms_olympiad_result.py
Normal file
32
lms/lms/doctype/lms_olympiad_result/lms_olympiad_result.py
Normal 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"
|
||||
@@ -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 "Ошибка сохранения олимпиады: "
|
||||
|
||||
Reference in New Issue
Block a user