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>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="course.title"
|
v-if="course.title"
|
||||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
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: 350px"
|
style="min-height: 340px"
|
||||||
>
|
>
|
||||||
|
<!-- Cover image / gradient -->
|
||||||
<div
|
<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="
|
:style="
|
||||||
course.image
|
course.image
|
||||||
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||||
: {
|
: { backgroundImage: getGradientColor() }
|
||||||
backgroundImage: getGradientColor(),
|
|
||||||
backgroundBlendMode: 'screen',
|
|
||||||
}
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
<!-- Top badges row -->
|
||||||
<div
|
<div class="absolute top-2.5 left-2.5 flex flex-wrap gap-1">
|
||||||
v-if="course.featured"
|
<span
|
||||||
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"
|
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" />
|
<Clock class="h-2.5 w-2.5" />
|
||||||
<span>
|
{{ __('Upcoming') }}
|
||||||
{{ __('Featured') }}
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="course.tags"
|
<div class="absolute top-2.5 right-2.5 flex items-center gap-1">
|
||||||
v-for="tag in course.tags?.split(', ')"
|
<Tooltip v-if="course.featured" :text="__('Featured')">
|
||||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
<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" />
|
||||||
{{ tag }}
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
|
||||||
|
<!-- Title fallback when no image -->
|
||||||
<div
|
<div
|
||||||
v-if="!course.image"
|
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="flex items-end h-full px-4 pb-3"
|
||||||
:class="
|
>
|
||||||
course.title.length > 32
|
<h3
|
||||||
? 'text-lg'
|
class="text-white font-bold leading-tight drop-shadow"
|
||||||
: course.title.length > 20
|
:class="course.title.length > 40 ? 'text-base' : course.title.length > 24 ? 'text-lg' : 'text-xl'"
|
||||||
? 'text-xl'
|
|
||||||
: 'text-2xl'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</h3>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col flex-auto p-4 border-x-2 border-b-2 rounded-b-md">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div v-if="course.lessons">
|
|
||||||
<Tooltip :text="__('Lessons')">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
|
||||||
{{ course.lessons }}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="course.enrollments">
|
<!-- Progress overlay -->
|
||||||
<Tooltip :text="__('Enrolled Students')">
|
<div
|
||||||
<span class="flex items-center">
|
v-if="user && course.membership"
|
||||||
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
class="absolute bottom-0 left-0 right-0 h-1 bg-black/20"
|
||||||
{{ formatAmount(course.enrollments) }}
|
>
|
||||||
</span>
|
<div
|
||||||
</Tooltip>
|
class="h-full bg-ink-green-3 transition-all duration-500"
|
||||||
</div>
|
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
|
||||||
|
/>
|
||||||
<div v-if="course.rating">
|
</div>
|
||||||
<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>
|
|
||||||
</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
|
<div
|
||||||
v-if="course.image"
|
v-if="course.image"
|
||||||
class="font-semibold leading-6"
|
class="font-semibold text-ink-gray-9 leading-snug mb-1 text-sm"
|
||||||
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.5rem"
|
||||||
>
|
>
|
||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</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 }}
|
{{ 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>
|
</div>
|
||||||
|
|
||||||
<ProgressBar
|
<!-- Footer -->
|
||||||
v-if="user && course.membership"
|
<div class="flex items-center justify-between py-3 border-t border-outline-gray-1">
|
||||||
:progress="course.membership.progress"
|
<!-- Instructors -->
|
||||||
/>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="flex -space-x-1.5">
|
||||||
<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 }"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-for="instructor in course.instructors"
|
v-for="instructor in course.instructors.slice(0, 3)"
|
||||||
|
:key="instructor.name"
|
||||||
:user="instructor"
|
:user="instructor"
|
||||||
|
class="ring-1 ring-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseInstructors :instructors="course.instructors" />
|
<CourseInstructors
|
||||||
|
v-if="course.instructors.length === 1"
|
||||||
|
:instructors="course.instructors"
|
||||||
|
class="text-xs text-ink-gray-5"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<!-- Right side: progress % / price / cert -->
|
||||||
<div v-if="course.paid_course" class="font-semibold">
|
<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 }}
|
{{ course.price }}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.paid_certificate || course.enable_certification"
|
v-if="course.paid_certificate || course.enable_certification"
|
||||||
:text="__('Get Certified')"
|
: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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { sessionStore } from '@/stores/session'
|
||||||
import { Tooltip } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { formatAmount } from '@/utils'
|
import { formatAmount } from '@/utils'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
|
||||||
import colors from '@/utils/frappe-ui-colors.json'
|
import colors from '@/utils/frappe-ui-colors.json'
|
||||||
|
import dayjs from '@/utils/dayjs'
|
||||||
|
|
||||||
const { user } = sessionStore()
|
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 = () => {
|
const getGradientColor = () => {
|
||||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||||
let colorMap = colors[theme][color]
|
let colorMap = colors[theme][color]
|
||||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
return `linear-gradient(to bottom right, #111827, ${colorMap[500]})`
|
||||||
}
|
}
|
||||||
</script>
|
</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 {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-group .avatar {
|
.avatar-group .avatar {
|
||||||
transition: margin 0.1s ease-in-out;
|
transition: margin 0.1s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-group.overlap .avatar + .avatar {
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
margin-left: calc(-8px);
|
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>
|
</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>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</header>
|
</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">
|
<div class="p-5 pb-10">
|
||||||
<FormControl
|
<!-- 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"
|
v-model="title"
|
||||||
:placeholder="__('Search')"
|
|
||||||
type="text"
|
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()"
|
@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
|
<Select
|
||||||
v-if="categories.length"
|
v-if="categories.length"
|
||||||
v-model="currentCategory"
|
v-model="currentCategory"
|
||||||
@@ -75,22 +73,25 @@
|
|||||||
@update:modelValue="updateCourses()"
|
@update:modelValue="updateCourses()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="certification"
|
v-model="certification"
|
||||||
:label="__('Certification')"
|
:label="__('Certified')"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@change="updateCourses()"
|
@change="updateCourses()"
|
||||||
/>
|
/>
|
||||||
|
<span class="text-sm text-ink-gray-5 whitespace-nowrap">
|
||||||
|
{{ courses.data?.length || 0 }} {{ __('courses') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="courses.data?.length"
|
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
|
<router-link
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
|
:key="course.name"
|
||||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||||
>
|
>
|
||||||
<CourseCard :course="course" />
|
<CourseCard :course="course" />
|
||||||
@@ -99,9 +100,9 @@
|
|||||||
<EmptyState v-else-if="!courses.list.loading" type="Courses" />
|
<EmptyState v-else-if="!courses.list.loading" type="Courses" />
|
||||||
<div
|
<div
|
||||||
v-if="!courses.list.loading && courses.hasNextPage"
|
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') }}
|
{{ __('Load More') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +126,7 @@ import {
|
|||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
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 { sessionStore } from '@/stores/session'
|
||||||
import { canCreateCourse } from '@/utils'
|
import { canCreateCourse } from '@/utils'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
|||||||
@@ -1,266 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- Fullscreen stream: fills all available space, no scroll -->
|
||||||
<div v-if="createdCourses.data?.length" class="mt-10">
|
<div class="w-full h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<!-- Stream fills remaining height -->
|
||||||
<span class="font-semibold text-lg text-ink-gray-9">
|
<div class="flex-1 min-h-0 relative bg-black">
|
||||||
{{ __('Courses Created') }}
|
<StreamEmbed
|
||||||
</span>
|
:platform="streamConfig.platform"
|
||||||
<router-link
|
:channel="streamConfig.channel"
|
||||||
:to="{
|
:is-live="streamConfig.isLive"
|
||||||
name: 'Courses',
|
class="h-full"
|
||||||
}"
|
/>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
import { ref } from 'vue'
|
||||||
import { inject } from 'vue'
|
import StreamEmbed from '@/components/StreamEmbed.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()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
liveClasses?: { data?: any[] }
|
liveClasses?: { data?: any[] }
|
||||||
evals?: { data?: any[] }
|
evals?: { data?: any[] }
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const createdCourses = createResource({
|
const streamConfig = ref({
|
||||||
url: 'lms.lms.api.get_created_courses',
|
platform: 'youtube' as 'twitch' | 'youtube',
|
||||||
auto: true,
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-5 pt-5 pb-10">
|
<!-- Admin view: fullscreen stream, no header, no scroll -->
|
||||||
<div class="space-y-2">
|
<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="flex items-center justify-between">
|
||||||
<div class="text-xl font-bold text-ink-gray-9">
|
<div class="text-xl font-bold text-ink-gray-9">
|
||||||
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
|
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div
|
<div
|
||||||
v-if="!isAdmin"
|
|
||||||
@click="showStreakModal = true"
|
@click="showStreakModal = true"
|
||||||
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
|
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -17,23 +27,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="text-base text-ink-gray-6 leading-6">
|
||||||
|
|
||||||
<div class="text-lg text-ink-gray-6 leading-6">
|
|
||||||
{{ subtitle }}
|
{{ subtitle }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminHome
|
<StudentHome :myLiveClasses="myLiveClasses" />
|
||||||
v-if="isAdmin && currentTab === 'instructor'"
|
|
||||||
:liveClasses="adminLiveClasses"
|
|
||||||
:evals="adminEvals"
|
|
||||||
/>
|
|
||||||
<StudentHome
|
|
||||||
v-else-if="currentTab === 'student'"
|
|
||||||
:myLiveClasses="myLiveClasses"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
|
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<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 ===== */
|
/* ===== PAGE ===== */
|
||||||
.student-profile-page {
|
.student-profile-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
}
|
||||||
.profile-content {
|
.profile-content {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
@@ -383,8 +382,8 @@
|
|||||||
|
|
||||||
/* ===== HERO CARD ===== */
|
/* ===== HERO CARD ===== */
|
||||||
.hero-card {
|
.hero-card {
|
||||||
background: #fff;
|
background: rgb(var(--color-surface-white));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -407,44 +406,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hero-avatar {
|
.hero-avatar {
|
||||||
width: 64px;
|
width: 96px;
|
||||||
height: 64px;
|
height: 96px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #f97316, #f59e0b);
|
background: linear-gradient(135deg, rgb(var(--color-surface-amber-2)), rgb(var(--color-surface-amber-3)));
|
||||||
color: #fff;
|
color: rgb(var(--color-ink-on-amber));
|
||||||
font-size: 26px;
|
font-size: 38px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
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 {
|
.hero-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.hero-name {
|
.hero-name {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
margin-top: 2px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.hero-bio {
|
.hero-bio {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #4b5563;
|
color: rgb(var(--color-ink-gray-7));
|
||||||
}
|
}
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ===== SECTION GRID ===== */
|
/* ===== SECTION GRID ===== */
|
||||||
.section-grid {
|
.section-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -463,8 +461,8 @@
|
|||||||
|
|
||||||
/* ===== INFO CARDS ===== */
|
/* ===== INFO CARDS ===== */
|
||||||
.info-card {
|
.info-card {
|
||||||
background: #fff;
|
background: rgb(var(--color-surface-white));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: box-shadow 0.15s ease;
|
transition: box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -476,20 +474,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
background: #fafafa;
|
background: rgb(var(--color-surface-gray-1));
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
}
|
}
|
||||||
.card-header-icon {
|
.card-header-icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
color: #f97316;
|
color: rgb(var(--color-ink-amber-3));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.card-header-title {
|
.card-header-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
}
|
}
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
@@ -498,7 +496,7 @@
|
|||||||
}
|
}
|
||||||
.text-body {
|
.text-body {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #374151;
|
color: rgb(var(--color-ink-gray-7));
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -510,7 +508,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
border-bottom: 1px solid #f9fafb;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.info-row:last-child {
|
.info-row:last-child {
|
||||||
@@ -520,7 +518,7 @@
|
|||||||
width: 130px;
|
width: 130px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
@@ -529,35 +527,29 @@
|
|||||||
}
|
}
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.text-body {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #374151;
|
|
||||||
line-height: 1.65;
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== TELEGRAM LINK ===== */
|
/* ===== TELEGRAM LINK ===== */
|
||||||
.tg-link {
|
.tg-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: #2563eb;
|
color: rgb(var(--color-ink-blue-3));
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
.tg-link:hover { color: #1d4ed8; }
|
.tg-link:hover { color: rgb(var(--color-ink-blue-4)); }
|
||||||
|
|
||||||
/* ===== BADGES ===== */
|
/* ===== BADGES ===== */
|
||||||
.badge-yes {
|
.badge-yes {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #ecfdf5;
|
background: rgb(var(--color-surface-green-1));
|
||||||
color: #059669;
|
color: rgb(var(--color-ink-green-3));
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -565,8 +557,8 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f3f4f6;
|
background: rgb(var(--color-surface-gray-2));
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -574,8 +566,8 @@
|
|||||||
/* ===== EMPTY CARD ===== */
|
/* ===== EMPTY CARD ===== */
|
||||||
.empty-card {
|
.empty-card {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
background: #fff;
|
background: rgb(var(--color-surface-white));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -589,8 +581,8 @@
|
|||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #fff7ed, #ffedd5);
|
background: rgb(var(--color-surface-amber-1));
|
||||||
color: #f97316;
|
color: rgb(var(--color-ink-amber-3));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -599,19 +591,19 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.empty-desc {
|
.empty-desc {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.empty-benefits {
|
.empty-benefits {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: #fafafa;
|
background: rgb(var(--color-surface-gray-1));
|
||||||
border: 1px solid #f3f4f6;
|
border: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -621,14 +613,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #374151;
|
color: rgb(var(--color-ink-gray-7));
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
.benefit-dot {
|
.benefit-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f97316;
|
background: rgb(var(--color-ink-amber-3));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,58 +629,58 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.edit-card {
|
.edit-card {
|
||||||
background: #fff;
|
background: rgb(var(--color-surface-white));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.edit-section-title {
|
.edit-section-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #374151;
|
color: rgb(var(--color-ink-gray-7));
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.edit-input {
|
.edit-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid rgb(var(--color-outline-gray-3));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
background: #fff;
|
background: rgb(var(--color-surface-white));
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.edit-input:focus {
|
.edit-input:focus {
|
||||||
border-color: #f97316;
|
border-color: rgb(var(--color-ink-amber-3));
|
||||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
box-shadow: 0 0 0 3px rgba(var(--color-surface-amber-2), 0.4);
|
||||||
}
|
}
|
||||||
.dropdown-list {
|
.dropdown-list {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||||
background: #fff;
|
background: rgb(var(--color-surface-white));
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #f9fafb;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.dropdown-item:last-child { border-bottom: none; }
|
.dropdown-item:last-child { border-bottom: none; }
|
||||||
.dropdown-item:hover { background: #fff7ed; }
|
.dropdown-item:hover { background: rgb(var(--color-surface-amber-1)); }
|
||||||
|
|
||||||
/* ===== SCROLLBAR ===== */
|
/* ===== SCROLLBAR ===== */
|
||||||
.dropdown-list::-webkit-scrollbar { width: 5px; }
|
.dropdown-list::-webkit-scrollbar { width: 5px; }
|
||||||
.dropdown-list::-webkit-scrollbar-track { background: #f9fafb; }
|
.dropdown-list::-webkit-scrollbar-track { background: rgb(var(--color-surface-gray-1)); }
|
||||||
.dropdown-list::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
|
.dropdown-list::-webkit-scrollbar-thumb { background: rgb(var(--color-outline-gray-3)); border-radius: 3px; }
|
||||||
.dropdown-list::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
|
.dropdown-list::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-outline-gray-4)); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -706,13 +698,6 @@ const { user } = sessionStore();
|
|||||||
const $user = inject('$user');
|
const $user = inject('$user');
|
||||||
const schoolProfileNotFound = ref(false);
|
const schoolProfileNotFound = ref(false);
|
||||||
|
|
||||||
// Логирование инициализации
|
|
||||||
console.log('[DEBUG] Инициализация компонента:', {
|
|
||||||
user: user,
|
|
||||||
$user: $user.data,
|
|
||||||
username: $user.data?.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
username: {
|
username: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -722,9 +707,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const effectiveUsername = computed(() => {
|
const effectiveUsername = computed(() => {
|
||||||
const username = props.username || $user.data?.username || '';
|
return props.username || $user.data?.username || '';
|
||||||
console.log('[DEBUG] Вычисление effectiveUsername:', { propsUsername: props.username, sessionUsername: $user.data?.username, result: username });
|
|
||||||
return username;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editMode = ref(false);
|
const editMode = ref(false);
|
||||||
@@ -734,17 +717,12 @@ const profile = createResource({
|
|||||||
url: 'frappe.client.get',
|
url: 'frappe.client.get',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
const username = effectiveUsername.value;
|
const username = effectiveUsername.value;
|
||||||
console.log('[DEBUG] Запрос profile:', { doctype: 'User', filters: { username } });
|
|
||||||
return {
|
return {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
filters: { username },
|
filters: { username },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
|
||||||
console.log('[DEBUG] Профиль загружен:', data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
onError(error) {
|
||||||
console.error('[DEBUG] Ошибка загрузки профиля:', error);
|
|
||||||
window.frappe?.msgprint({
|
window.frappe?.msgprint({
|
||||||
title: 'Ошибка',
|
title: 'Ошибка',
|
||||||
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
|
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
|
||||||
@@ -760,16 +738,10 @@ const schoolProfile = createResource({
|
|||||||
filters: { user:user },
|
filters: { user:user },
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
|
||||||
console.log('[DEBUG] Профиль школьника загружен:', data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
onError(error) {
|
||||||
// Проверяем, является ли ошибка "не найдено"
|
|
||||||
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
|
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
|
||||||
console.log('[DEBUG] Профиль школьника не найден, создаем новый');
|
|
||||||
schoolProfileNotFound.value = true;
|
schoolProfileNotFound.value = true;
|
||||||
} else {
|
} else {
|
||||||
console.error('[DEBUG] Ошибка загрузки профиля школьника:', error);
|
|
||||||
window.frappe?.msgprint({
|
window.frappe?.msgprint({
|
||||||
title: 'Ошибка',
|
title: 'Ошибка',
|
||||||
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
|
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
|
||||||
@@ -800,7 +772,7 @@ const form = ref({
|
|||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
const username = effectiveUsername.value;
|
const username = effectiveUsername.value;
|
||||||
const crumbs = [
|
return [
|
||||||
{
|
{
|
||||||
label: 'People',
|
label: 'People',
|
||||||
route: { name: 'People' },
|
route: { name: 'People' },
|
||||||
@@ -813,35 +785,20 @@ const breadcrumbs = computed(() => {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
console.log('[DEBUG] Хлебные крошки:', crumbs);
|
|
||||||
return crumbs;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => ({
|
||||||
const meta = {
|
|
||||||
title: profile.data?.full_name || 'Профиль',
|
title: profile.data?.full_name || 'Профиль',
|
||||||
description: profile.data?.headline || '',
|
description: profile.data?.headline || '',
|
||||||
};
|
}));
|
||||||
console.log('[DEBUG] Мета-данные страницы:', meta);
|
|
||||||
return meta;
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
if (!profile.data) {
|
if (!profile.data) return 'Загрузка...';
|
||||||
console.log('[DEBUG] displayName: profile.data не загружен');
|
return profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
|
||||||
return 'Загрузка...';
|
|
||||||
}
|
|
||||||
const name = profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
|
|
||||||
console.log('[DEBUG] Отображаемое имя:', name);
|
|
||||||
return name;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSessionUser = () => {
|
const isSessionUser = () => {
|
||||||
const sessionUser = $user.data?.username;
|
return $user.data?.username === effectiveUsername.value;
|
||||||
const profileUser = effectiveUsername.value;
|
|
||||||
const isSession = sessionUser === profileUser;
|
|
||||||
console.log('[DEBUG] Проверка isSessionUser:', { sessionUser, profileUser, isSession });
|
|
||||||
return isSession;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formattedDate(d) {
|
function formattedDate(d) {
|
||||||
@@ -849,7 +806,6 @@ function formattedDate(d) {
|
|||||||
try {
|
try {
|
||||||
return new Date(d).toLocaleDateString('ru-RU');
|
return new Date(d).toLocaleDateString('ru-RU');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[DEBUG] Ошибка форматирования даты:', e, { date: d });
|
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,11 +826,6 @@ function formatTelegram(t) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fillFormFromProfile() {
|
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.first_name = schoolProfile.data?.first_name || profile.data?.first_name || '';
|
||||||
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
|
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
|
||||||
form.value.middle_name = schoolProfile.data?.middle_name || '';
|
form.value.middle_name = schoolProfile.data?.middle_name || '';
|
||||||
@@ -891,38 +842,20 @@ function fillFormFromProfile() {
|
|||||||
form.value.interests = schoolProfile.data?.interests || '';
|
form.value.interests = schoolProfile.data?.interests || '';
|
||||||
form.value.about_me = schoolProfile.data?.about_me || '';
|
form.value.about_me = schoolProfile.data?.about_me || '';
|
||||||
form.value.dreams = schoolProfile.data?.dreams || '';
|
form.value.dreams = schoolProfile.data?.dreams || '';
|
||||||
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function toggleEdit() {
|
function toggleEdit() {
|
||||||
editMode.value = !editMode.value;
|
editMode.value = !editMode.value;
|
||||||
if (editMode.value) fillFormFromProfile();
|
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() {
|
async function saveProfile() {
|
||||||
console.log('[DEBUG] Сохранение профиля:', { form: form.value });
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
// Создаём копию данных формы
|
|
||||||
const formData = { ...form.value };
|
const formData = { ...form.value };
|
||||||
console.log('[DEBUG] Копия formData:', JSON.stringify(formData, null, 2));
|
|
||||||
|
|
||||||
// Обновление full_name в User, если нужно
|
|
||||||
if (formData.first_name || formData.last_name) {
|
if (formData.first_name || formData.last_name) {
|
||||||
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
|
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
|
||||||
console.log('[DEBUG] Обновление User.full_name:', { name: profile.data?.name, fullName });
|
|
||||||
await createResource({
|
await createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
params: {
|
params: {
|
||||||
@@ -934,18 +867,14 @@ async function saveProfile() {
|
|||||||
}).submit();
|
}).submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем docname
|
|
||||||
let docname = '';
|
let docname = '';
|
||||||
try {
|
try {
|
||||||
await schoolProfile.reload();
|
await schoolProfile.reload();
|
||||||
console.log('[DEBUG] Schoolprofile:', { schoolProfile });
|
|
||||||
docname = schoolProfile?.data?.name;
|
docname = schoolProfile?.data?.name;
|
||||||
console.log('[DEBUG] Выбранное имя документа:', docname);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[DEBUG] Ошибка загрузки schoolProfile, продолжаем с profile:', error.message);
|
// профиль не найден, создаём новый
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формируем payload из копии данных формы
|
|
||||||
let payload = {
|
let payload = {
|
||||||
doctype: 'Schoolchildren Profile',
|
doctype: 'Schoolchildren Profile',
|
||||||
user: profile.data?.name,
|
user: profile.data?.name,
|
||||||
@@ -967,9 +896,7 @@ async function saveProfile() {
|
|||||||
dreams: formData.dreams,
|
dreams: formData.dreams,
|
||||||
last_updated: new Date().toISOString(),
|
last_updated: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
console.log('[DEBUG] Сохранение Schoolchildren Profile (payload):', { docname, payload });
|
|
||||||
|
|
||||||
// Сохранение или создание документа
|
|
||||||
if (docname) {
|
if (docname) {
|
||||||
await createResource({
|
await createResource({
|
||||||
url: 'frappe.client.save',
|
url: 'frappe.client.save',
|
||||||
@@ -985,9 +912,7 @@ async function saveProfile() {
|
|||||||
editMode.value = false;
|
editMode.value = false;
|
||||||
schoolProfileNotFound.value = false;
|
schoolProfileNotFound.value = false;
|
||||||
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
|
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
|
||||||
console.log('[DEBUG] Профиль успешно сохранён');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[DEBUG] Ошибка при сохранении профиля:', e);
|
|
||||||
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
|
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
|
||||||
title: 'Ошибка',
|
title: 'Ошибка',
|
||||||
message: (e && e.message) || 'Ошибка при сохранении',
|
message: (e && e.message) || 'Ошибка при сохранении',
|
||||||
@@ -1008,7 +933,6 @@ async function searchSchool(q) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.log('[DEBUG] Поиск школы:', { query: q });
|
|
||||||
const res = await createResource({
|
const res = await createResource({
|
||||||
url: 'frappe.client.get_list',
|
url: 'frappe.client.get_list',
|
||||||
params: {
|
params: {
|
||||||
@@ -1019,10 +943,8 @@ async function searchSchool(q) {
|
|||||||
},
|
},
|
||||||
}).submit();
|
}).submit();
|
||||||
schoolResults.value = res || [];
|
schoolResults.value = res || [];
|
||||||
console.log('[DEBUG] Результаты поиска школы:', schoolResults.value);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
schoolResults.value = [];
|
schoolResults.value = [];
|
||||||
console.error('[DEBUG] Ошибка поиска школы:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,11 +952,8 @@ const debouncedSearchSchool = debounce(() => searchSchool(schoolQuery.value), 30
|
|||||||
|
|
||||||
function selectSchool(s) {
|
function selectSchool(s) {
|
||||||
form.value.school = s.school;
|
form.value.school = s.school;
|
||||||
//form.value.school_name = s.school_name;
|
|
||||||
schoolResults.value = [];
|
schoolResults.value = [];
|
||||||
schoolQuery.value = s.school;
|
schoolQuery.value = s.school;
|
||||||
console.log('[DEBUG] Выбрана школа:', { school: s });
|
|
||||||
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const majorQuery = ref('');
|
const majorQuery = ref('');
|
||||||
@@ -1046,7 +965,6 @@ async function searchMajor(q) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.log('[DEBUG] Поиск направления:', { query: q });
|
|
||||||
const res = await createResource({
|
const res = await createResource({
|
||||||
url: 'frappe.client.get_list',
|
url: 'frappe.client.get_list',
|
||||||
params: {
|
params: {
|
||||||
@@ -1057,10 +975,8 @@ async function searchMajor(q) {
|
|||||||
},
|
},
|
||||||
}).submit();
|
}).submit();
|
||||||
majorResults.value = res || [];
|
majorResults.value = res || [];
|
||||||
console.log('[DEBUG] Результаты поиска направления:', majorResults.value);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
majorResults.value = [];
|
majorResults.value = [];
|
||||||
console.error('[DEBUG] Ошибка поиска направления:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,40 +984,27 @@ const debouncedSearchMajor = debounce(() => searchMajor(majorQuery.value), 300);
|
|||||||
|
|
||||||
function selectMajor(m) {
|
function selectMajor(m) {
|
||||||
form.value.major = m.major_name;
|
form.value.major = m.major_name;
|
||||||
//form.value.school_name = s.school_name;
|
|
||||||
majorResults.value = [];
|
majorResults.value = [];
|
||||||
majorQuery.value = m.major_name;
|
majorQuery.value = m.major_name;
|
||||||
console.log('[DEBUG] Выбрана школа:', { major: m });
|
|
||||||
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log('[DEBUG] Компонент смонтирован:', {
|
|
||||||
propsUsername: props.username,
|
|
||||||
sessionUsername: $user.data?.username,
|
|
||||||
user: user,
|
|
||||||
$user: $user.data,
|
|
||||||
});
|
|
||||||
if ($user.data) {
|
if ($user.data) {
|
||||||
console.log('[DEBUG] Запуск profile.reload()');
|
|
||||||
profile.reload();
|
profile.reload();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.username,
|
() => props.username,
|
||||||
(newUsername, oldUsername) => {
|
() => {
|
||||||
console.log('[DEBUG] Изменение props.username:', { old: oldUsername, new: newUsername });
|
|
||||||
profile.reload();
|
profile.reload();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => profile.data,
|
() => profile.data,
|
||||||
(newData, oldData) => {
|
(newData) => {
|
||||||
console.log('[DEBUG] Изменение profile.data:', { old: oldData, new: newData });
|
|
||||||
if (newData) {
|
if (newData) {
|
||||||
console.log('[DEBUG] Запуск schoolProfile.reload()');
|
|
||||||
schoolProfile.reload();
|
schoolProfile.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1109,10 +1012,8 @@ watch(
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => schoolProfile.data,
|
() => schoolProfile.data,
|
||||||
(newData, oldData) => {
|
(newData) => {
|
||||||
console.log('[DEBUG] Изменение schoolProfile.data:', { old: oldData, new: newData });
|
|
||||||
if (newData && !editMode.value && !schoolProfileNotFound.value) {
|
if (newData && !editMode.value && !schoolProfileNotFound.value) {
|
||||||
console.log('[DEBUG] Заполнение формы из schoolProfile');
|
|
||||||
fillFormFromProfile();
|
fillFormFromProfile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1121,7 +1022,6 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => effectiveUsername.value,
|
() => effectiveUsername.value,
|
||||||
(newUsername) => {
|
(newUsername) => {
|
||||||
console.log('[DEBUG] Изменение effectiveUsername для schoolProfile:', newUsername);
|
|
||||||
schoolProfile.update({
|
schoolProfile.update({
|
||||||
params: {
|
params: {
|
||||||
doctype: 'Schoolchildren Profile',
|
doctype: 'Schoolchildren Profile',
|
||||||
|
|||||||
@@ -266,6 +266,28 @@ const routes = [
|
|||||||
name: 'Search',
|
name: 'Search',
|
||||||
component: () => import('@/pages/Search/Search.vue'),
|
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',
|
path: '/data-import',
|
||||||
name: 'DataImportList',
|
name: 'DataImportList',
|
||||||
|
|||||||
@@ -503,6 +503,12 @@ const getSidebarItems = () => {
|
|||||||
return userResource?.data
|
return userResource?.data
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Olympiads',
|
||||||
|
icon: 'Trophy',
|
||||||
|
to: 'Olympiads',
|
||||||
|
activeFor: ['Olympiads', 'OlympiadDetail'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
icon: 'Briefcase',
|
icon: 'Briefcase',
|
||||||
@@ -535,6 +541,15 @@ const getSidebarItems = () => {
|
|||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
hideLabel: true,
|
hideLabel: true,
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Create Olympiad',
|
||||||
|
icon: 'PlusCircle',
|
||||||
|
to: 'OlympiadNew',
|
||||||
|
condition: () => {
|
||||||
|
return isAdmin()
|
||||||
|
},
|
||||||
|
activeFor: ['OlympiadNew', 'OlympiadEdit'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Quizzes',
|
label: 'Quizzes',
|
||||||
icon: 'CircleHelp',
|
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"],
|
["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
|
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"
|
msgid "Установить приложение IIE"
|
||||||
msgstr "Установить приложение 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