feat: rating component
This commit is contained in:
@@ -2,16 +2,28 @@
|
|||||||
<div class="shadow rounded-md" style="width: 300px;">
|
<div class="shadow rounded-md" style="width: 300px;">
|
||||||
<iframe v-if="course.data.video_link" :src="video_link" class="rounded-t-md" />
|
<iframe v-if="course.data.video_link" :src="video_link" class="rounded-t-md" />
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<Button v-if="course.data.membership" variant="solid" class="w-full mb-3">
|
<router-link v-if="course.data.membership && course.data.current_lesson"
|
||||||
<span>
|
:to="{name: 'Lesson', params: {
|
||||||
{{ __("Continue Learning") }}
|
courseName: course.name,
|
||||||
</span>
|
chapterNumber: course.data.current_lesson.split('.')[0],
|
||||||
</Button>
|
lessonNumber: course.data.current_lesson.split('.')[1]
|
||||||
<Button v-else variant="solid" class="w-full mb-3" >
|
}}">
|
||||||
|
<Button variant="solid" class="w-full mb-3">
|
||||||
|
<span>
|
||||||
|
{{ __("Continue Learning") }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button v-else @click="enrollStudent()" variant="solid" class="w-full mb-3">
|
||||||
<span>
|
<span>
|
||||||
{{ __("Start Learning") }}
|
{{ __("Start Learning") }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button v-if="user?.data?.is_moderator" variant="subtle" class="w-full mb-3">
|
||||||
|
<span>
|
||||||
|
{{ __("Edit") }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<Users class="h-4 w-4 text-gray-700"/>
|
<Users class="h-4 w-4 text-gray-700"/>
|
||||||
<span class="ml-1">
|
<span class="ml-1">
|
||||||
@@ -35,8 +47,14 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
import { computed } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { Button } from "frappe-ui"
|
import { Button, createResource } from "frappe-ui"
|
||||||
|
import { createToast } from "@/utils/"
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const user = inject("$user");
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
course: {
|
course: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -50,4 +68,34 @@ const video_link = computed(() => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function enrollStudent() {
|
||||||
|
if (!user.data) {
|
||||||
|
createToast({
|
||||||
|
title: "Please Login",
|
||||||
|
icon: 'alert-circle',
|
||||||
|
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
const enrollStudentResource = createResource({
|
||||||
|
url: "lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership"
|
||||||
|
})
|
||||||
|
console.log(props.course)
|
||||||
|
enrollStudentResource.submit({
|
||||||
|
course: props.course.data.name
|
||||||
|
}).then(() => {
|
||||||
|
createToast({
|
||||||
|
title: "Enrolled Successfully",
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'text-green-600 bg-green-100',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({ name: 'Lesson', params: { courseName: props.course.data.name, chapterNumber: 1, lessonNumber: 1 } })
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-base">
|
<div class="text-base">
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<Disclosure v-slot="{ open }" v-for="(chapter, index) in outline.data" :key="chapter.name">
|
<Disclosure v-slot="{ open }" v-for="(chapter, index) in outline.data" :key="chapter.name" :defaultOpen="index == 0">
|
||||||
<DisclosureButton class="flex w-full px-2 pt-2 pb-3">
|
<DisclosureButton class="flex w-full px-2 pt-2 pb-3">
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
:class="{'rotate-90 transform duration-200' : open, 'duration-200' : !open, 'open': index == 1}"
|
:class="{'rotate-90 transform duration-200' : open, 'duration-200' : !open, 'open': index == 1}"
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</div>
|
</div>
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel class="px-10 pb-4" :static="index == 0">
|
<DisclosurePanel class="px-10 pb-4">
|
||||||
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
||||||
<div class="flex items-center text-base mb-3">
|
<div class="flex items-center text-base mb-3">
|
||||||
<MonitorPlay v-if="lesson.icon === 'icon-youtube'" class="h-4 w-4 text-gray-900 stroke-1 mr-2"/>
|
<MonitorPlay v-if="lesson.icon === 'icon-youtube'" class="h-4 w-4 text-gray-900 stroke-1 mr-2"/>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{{ reviews.data.length }} {{ __("reviews") }}
|
{{ reviews.data.length }} {{ __("reviews") }}
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button @click="openReviewModal()">
|
||||||
<span>
|
<span>
|
||||||
{{ __("Write a review") }}
|
{{ __("Write a review") }}
|
||||||
</span>
|
</span>
|
||||||
@@ -53,11 +53,8 @@
|
|||||||
<Star v-for="index in 5" class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2" :class="(index <= Math.ceil(review.rating)) ? 'fill-orange-500' : 'fill-gray-600'"/>
|
<Star v-for="index in 5" class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2" :class="(index <= Math.ceil(review.rating)) ? 'fill-orange-500' : 'fill-gray-600'"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4 leading-5">
|
||||||
{{ review.review }}
|
{{ review.review }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,12 +62,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ showReviewModal }}
|
||||||
|
<ReviewModal v-model="showReviewModal"/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { createResource, Button } from "frappe-ui";
|
import { createResource, Button } from "frappe-ui";
|
||||||
import { computed } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import UserAvatar from '@/components/UserAvatar.vue';
|
import UserAvatar from '@/components/UserAvatar.vue';
|
||||||
|
import ReviewModal from '@/components/ReviewModal.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -111,4 +111,10 @@ const rating_percent = computed(() => {
|
|||||||
});
|
});
|
||||||
return rating_percent;
|
return rating_percent;
|
||||||
});
|
});
|
||||||
|
const showReviewModal = ref(false)
|
||||||
|
|
||||||
|
function openReviewModal() {
|
||||||
|
console.log("called")
|
||||||
|
showReviewModal.value = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
34
frontend/src/components/Rating.vue
Normal file
34
frontend/src/components/Rating.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex text-center">
|
||||||
|
<div v-for="index in 5">
|
||||||
|
<Star class="h-5 w-5 fill-gray-400 text-gray-200 mr-1 cursor-pointer hover:fill-orange-500" @input="handleChange"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useAttrs } from 'vue'
|
||||||
|
import { Star } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
let emitChange = (value: string) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
let handleChange = (e: Event) => {
|
||||||
|
emitChange((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
26
frontend/src/components/ReviewModal.vue
Normal file
26
frontend/src/components/ReviewModal.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options='{
|
||||||
|
title: __("Write a Review"),
|
||||||
|
size: "xl",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "Submit",
|
||||||
|
variant: "solid",
|
||||||
|
onClick: ({ close }) => submitReview(close)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'>
|
||||||
|
<template #body-content>
|
||||||
|
<Textarea type="text" size="md" rows="5" />
|
||||||
|
<Rating />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Textarea } from 'frappe-ui'
|
||||||
|
import { defineModel } from "vue"
|
||||||
|
import Rating from '@/components/Rating.vue';
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
console.log(show)
|
||||||
|
</script>
|
||||||
@@ -1,58 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="courses.data" class="h-screen">
|
<div class="h-screen">
|
||||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5">
|
<div v-if="courses.data">
|
||||||
<Breadcrumbs class="h-7" :items="[{ label: __('All Courses'), route: { name: 'Courses' } }]" />
|
<header class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5">
|
||||||
<div class="flex">
|
<Breadcrumbs class="h-7" :items="[{ label: __('All Courses'), route: { name: 'Courses' } }]" />
|
||||||
<Button variant="solid">
|
<div class="flex">
|
||||||
<template #prefix>
|
<Button variant="solid">
|
||||||
<Plus class="h-4 w-4" />
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __("New Course") }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="mx-5 py-5">
|
||||||
|
<Tabs class="overflow-hidden" v-model="tabIndex" :tabs="tabs">
|
||||||
|
<template #tab="{ tab, selected }">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="group -mb-px flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
|
||||||
|
:class="{ 'text-gray-900': selected }">
|
||||||
|
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
|
||||||
|
{{ __(tab.label) }}
|
||||||
|
<Badge :class="{ 'text-gray-900 border border-gray-900': selected }" variant="subtle" theme="gray"
|
||||||
|
size="sm">
|
||||||
|
{{ tab.count }}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{{ __("New Course") }}
|
<template #default="{ tab }">
|
||||||
</Button>
|
<div v-if="tab.courses && tab.courses.value.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
||||||
</div>
|
<router-link v-for="course in tab.courses.value"
|
||||||
</header>
|
:to="course.membership && course.current_lesson
|
||||||
<div class="mx-5 py-5">
|
? {
|
||||||
<Tabs class="overflow-hidden" v-model="tabIndex" :tabs="tabs">
|
name: 'Lesson', params: {
|
||||||
<template #tab="{ tab, selected }">
|
courseName: course.name,
|
||||||
<div>
|
chapterNumber: course.current_lesson.split('.')[0],
|
||||||
<button
|
lessonNumber: course.current_lesson.split('.')[1]
|
||||||
class="group -mb-px flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
|
}
|
||||||
:class="{ 'text-gray-900': selected }">
|
}
|
||||||
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
|
: course.membership ? {
|
||||||
{{ __(tab.label) }}
|
name: 'Lesson', params: {
|
||||||
<Badge :class="{ 'text-gray-900 border border-gray-900': selected }" variant="subtle" theme="gray"
|
courseName: course.name,
|
||||||
size="sm">
|
chapterNumber: 1,
|
||||||
{{ tab.count }}
|
lessonNumber: 1
|
||||||
</Badge>
|
}
|
||||||
</button>
|
} : { name: 'CourseDetail', params: { courseName: course.name } }">
|
||||||
</div>
|
<CourseCard :course="course" />
|
||||||
</template>
|
</router-link>
|
||||||
<template #default="{ tab }">
|
</div>
|
||||||
<div v-if="tab.courses && tab.courses.value.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
<div v-else class="grid flex-1 place-items-center text-xl font-medium text-gray-500">
|
||||||
<router-link v-for="course in tab.courses.value"
|
<div class="flex flex-col items-center justify-center mt-4">
|
||||||
:to="
|
<div>
|
||||||
course.membership && course.current_lesson
|
{{ __("No {0} courses found").format(tab.label.toLowerCase()) }}
|
||||||
? {name: 'Lesson', params: {
|
</div>
|
||||||
courseName: course.name,
|
|
||||||
chapterNumber: course.current_lesson.split('.')[0],
|
|
||||||
lessonNumber: course.current_lesson.split('.')[1] }}
|
|
||||||
: course.membership ? { name: 'Lesson', params: {
|
|
||||||
courseName: course.name,
|
|
||||||
chapterNumber: 1,
|
|
||||||
lessonNumber: 1 }
|
|
||||||
} : { name: 'CourseDetail', params: { courseName: course.name } }">
|
|
||||||
<CourseCard :course="course" />
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div v-else class="grid flex-1 place-items-center text-xl font-medium text-gray-500">
|
|
||||||
<div class="flex flex-col items-center justify-center mt-4">
|
|
||||||
<div>
|
|
||||||
{{ __("No {0} courses found").format(tab.label.toLowerCase()) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</Tabs>
|
||||||
</Tabs>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
35
frontend/src/utils/dialogs.js
Normal file
35
frontend/src/utils/dialogs.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Dialog, ErrorMessage } from 'frappe-ui'
|
||||||
|
import { h, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
let dialogs = ref([])
|
||||||
|
|
||||||
|
export let Dialogs = {
|
||||||
|
name: 'Dialogs',
|
||||||
|
render() {
|
||||||
|
return dialogs.value.map((dialog) => {
|
||||||
|
return h(
|
||||||
|
Dialog,
|
||||||
|
{
|
||||||
|
options: dialog,
|
||||||
|
modelValue: dialog.show,
|
||||||
|
'onUpdate:modelValue': (val) => (dialog.show = val),
|
||||||
|
},
|
||||||
|
() => [
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'text-p-base text-gray-700' },
|
||||||
|
dialog.message
|
||||||
|
),
|
||||||
|
h(ErrorMessage, { class: 'mt-2', message: dialog.error }),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDialog(options) {
|
||||||
|
let dialog = reactive(options)
|
||||||
|
dialog.key = `dialog-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
dialogs.value.push(dialog)
|
||||||
|
dialog.show = true
|
||||||
|
}
|
||||||
8
frontend/src/utils/index.js
Normal file
8
frontend/src/utils/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { toast } from 'frappe-ui'
|
||||||
|
|
||||||
|
export function createToast(options) {
|
||||||
|
toast({
|
||||||
|
position: 'bottom-right',
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,15 @@ import frappeui from 'frappe-ui/vite'
|
|||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [frappeui(), vue()],
|
plugins: [
|
||||||
|
frappeui(),
|
||||||
|
vue({
|
||||||
|
script: {
|
||||||
|
defineModel: true,
|
||||||
|
propsDestructure: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|||||||
@@ -1169,7 +1169,7 @@ def get_courses():
|
|||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_course_details(course):
|
def get_course_details(course):
|
||||||
course = frappe.db.get_value(
|
course_details = frappe.db.get_value(
|
||||||
"LMS Course",
|
"LMS Course",
|
||||||
course,
|
course,
|
||||||
[
|
[
|
||||||
@@ -1185,38 +1185,46 @@ def get_course_details(course):
|
|||||||
],
|
],
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
course.tags = get_tags(course.name)
|
course_details.tags = get_tags(course_details.name)
|
||||||
course.lesson_count = get_lesson_count(course.name)
|
course_details.lesson_count = get_lesson_count(course_details.name)
|
||||||
|
|
||||||
course.enrollment_count = frappe.db.count(
|
course_details.enrollment_count = frappe.db.count(
|
||||||
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
|
"LMS Enrollment", {"course": course_details.name, "member_type": "Student"}
|
||||||
|
)
|
||||||
|
course_details.enrollment_count_formatted = format_number(
|
||||||
|
course_details.enrollment_count
|
||||||
)
|
)
|
||||||
course.enrollment_count_formatted = format_number(course.enrollment_count)
|
|
||||||
|
|
||||||
avg_rating = get_average_rating(course.name) or 0
|
avg_rating = get_average_rating(course_details.name) or 0
|
||||||
course.avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
|
course_details.avg_rating = flt(
|
||||||
|
avg_rating, frappe.get_system_settings("float_precision") or 3
|
||||||
|
)
|
||||||
|
|
||||||
course.instructors = get_instructors(course.name)
|
course_details.instructors = get_instructors(course_details.name)
|
||||||
if course.paid_course:
|
if course_details.paid_course:
|
||||||
course.price = fmt_money(course.course_price, 0, course.currency)
|
course_details.price = fmt_money(
|
||||||
|
course_details.course_price, 0, course_details.currency
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
course.price = _("Free")
|
course_details.price = _("Free")
|
||||||
|
|
||||||
if frappe.session.user == "Guest":
|
if frappe.session.user == "Guest":
|
||||||
course.membership = None
|
course_details.membership = None
|
||||||
course.is_instructor = False
|
course_details.is_instructor = False
|
||||||
else:
|
else:
|
||||||
course.membership = frappe.db.get_value(
|
course_details.membership = frappe.db.get_value(
|
||||||
"LMS Enrollment",
|
"LMS Enrollment",
|
||||||
{"member": frappe.session.user, "course": course.name},
|
{"member": frappe.session.user, "course": course_details.name},
|
||||||
["name", "course", "current_lesson", "progress"],
|
["name", "course", "current_lesson", "progress"],
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
course.is_instructor = is_instructor(course.name)
|
course_details.is_instructor = is_instructor(course_details.name)
|
||||||
|
|
||||||
if course.membership and course.membership.current_lesson:
|
if course_details.membership and course_details.membership.current_lesson:
|
||||||
course.current_lesson = get_lesson_index(course.membership.current_lesson)
|
course_details.current_lesson = get_lesson_index(
|
||||||
return course
|
course_details.membership.current_lesson
|
||||||
|
)
|
||||||
|
return course_details
|
||||||
|
|
||||||
|
|
||||||
def get_categorized_courses(courses):
|
def get_categorized_courses(courses):
|
||||||
|
|||||||
Reference in New Issue
Block a user