feat: translations
This commit is contained in:
@@ -10,11 +10,12 @@
|
||||
"dependencies": {
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.16",
|
||||
"vue": "^3.2.25",
|
||||
"vue-router": "^4.0.12",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"lucide-vue-next": "^0.259.0",
|
||||
"pinia": "^2.0.33"
|
||||
"pinia": "^2.0.33",
|
||||
"qalendar": "^3.6.1",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vue": "^3.2.25",
|
||||
"vue-router": "^4.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^2.0.0",
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col border border-gray-200 h-full rounded-md shadow-sm" style="min-height: 320px;">
|
||||
<div class="course-image" :class="{'default-image': !course.image}" :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }">
|
||||
<div class="flex relative top-4 left-4">
|
||||
<div class="course-card-pills rounded-md border border-gray-200" v-for="tag in course.tags">
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!course.image" class="image-placeholder">{{ course.title[0] }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex text-base items-center justify-between mb-2">
|
||||
<div v-if="course.lesson_count" class="flex items-center space-x-1 py-1">
|
||||
<BookOpen class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.lesson_count }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count" class="flex items-center space-x-1 py-1">
|
||||
<Users class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.enrollment_count }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating" class="flex items-center space-x-1 py-1">
|
||||
<Star class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.avg_rating }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
<div class="short-introduction text-base">
|
||||
{{ course.short_introduction }}
|
||||
</div>
|
||||
<div v-if="user && course.membership" class="w-full bg-gray-200 rounded-full h-1 mb-2">
|
||||
<div class="bg-gray-900 h-1 rounded-full" :style="{ width: Math.ceil(course.membership.progress) + '%' }"></div>
|
||||
</div>
|
||||
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||
{{ Math.ceil(course.membership.progress) }}% completed
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-base mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div class="mr-1" :class="{'avatar-group overlap': course.instructors.length > 1}">
|
||||
<UserAvatar v-for="instructor in course.instructors" :user="instructor"/>
|
||||
<div class="flex flex-col border border-gray-200 h-full rounded-md shadow-sm text-base overflow-auto" style="min-height: 320px;">
|
||||
<div class="course-image" :class="{ 'default-image': !course.image }" :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }">
|
||||
<div class="flex relative top-4 left-4 w-fit">
|
||||
<div class="course-card-pills rounded-md border border-gray-200" v-for="tag in course.tags">
|
||||
{{ tag }}
|
||||
</div>
|
||||
<span v-if="course.instructors.length == 1">
|
||||
{{ course.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.instructors.length == 2">
|
||||
{{ course.instructors[0].first_name }} and {{ course.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.instructors.length > 2">
|
||||
{{ course.instructors[0].first_name }} and {{ course.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!course.image" class="image-placeholder">{{ course.title[0] }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lesson_count" class="flex items-center space-x-1 py-1">
|
||||
<BookOpen class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.lesson_count }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count" class="flex items-center space-x-1 py-1">
|
||||
<Users class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.enrollment_count }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating" class="flex items-center space-x-1 py-1">
|
||||
<Star class="h-4 w-4 text-gray-700" />
|
||||
<span> {{ course.avg_rating }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
<div class="text-base font-semibold">
|
||||
{{ course.price }}
|
||||
<div class="short-introduction">
|
||||
{{ course.short_introduction }}
|
||||
</div>
|
||||
<div v-if="user && course.membership" class="w-full bg-gray-200 rounded-full h-1 mb-2">
|
||||
<div class="bg-gray-900 h-1 rounded-full" :style="{ width: Math.ceil(course.membership.progress) + '%' }"></div>
|
||||
</div>
|
||||
<div v-if="user && course.membership" class="text-sm 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="mr-1" :class="{ 'avatar-group overlap': course.instructors.length > 1 }">
|
||||
<UserAvatar v-for="instructor in course.instructors" :user="instructor"/>
|
||||
</div>
|
||||
<span v-if="course.instructors.length == 1">
|
||||
{{ course.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.instructors.length == 2">
|
||||
{{ course.instructors[0].first_name }} and {{ course.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.instructors.length > 2">
|
||||
{{ course.instructors[0].first_name }} and {{ course.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
@@ -139,6 +139,7 @@ const props = defineProps({
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.25rem;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
frappeRequest,
|
||||
resourcesPlugin,
|
||||
} from 'frappe-ui'
|
||||
import translationPlugin from './translation'
|
||||
|
||||
// create a pinia instance
|
||||
let pinia = createPinia()
|
||||
@@ -24,6 +25,7 @@ app.use(FrappeUI)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(resourcesPlugin)
|
||||
app.use(translationPlugin)
|
||||
|
||||
app.component('Button', Button)
|
||||
app.mount('#app')
|
||||
|
||||
7
frontend/src/pages/CourseDetail.vue
Normal file
7
frontend/src/pages/CourseDetail.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
Course Detail
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -4,24 +4,31 @@
|
||||
>
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="[{ label: 'All Courses', route: { name: 'Courses' } }]"
|
||||
:items="[{ label: __('All Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
New Course
|
||||
</Button>
|
||||
<div class="flex">
|
||||
<Select class="mr-2"
|
||||
:options="orderOptions"
|
||||
v-model="orderBy"
|
||||
/>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __("New Course") }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mx-5 my-10">
|
||||
<Tabs class="overflow-hidden" v-model="tabIndex" :tabs="tabs">
|
||||
<template #tab="{ tab, selected }">
|
||||
<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 }}
|
||||
{{ __(tab.label) }}
|
||||
<Badge
|
||||
class="group-hover:bg-gray-900"
|
||||
:class="[selected ? 'bg-gray-900' : 'bg-gray-600']"
|
||||
@@ -32,28 +39,35 @@
|
||||
{{ tab.count }}
|
||||
</Badge>
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ tab }">
|
||||
<div class="grid grid-cols-3 gap-8 mt-5" v-if="tab.courses && tab.courses.value.length">
|
||||
<div v-for="course in tab.courses.value">
|
||||
<CourseCard :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ tab }">
|
||||
<div class="grid grid-cols-3 gap-8 mt-5" v-if="tab.courses && tab.courses.value.length && 0">
|
||||
<router-link v-for="course in tab.courses.value" :to="{ name: 'CourseDetail', params: { course: 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 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 {{ tab.label.toLowerCase() }} courses found</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createListResource, Breadcrumbs, Tabs, Badge } from 'frappe-ui';
|
||||
import { createListResource, Breadcrumbs, Tabs, Badge, Select } from 'frappe-ui';
|
||||
import CourseCard from '@/components/CourseCard.vue';
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
|
||||
const { isLoggedIn } = sessionStore()
|
||||
const { getUser } = usersStore()
|
||||
const user = computed(() => isLoggedIn && getUser())
|
||||
|
||||
const courses = createListResource({
|
||||
type: 'list',
|
||||
@@ -61,36 +75,75 @@ const courses = createListResource({
|
||||
doctype: 'LMS Course',
|
||||
url: "lms.lms.utils.get_courses",
|
||||
auto: true,
|
||||
})
|
||||
});
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Live',
|
||||
courses: computed(() => {
|
||||
return courses.data?.live || []
|
||||
}),
|
||||
courses: computed(() => courses.data?.live || []),
|
||||
count: computed(() => courses.data?.live?.length),
|
||||
show: true
|
||||
},
|
||||
{
|
||||
label: 'Upcoming',
|
||||
courses: computed(() => courses.data?.upcoming),
|
||||
count: computed(() => courses.data?.upcoming?.length),
|
||||
show: true
|
||||
},
|
||||
{
|
||||
label: 'Enrolled',
|
||||
courses: computed(() => courses.data?.enrolled),
|
||||
count: computed(() => courses.data?.enrolled?.length),
|
||||
show: user
|
||||
},
|
||||
{
|
||||
label: 'Created',
|
||||
courses: computed(() => courses.data?.created),
|
||||
count: computed(() => courses.data?.created?.length),
|
||||
show: computed(() => user && (user.roles.includes('Course Creator') || user.roles.includes('Moderator')))
|
||||
},
|
||||
{
|
||||
label: 'Under Review',
|
||||
courses: computed(() => courses.data?.under_review),
|
||||
count: computed(() => courses.data?.under_review?.length),
|
||||
show: computed(() => user && user.roles.includes('Moderator'))
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const orderOptions = [
|
||||
{
|
||||
label: "Sort By",
|
||||
disabled: 1
|
||||
},
|
||||
{
|
||||
label: "Most Popular",
|
||||
value: "enrollment"
|
||||
},
|
||||
{
|
||||
label: "Highest Rated",
|
||||
value: "rating"
|
||||
},
|
||||
{
|
||||
label: "Newest",
|
||||
value: "creation"
|
||||
},
|
||||
];
|
||||
const orderBy = 'enrollment';
|
||||
|
||||
function sort_courses(order) {
|
||||
const categories = ['live', 'upcoming', 'enrolled', 'created', 'under_review'];
|
||||
categories.forEach(category => {
|
||||
console.log(courses.data)
|
||||
courses.data[category] = courses.data[category].sort((a, b) => {
|
||||
if (order === 'enrollment') {
|
||||
return b.enrollment_count - a.enrollment_count;
|
||||
} else if (order === 'rating') {
|
||||
return b.avg_rating - a.avg_rating;
|
||||
} else if (order === 'newest') {
|
||||
return new Date(b.creation).getTime() - new Date(a.creation).getTime();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -13,6 +13,12 @@ const routes = [
|
||||
name: 'Courses',
|
||||
component: () => import('@/pages/Courses.vue'),
|
||||
},
|
||||
{
|
||||
path: '/courses/:course',
|
||||
name: 'CourseDetail',
|
||||
component: () => import('@/pages/CourseDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
|
||||
@@ -38,6 +38,7 @@ export const usersStore = defineStore('lms-users', () => {
|
||||
email: email,
|
||||
full_name: email.split('@')[0],
|
||||
user_image: null,
|
||||
roles: ['LMS Student'],
|
||||
}
|
||||
}
|
||||
return usersByName[email]
|
||||
|
||||
53
frontend/src/translation.js
Normal file
53
frontend/src/translation.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createResource } from 'frappe-ui'
|
||||
export default function translationPlugin(app) {
|
||||
app.config.globalProperties.__ = translate
|
||||
// fetch translations
|
||||
|
||||
if (!window.translatedMessages)
|
||||
fetchTranslations().then((translations) => {
|
||||
window.translatedMessages = translations
|
||||
})
|
||||
}
|
||||
|
||||
async function translate(message) {
|
||||
let lang = window.lang || 'hi'
|
||||
let translatedMessage = /* window.translatedMessages[message] || */ message
|
||||
const hasPlaceholders = /{\d+}/.test(message)
|
||||
|
||||
console.log(translatedMessage)
|
||||
console.log(hasPlaceholders)
|
||||
if (!hasPlaceholders) {
|
||||
return translatedMessage
|
||||
}
|
||||
return {
|
||||
format: function (...args) {
|
||||
return translatedMessage.replace(
|
||||
/{(\d+)}/g,
|
||||
function (match, number) {
|
||||
console.log(match, number)
|
||||
console.log(args[number])
|
||||
|
||||
return typeof args[number] != 'undefined'
|
||||
? args[number]
|
||||
: match
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTranslations() {
|
||||
let lang = window.lang || 'hi'
|
||||
let translations = await createResource({
|
||||
url: 'lms.lms.api.get_translations',
|
||||
cache: 'translations',
|
||||
auto: true,
|
||||
})
|
||||
let translatedMessages = {}
|
||||
console.log(translations.data)
|
||||
translations.forEach((translation) => {
|
||||
translatedMessages[translation.source_text] =
|
||||
translation.translated_text
|
||||
})
|
||||
window.translatedMessages = translatedMessages
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe.translate import get_all_translations
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -143,7 +144,6 @@ def add_mentor_to_subgroup(subgroup, email):
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_user_info(user=None):
|
||||
print(user)
|
||||
if frappe.session.user == "Guest":
|
||||
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
|
||||
filters = {}
|
||||
@@ -157,5 +157,16 @@ def get_user_info(user=None):
|
||||
order_by="full_name asc",
|
||||
distinct=True,
|
||||
).run(as_dict=1)
|
||||
print(users)
|
||||
user["roles"] = frappe.get_roles(user.name)
|
||||
return users
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_translations():
|
||||
if frappe.session.user != "Guest":
|
||||
language = frappe.db.get_value("User", frappe.session.user, "language")
|
||||
else:
|
||||
language = frappe.db.get_single_value("System Settings", "language")
|
||||
print("language", language)
|
||||
print(get_all_translations(language))
|
||||
return get_all_translations(language)
|
||||
|
||||
@@ -1166,7 +1166,6 @@ def get_courses():
|
||||
"course_price",
|
||||
"currency",
|
||||
],
|
||||
filters={"published": True},
|
||||
)
|
||||
|
||||
courses = get_course_details(courses)
|
||||
@@ -1212,16 +1211,21 @@ def get_categorized_courses(courses):
|
||||
live, upcoming, enrolled, created, under_review = [], [], [], [], []
|
||||
|
||||
for course in courses:
|
||||
if course.upcoming:
|
||||
if course.status == "Under Review":
|
||||
under_review.append(course)
|
||||
elif course.published and course.upcoming:
|
||||
upcoming.append(course)
|
||||
elif course.published:
|
||||
live.append(course)
|
||||
elif course.membership:
|
||||
|
||||
if course.membership and course.published:
|
||||
enrolled.append(course)
|
||||
elif course.is_instructor:
|
||||
created.append(course)
|
||||
elif course.status == "Under Review":
|
||||
under_review.append(course)
|
||||
|
||||
categories = [live, upcoming, enrolled, created, under_review]
|
||||
for category in categories:
|
||||
category.sort(key=lambda x: x.enrollment_count, reverse=True)
|
||||
|
||||
return {
|
||||
"live": live,
|
||||
|
||||
Reference in New Issue
Block a user