Merge pull request #836 from pateljannat/notifications
feat: notifications
This commit is contained in:
Submodule frappe-ui updated: 5c0513c2df...38728b80aa
@@ -21,7 +21,7 @@
|
|||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.54",
|
"frappe-ui": "^0.1.56",
|
||||||
"lucide-vue-next": "^0.309.0",
|
"lucide-vue-next": "^0.309.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||||
<div class="flex flex-col overflow-y-auto">
|
<div class="flex flex-col overflow-y-auto">
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
v-for="link in links"
|
v-for="link in sidebarLinks"
|
||||||
:link="link"
|
:link="link"
|
||||||
:isCollapsed="isSidebarCollapsed"
|
:isCollapsed="isSidebarCollapsed"
|
||||||
class="mx-2 my-0.5"
|
class="mx-2 my-0.5"
|
||||||
@@ -42,10 +42,53 @@ import UserDropdown from '@/components/UserDropdown.vue'
|
|||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, inject, computed } from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { Bell } from 'lucide-vue-next'
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
|
||||||
const links = getSidebarLinks()
|
const { user } = sessionStore()
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
|
unreadNotifications.reload()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const unreadNotifications = createResource({
|
||||||
|
cache: 'Unread Notifications Count',
|
||||||
|
url: 'frappe.client.get_count',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
filters: {
|
||||||
|
for_user: user,
|
||||||
|
read: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
unreadCount.value = data
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sidebarLinks = computed(() => {
|
||||||
|
const links = getSidebarLinks()
|
||||||
|
if (user) {
|
||||||
|
links.push({
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: Bell,
|
||||||
|
to: 'Notifications',
|
||||||
|
activeFor: ['Notifications'],
|
||||||
|
count: unreadCount.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
})
|
||||||
|
|
||||||
const getSidebarFromStorage = () => {
|
const getSidebarFromStorage = () => {
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
return useStorage('sidebar_is_collapsed', false)
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ function enrollStudent() {
|
|||||||
const enrollStudentResource = createResource({
|
const enrollStudentResource = createResource({
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||||
})
|
})
|
||||||
console.log(props.course)
|
|
||||||
enrollStudentResource
|
enrollStudentResource
|
||||||
.submit({
|
.submit({
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<div
|
<div
|
||||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||||
>
|
>
|
||||||
<slot name="sidebar" />
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-auto" id="scrollContainer">
|
<div class="w-full overflow-auto" id="scrollContainer">
|
||||||
|
|||||||
@@ -69,9 +69,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
:content="newReply"
|
:content="newReply"
|
||||||
|
:mentions="mentionUsers"
|
||||||
@change="(val) => (newReply = val)"
|
@change="(val) => (newReply = val)"
|
||||||
placeholder="Type your reply here..."
|
placeholder="Type your reply here..."
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
@@ -92,13 +94,14 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, computed } from 'vue'
|
||||||
import { createToast } from '../utils'
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
topic: {
|
||||||
@@ -147,6 +150,16 @@ const newReplyResource = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mentionUsers = computed(() => {
|
||||||
|
let users = Object.values(allUsers.data).map((user) => {
|
||||||
|
return {
|
||||||
|
value: user.name,
|
||||||
|
label: user.full_name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
})
|
||||||
|
|
||||||
const postReply = () => {
|
const postReply = () => {
|
||||||
newReplyResource.submit(
|
newReplyResource.submit(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -42,14 +42,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex items-center justify-center border mt-5 p-5 rounded-md"
|
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||||
>
|
>
|
||||||
<MessageSquareIcon class="w-5 h-5 stroke-1.5 mr-2" />
|
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
|
||||||
<div>
|
<div class="">
|
||||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="text-gray-600">
|
||||||
{{ __(emptyStateText) }}
|
{{ __(emptyStateText) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +63,14 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button, TextEditor } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareIcon } from 'lucide-vue-next'
|
import { MessageSquareText } from 'lucide-vue-next'
|
||||||
|
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||||
|
|
||||||
const showTopics = ref(true)
|
const showTopics = ref(true)
|
||||||
const currentTopic = ref(null)
|
const currentTopic = ref(null)
|
||||||
@@ -96,12 +97,16 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Be the first to start a discussion',
|
default: 'Start a discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
scrollToBottom: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -110,8 +115,19 @@ onMounted(() => {
|
|||||||
socket.on('new_discussion_topic', (data) => {
|
socket.on('new_discussion_topic', (data) => {
|
||||||
topics.refresh()
|
topics.refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (props.scrollToBottom) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToEnd()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scrollToEnd = () => {
|
||||||
|
let scrollContainer = getScrollContainer()
|
||||||
|
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
const topics = createResource({
|
const topics = createResource({
|
||||||
url: 'lms.lms.utils.get_discussion_topics',
|
url: 'lms.lms.utils.get_discussion_topics',
|
||||||
cache: ['topics', props.doctype, props.docname],
|
cache: ['topics', props.doctype, props.docname],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { sessionStore } from '@/stores/session'
|
|||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { LogOut, LogIn, UserRound } from 'lucide-vue-next'
|
import { LogOut, LogIn, UserRound } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, username } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center duration-300 ease-in-out"
|
class="flex items-center w-full duration-300 ease-in-out"
|
||||||
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||||
>
|
>
|
||||||
<Tooltip :text="link.label" placement="right">
|
<Tooltip :text="link.label" placement="right">
|
||||||
@@ -29,6 +29,9 @@
|
|||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||||
|
{{ link.count }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ const props = defineProps({
|
|||||||
default: 'Tags',
|
default: 'Tags',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(props.modelValue)
|
|
||||||
let tags = ref(props.modelValue)
|
let tags = ref(props.modelValue)
|
||||||
console.log(tags.value)
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
let newTag = ref('')
|
let newTag = ref('')
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ app.provide('$dayjs', dayjs)
|
|||||||
app.provide('$socket', initSocket())
|
app.provide('$socket', initSocket())
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
const { userResource } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
|
app.provide('$allUsers', allUsers)
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
|
|||||||
@@ -66,9 +66,10 @@
|
|||||||
<Discussions
|
<Discussions
|
||||||
doctype="LMS Batch"
|
doctype="LMS Batch"
|
||||||
:docname="batch.data.name"
|
:docname="batch.data.name"
|
||||||
title="Discussions"
|
:title="__('Discussions')"
|
||||||
:key="batch.data.name"
|
:key="batch.data.name"
|
||||||
:singleThread="true"
|
:singleThread="true"
|
||||||
|
:scrollToBottom="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||||
import { formatTime } from '@/utils'
|
import { formatTime } from '@/utils'
|
||||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.current_lesson.split('.')[0],
|
chapterNumber: course.current_lesson.split('-')[0],
|
||||||
lessonNumber: course.current_lesson.split('.')[1],
|
lessonNumber: course.current_lesson.split('-')[1],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: course.membership
|
: course.membership
|
||||||
|
|||||||
157
frontend/src/pages/Notifications.vue
Normal file
157
frontend/src/pages/Notifications.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
@click="markAllAsRead.submit"
|
||||||
|
:loading="markAllAsRead.loading"
|
||||||
|
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
|
||||||
|
>
|
||||||
|
{{ __('Mark all as read') }}
|
||||||
|
</Button>
|
||||||
|
<TabButtons
|
||||||
|
class="inline-block"
|
||||||
|
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
|
||||||
|
v-model="activeTab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
|
||||||
|
<div
|
||||||
|
v-if="notifications?.length"
|
||||||
|
v-for="log in notifications"
|
||||||
|
class="flex items-center py-2 justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
|
||||||
|
<div class="notification" v-html="log.subject"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Link
|
||||||
|
v-if="log.link"
|
||||||
|
:to="log.link"
|
||||||
|
@click="markAsRead.submit({ name: log.name })"
|
||||||
|
class="text-gray-600 font-medium text-sm hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{{ __('View') }}
|
||||||
|
</Link>
|
||||||
|
<Tooltip :text="__('Mark as read')">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
v-if="!log.read"
|
||||||
|
@click="markAsRead.submit({ name: log.name })"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-600">
|
||||||
|
{{ __('Nothing to see here.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
Breadcrumbs,
|
||||||
|
Link,
|
||||||
|
TabButtons,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, ref, onMounted } from 'vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
const activeTab = ref('Unread')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data) router.push({ name: 'Courses' })
|
||||||
|
|
||||||
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
|
unReadNotifications.reload()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const notifications = computed(() => {
|
||||||
|
return activeTab.value === 'Unread'
|
||||||
|
? unReadNotifications.data
|
||||||
|
: readNotifications.data
|
||||||
|
})
|
||||||
|
|
||||||
|
const unReadNotifications = createListResource({
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||||
|
filters: {
|
||||||
|
for_user: user.data?.name,
|
||||||
|
read: 0,
|
||||||
|
},
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
cache: 'Unread Notifications',
|
||||||
|
})
|
||||||
|
|
||||||
|
const readNotifications = createListResource({
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||||
|
filters: {
|
||||||
|
for_user: user.data?.name,
|
||||||
|
read: 1,
|
||||||
|
},
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
cache: 'Read Notifications',
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAsRead = createResource({
|
||||||
|
url: 'lms.lms.api.mark_as_read',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
name: values.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
unReadNotifications.reload()
|
||||||
|
readNotifications.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAllAsRead = createResource({
|
||||||
|
url: 'lms.lms.api.mark_all_as_read',
|
||||||
|
onSuccess(data) {
|
||||||
|
unReadNotifications.reload()
|
||||||
|
readNotifications.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
route: {
|
||||||
|
name: 'Notifications',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.notification strong {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.notification b {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -130,6 +130,11 @@ const routes = [
|
|||||||
name: 'CertifiedParticipants',
|
name: 'CertifiedParticipants',
|
||||||
component: () => import('@/pages/CertifiedParticipants.vue'),
|
component: () => import('@/pages/CertifiedParticipants.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
component: () => import('@/pages/Notifications.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
@@ -138,13 +143,21 @@ let router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const { userResource } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
await userResource.reload()
|
await userResource.reload()
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isLoggedIn &&
|
||||||
|
(to.name == 'Lesson' ||
|
||||||
|
to.name == 'Batch' ||
|
||||||
|
to.name == 'Notifications')
|
||||||
|
) {
|
||||||
|
await allUsers.reload()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ export const usersStore = defineStore('lms-users', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allUsers = createResource({
|
||||||
|
url: 'lms.lms.api.get_all_users',
|
||||||
|
cache: ['allUsers'],
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userResource,
|
userResource,
|
||||||
|
allUsers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
Bell,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
@@ -350,6 +351,7 @@ export function getSidebarLinks() {
|
|||||||
label: 'Statistics',
|
label: 'Statistics',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
to: 'Statistics',
|
to: 'Statistics',
|
||||||
|
activeFor: ['Statistics'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export class Quiz {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
console.log(blockContent)
|
|
||||||
return {
|
return {
|
||||||
quiz: this.data.quiz,
|
quiz: this.data.quiz,
|
||||||
}
|
}
|
||||||
|
|||||||
11
frontend/src/utils/scrollContainer.js
Normal file
11
frontend/src/utils/scrollContainer.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function scrollTo(...options) {
|
||||||
|
if (!options || options.length === 0) return
|
||||||
|
const container = getScrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
container.scrollTo(...options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrollContainer() {
|
||||||
|
// window.scrollContainer is reference to the scroll container in DesktopLayout.vue and MobileLayout.vue
|
||||||
|
return window.scrollContainer
|
||||||
|
}
|
||||||
2081
frontend/yarn.lock
2081
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,7 @@ doc_events = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
||||||
|
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
|
|||||||
@@ -385,3 +385,33 @@ def get_certificates(member):
|
|||||||
fields=["name", "course", "course_title", "issue_date", "template"],
|
fields=["name", "course", "course_title", "issue_date", "template"],
|
||||||
order_by="creation desc",
|
order_by="creation desc",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_all_users():
|
||||||
|
users = frappe.get_all(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"enabled": 1,
|
||||||
|
},
|
||||||
|
["name", "full_name", "user_image"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {user.name: user for user in users}
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def mark_as_read(name):
|
||||||
|
doc = frappe.get_doc("Notification Log", name)
|
||||||
|
doc.read = 1
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def mark_all_as_read():
|
||||||
|
notifications = frappe.get_all(
|
||||||
|
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
|
||||||
|
)
|
||||||
|
|
||||||
|
for notification in notifications:
|
||||||
|
mark_as_read(notification)
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ class CourseLesson(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def save_progress(lesson, course):
|
def save_progress(lesson, course):
|
||||||
print("save progress")
|
|
||||||
membership = frappe.db.exists(
|
membership = frappe.db.exists(
|
||||||
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -281,21 +281,21 @@ def get_lesson_index(lesson_name):
|
|||||||
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
|
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
|
||||||
)
|
)
|
||||||
if not lesson:
|
if not lesson:
|
||||||
return "1.1"
|
return "1-1"
|
||||||
|
|
||||||
chapter = frappe.db.get_value(
|
chapter = frappe.db.get_value(
|
||||||
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
|
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
|
||||||
)
|
)
|
||||||
if not chapter:
|
if not chapter:
|
||||||
return "1.1"
|
return "1-1"
|
||||||
|
|
||||||
return f"{chapter.idx}.{lesson.idx}"
|
return f"{chapter.idx}-{lesson.idx}"
|
||||||
|
|
||||||
|
|
||||||
def get_lesson_url(course, lesson_number):
|
def get_lesson_url(course, lesson_number):
|
||||||
if not lesson_number:
|
if not lesson_number:
|
||||||
return
|
return
|
||||||
return f"/lms/courses/{course}/learn/{lesson_number}"
|
return f"/courses/{course}/learn/{lesson_number}"
|
||||||
|
|
||||||
|
|
||||||
def get_batch(course, batch_name):
|
def get_batch(course, batch_name):
|
||||||
@@ -640,37 +640,91 @@ def handle_notifications(doc, method):
|
|||||||
if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]:
|
if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]:
|
||||||
return
|
return
|
||||||
create_notification_log(doc, topic)
|
create_notification_log(doc, topic)
|
||||||
notify_mentions(doc, topic)
|
notify_mentions_on_portal(doc, topic)
|
||||||
|
notify_mentions_via_email(doc, topic)
|
||||||
|
|
||||||
|
|
||||||
def create_notification_log(doc, topic):
|
def create_notification_log(doc, topic):
|
||||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
users = []
|
||||||
instructors = frappe.db.get_all(
|
if topic.reference_doctype == "LMS Course":
|
||||||
"Course Instructor", {"parent": course}, pluck="instructor"
|
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||||
)
|
course_title = frappe.db.get_value("LMS Course", course, "title")
|
||||||
|
instructors = frappe.db.get_all(
|
||||||
|
"Course Instructor", {"parent": course}, pluck="instructor"
|
||||||
|
)
|
||||||
|
users.append(topic.owner)
|
||||||
|
users += instructors
|
||||||
|
subject = _("New reply on the topic {0} in course {1}").format(
|
||||||
|
topic.title, course_title
|
||||||
|
)
|
||||||
|
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||||
|
|
||||||
|
else:
|
||||||
|
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||||
|
subject = _("New comment in batch {0}").format(batch_title)
|
||||||
|
link = f"/batches/{topic.reference_docname}"
|
||||||
|
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
|
||||||
|
users += moderators
|
||||||
|
|
||||||
notification = frappe._dict(
|
notification = frappe._dict(
|
||||||
{
|
{
|
||||||
"subject": _("New reply on the topic {0}").format(topic.title),
|
"subject": subject,
|
||||||
"email_content": doc.reply,
|
"email_content": doc.reply,
|
||||||
"document_type": topic.reference_doctype,
|
"document_type": topic.reference_doctype,
|
||||||
"document_name": topic.reference_docname,
|
"document_name": topic.reference_docname,
|
||||||
"for_user": topic.owner,
|
"for_user": topic.owner,
|
||||||
"from_user": doc.owner,
|
"from_user": doc.owner,
|
||||||
"type": "Alert",
|
"type": "Alert",
|
||||||
|
"link": link,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
users = []
|
|
||||||
if doc.owner != topic.owner:
|
|
||||||
users.append(topic.owner)
|
|
||||||
|
|
||||||
if doc.owner not in instructors:
|
|
||||||
users += instructors
|
|
||||||
make_notification_logs(notification, users)
|
make_notification_logs(notification, users)
|
||||||
|
|
||||||
|
|
||||||
def notify_mentions(doc, topic):
|
def notify_mentions_on_portal(doc, topic):
|
||||||
|
mentions = extract_mentions(doc.reply)
|
||||||
|
if not mentions:
|
||||||
|
return
|
||||||
|
|
||||||
|
from_user_name = get_fullname(doc.owner)
|
||||||
|
|
||||||
|
if topic.reference_doctype == "LMS Course":
|
||||||
|
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||||
|
subject = _("{0} mentioned you in a comment in {1}").format(
|
||||||
|
from_user_name, topic.title
|
||||||
|
)
|
||||||
|
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||||
|
else:
|
||||||
|
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||||
|
subject = _("{0} mentioned you in a comment in {1}").format(
|
||||||
|
from_user_name, batch_title
|
||||||
|
)
|
||||||
|
link = f"/batches/{topic.reference_docname}"
|
||||||
|
|
||||||
|
for user in mentions:
|
||||||
|
notification = frappe._dict(
|
||||||
|
{
|
||||||
|
"subject": subject,
|
||||||
|
"email_content": doc.reply,
|
||||||
|
"document_type": topic.reference_doctype,
|
||||||
|
"document_name": topic.reference_docname,
|
||||||
|
"for_user": user,
|
||||||
|
"from_user": doc.owner,
|
||||||
|
"type": "Alert",
|
||||||
|
"link": link,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
make_notification_logs(notification, user)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_mentions_via_email(doc, topic):
|
||||||
|
outgoing_email_account = frappe.get_cached_value(
|
||||||
|
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
|
||||||
|
)
|
||||||
|
if not outgoing_email_account or not frappe.conf.get("mail_login"):
|
||||||
|
return
|
||||||
|
|
||||||
mentions = extract_mentions(doc.reply)
|
mentions = extract_mentions(doc.reply)
|
||||||
if not mentions:
|
if not mentions:
|
||||||
return
|
return
|
||||||
@@ -1708,6 +1762,7 @@ def create_discussion_topic(doctype, docname):
|
|||||||
doc = frappe.new_doc("Discussion Topic")
|
doc = frappe.new_doc("Discussion Topic")
|
||||||
doc.update(
|
doc.update(
|
||||||
{
|
{
|
||||||
|
"title": docname,
|
||||||
"reference_doctype": doctype,
|
"reference_doctype": doctype,
|
||||||
"reference_docname": docname,
|
"reference_docname": docname,
|
||||||
}
|
}
|
||||||
@@ -1817,3 +1872,9 @@ def get_roles(name):
|
|||||||
"batch_evaluator": has_course_evaluator_role(name),
|
"batch_evaluator": has_course_evaluator_role(name),
|
||||||
"lms_student": has_student_role(name),
|
"lms_student": has_student_role(name),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def publish_notifications(doc, method):
|
||||||
|
frappe.publish_realtime(
|
||||||
|
"publish_lms_notifications", user=doc.for_user, after_commit=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -88,3 +88,4 @@ lms.patches.v1_0.rename_evaluator_role
|
|||||||
lms.patches.v1_0.change_navbar_urls
|
lms.patches.v1_0.change_navbar_urls
|
||||||
lms.patches.v1_0.set_published_on
|
lms.patches.v1_0.set_published_on
|
||||||
lms.patches.v2_0.fix_progress_percentage
|
lms.patches.v2_0.fix_progress_percentage
|
||||||
|
lms.patches.v2_0.add_discussion_topic_titles
|
||||||
13
lms/patches/v2_0/add_discussion_topic_titles.py
Normal file
13
lms/patches/v2_0/add_discussion_topic_titles.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
topics = frappe.get_all(
|
||||||
|
"Discussion Topic",
|
||||||
|
{"title": ["is", "not set"]},
|
||||||
|
["name", "reference_docname", "title"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
if not topic.title:
|
||||||
|
frappe.db.set_value("Discussion Topic", topic.name, "title", topic.reference_docname)
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||||
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-CdhjdjEj.js"></script>
|
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-C-DogOtg.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-CgFK8870.js">
|
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-CGsuCsfq.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/frappe-ui-DzKBfka9.css">
|
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/frappe-ui-B1gEXx4C.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-C1pDkvO9.css">
|
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-C1pDkvO9.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user