feat: discussions
This commit is contained in:
@@ -10,14 +10,10 @@ import { Toasts } from 'frappe-ui'
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
const MobileLayout = defineAsyncComponent(() =>
|
||||
import('@/components/MobileLayout.vue')
|
||||
)
|
||||
const DesktopLayout = defineAsyncComponent(() =>
|
||||
import('@/components/DesktopLayout.vue')
|
||||
)
|
||||
|
||||
const Layout = computed(() => {
|
||||
if (screenSize.width < 640) {
|
||||
|
||||
110
frontend/src/components/DiscussionReplies.vue
Normal file
110
frontend/src/components/DiscussionReplies.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center mb-5">
|
||||
<Button variant="subtle" @click="showTopics = true">
|
||||
<ChevronLeft class="w-4 h-4 stroke-1.5" />
|
||||
</Button>
|
||||
<span class="text-lg font-semibold ml-2">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(reply, index) in replies.data">
|
||||
<div
|
||||
class="py-3"
|
||||
:class="{ 'border-b': index + 1 != replies.data.length }"
|
||||
>
|
||||
<div class="flex items-center mb-2">
|
||||
<UserAvatar :user="reply.user" class="mr-2" />
|
||||
<span>
|
||||
{{ reply.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-2">
|
||||
{{ timeAgo(reply.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-html="reply.reply"></div>
|
||||
</div>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="newReply"
|
||||
@change="(val) => (newReply = val)"
|
||||
placeholder="Type your reply here..."
|
||||
editorClass="prose-sm py-2 px-2 min-h-[100px] border-gray-300 hover:border-gray-400 rounded-md bg-gray-200 w-full mt-5"
|
||||
/>
|
||||
<div class="flex justify-between mt-2">
|
||||
<span> </span>
|
||||
<Button @click="postReply()">
|
||||
<span>
|
||||
{{ __('Post') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, TextEditor, Button } from 'frappe-ui'
|
||||
import { timeAgo } from '../utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted } from 'vue'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
const socket = inject('$socket')
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(socket)
|
||||
socket.on('publish_message', (data) => {
|
||||
console.log('publish')
|
||||
console.log(data)
|
||||
replies.reload()
|
||||
})
|
||||
|
||||
const replies = createResource({
|
||||
url: 'lms.lms.utils.get_discussion_replies',
|
||||
cache: ['replies', props.topic],
|
||||
makeParams(values) {
|
||||
return {
|
||||
topic: props.topic.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newReplyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!newReply.value) {
|
||||
return __('Reply cannot be empty')
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
84
frontend/src/components/Discussions.vue
Normal file
84
frontend/src/components/Discussions.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div v-if="topics.data">
|
||||
<div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showTopics" v-for="(topic, index) in topics.data">
|
||||
<div
|
||||
@click="showReplies(topic)"
|
||||
class="flex items-center cursor-pointer py-5"
|
||||
:class="{ 'border-b': index + 1 != topics.data.length }"
|
||||
>
|
||||
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold mb-1">
|
||||
{{ topic.title }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span>
|
||||
{{ topic.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-2">
|
||||
{{ timeAgo(topic.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<DiscussionReplies
|
||||
:topic="currentTopic"
|
||||
v-model:showTopics="showTopics"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource } from 'frappe-ui'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { timeAgo } from '../utils'
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
|
||||
const showTopics = ref(true)
|
||||
const currentTopic = ref(null)
|
||||
const socket = inject('$socket')
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
socket.on('new_discussion_topic', (data) => {
|
||||
topics.refresh()
|
||||
})
|
||||
})
|
||||
|
||||
const topics = createResource({
|
||||
url: 'lms.lms.utils.get_discussion_topics',
|
||||
cache: ['topics', props.doctype, props.docname],
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
docname: props.docname,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const showReplies = (topic) => {
|
||||
showTopics.value = false
|
||||
currentTopic.value = topic
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,9 @@
|
||||
<div class="h-full overflow-auto" id="scrollContainer">
|
||||
<slot />
|
||||
</div>
|
||||
{{ tabs }}
|
||||
<div
|
||||
v-if="tabs"
|
||||
class="grid grid-cols-5 border-t border-gray-300 standalone:pb-4"
|
||||
:style="{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }"
|
||||
>
|
||||
@@ -13,27 +15,29 @@
|
||||
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
||||
@click="handleClick(tab)"
|
||||
>
|
||||
<component
|
||||
:is="tab.icon"
|
||||
class="h-6 w-6"
|
||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||
/>
|
||||
{{ tab.label }}
|
||||
<component :is="tab.icon" class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { scrollTo } from '@/utils/scrollContainer'
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const tabs = getSidebarLinks()
|
||||
|
||||
let isActive = computed((tab) => {
|
||||
return router.currentRoute.value.name === tab.to
|
||||
const tabs = computed(() => {
|
||||
return getSidebarLinks()
|
||||
})
|
||||
|
||||
console.log(tabs.value)
|
||||
|
||||
/* let isActive = computed((tab) => {
|
||||
console.log(tab);
|
||||
return router.currentRoute.value.name === tab.to
|
||||
}) */
|
||||
|
||||
const handleClick = (tab) => {
|
||||
router.push({ name: tab.to })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import dayjs from '@/utils/dayjs'
|
||||
import translationPlugin from './translation'
|
||||
import { usersStore } from './stores/user'
|
||||
import { sessionStore } from './stores/session'
|
||||
import { initSocket } from './socket'
|
||||
import {
|
||||
FrappeUI,
|
||||
setConfig,
|
||||
@@ -26,6 +27,7 @@ app.use(router)
|
||||
app.use(translationPlugin)
|
||||
app.use(pageMetaPlugin)
|
||||
app.provide('$dayjs', dayjs)
|
||||
app.provide('$socket', initSocket())
|
||||
app.mount('#app')
|
||||
|
||||
const { userResource } = usersStore()
|
||||
|
||||
@@ -165,6 +165,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<Discussions
|
||||
v-if="allowDiscussions()"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
<div class="bg-gray-50 p-5 border-b-2">
|
||||
@@ -194,13 +202,13 @@
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||
import { computed, watch, onBeforeMount, onUnmounted, inject } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const route = useRoute()
|
||||
@@ -327,6 +335,14 @@ const getId = (block) => {
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect_to=/courses/${props.courseName}/learn/${route.params.chapterNumber}-${route.params.lessonNumber}`
|
||||
}
|
||||
|
||||
const allowDiscussions = () => {
|
||||
return (
|
||||
course.data?.membership ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
|
||||
139
frontend/src/pages/Statistics.vue
Normal file
139
frontend/src/pages/Statistics.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-5 gap-5">
|
||||
<div class="flex items-center border py-2 px-3 rounded-md">
|
||||
<div class="p-15 bg-gray-100">
|
||||
<BookOpen class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{{ courseCount.data }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Published Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border py-2 px-3 rounded-md">
|
||||
<BookOpenCheck class="w-4 h-4 stroke-1.5" />
|
||||
<div>
|
||||
{{ enrollmentCount.data }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Course Enrollments') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border py-2 px-3 rounded-md">
|
||||
<LogIn class="w-4 h-4 stroke-1.5" />
|
||||
<div>
|
||||
{{ userCount.data }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Total Signups') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border py-2 px-3 rounded-md">
|
||||
<FileCheck class="w-4 h-4 stroke-1.5" />
|
||||
<div>
|
||||
{{ coursesCompleted.data }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Courses Completed') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border py-2 px-3 rounded-md">
|
||||
<FileCheck2 class="w-4 h-4 stroke-1.5" />
|
||||
<div>
|
||||
{{ lessonsCompleted.data }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Lessons Completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
BookOpen,
|
||||
LogIn,
|
||||
FileCheck,
|
||||
FileCheck2,
|
||||
BookOpenCheck,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Statistics',
|
||||
route: {
|
||||
name: 'Statistics',
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const enrollmentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Enrollment',
|
||||
},
|
||||
auto: true,
|
||||
cache: ['enrollment_count'],
|
||||
})
|
||||
|
||||
const courseCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Course',
|
||||
filters: {
|
||||
published: 1,
|
||||
upcoming: 0,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
cache: ['course_count'],
|
||||
})
|
||||
|
||||
const userCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'User',
|
||||
filters: {
|
||||
enabled: 1,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
cache: ['user_count'],
|
||||
})
|
||||
|
||||
const coursesCompleted = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
progress: ['like', '%100%'],
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
cache: ['courses_completed'],
|
||||
})
|
||||
|
||||
const lessonsCompleted = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Course Progress',
|
||||
},
|
||||
auto: true,
|
||||
cache: ['lessons_completed'],
|
||||
})
|
||||
</script>
|
||||
@@ -43,6 +43,11 @@ const routes = [
|
||||
component: () => import('@/pages/Batch.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'Statistics',
|
||||
component: () => import('@/pages/Statistics.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
|
||||
28
frontend/src/socket.js
Normal file
28
frontend/src/socket.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { io } from 'socket.io-client'
|
||||
import { socketio_port } from '../../../../sites/common_site_config.json'
|
||||
import { getCachedListResource } from 'frappe-ui/src/resources/listResource'
|
||||
import { getCachedResource } from 'frappe-ui/src/resources/resources'
|
||||
|
||||
export function initSocket() {
|
||||
let host = window.location.hostname
|
||||
let siteName = window.site_name
|
||||
let port = window.location.port ? `:${socketio_port}` : ''
|
||||
let protocol = port ? 'http' : 'https'
|
||||
let url = `${protocol}://${host}${port}/${siteName}`
|
||||
console.log(protocol, host, port, siteName)
|
||||
let socket = io(url, {
|
||||
withCredentials: true,
|
||||
reconnectionAttempts: 5,
|
||||
})
|
||||
socket.on('refetch_resource', (data) => {
|
||||
if (data.cache_key) {
|
||||
let resource =
|
||||
getCachedResource(data.cache_key) ||
|
||||
getCachedListResource(data.cache_key)
|
||||
if (resource) {
|
||||
resource.reload()
|
||||
}
|
||||
}
|
||||
})
|
||||
return socket
|
||||
}
|
||||
Reference in New Issue
Block a user