feat: discussions

This commit is contained in:
Jannat Patel
2024-01-15 23:26:31 +05:30
parent bcee74ce77
commit 3a5977a718
14 changed files with 771 additions and 336 deletions

View File

@@ -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) {

View 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>

View 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>

View File

@@ -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 })
}

View File

@@ -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()

View File

@@ -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 {

View 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>

View File

@@ -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
View 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
}