feat: notes in lesson
This commit is contained in:
10
frontend/auto-imports.d.ts
vendored
Normal file
10
frontend/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
3
frontend/components.d.ts
vendored
3
frontend/components.d.ts
vendored
@@ -66,6 +66,8 @@ declare module 'vue' {
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
|
||||
InlineMenu: typeof import('./src/components/InlineMenu.vue')['default']
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||
@@ -81,6 +83,7 @@ declare module 'vue' {
|
||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.172",
|
||||
"frappe-ui": "^0.1.182",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
label: __('Edit'),
|
||||
onClick() {
|
||||
reply.editable = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
label: __('Delete'),
|
||||
onClick() {
|
||||
deleteReply(reply)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
class="float-right"
|
||||
@click="openTopicModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="size-4" />
|
||||
</template>
|
||||
{{ __('New {0}').format(singularize(title)) }}
|
||||
</Button>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
@@ -49,7 +52,7 @@
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||
<div class="">
|
||||
<div class="mt-2">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
@@ -73,7 +76,7 @@ import { singularize, timeAgo } from '@/utils'
|
||||
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||
import { MessageSquareText } from 'lucide-vue-next'
|
||||
import { MessageSquareText, Plus } from 'lucide-vue-next'
|
||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||
|
||||
const showTopics = ref(true)
|
||||
@@ -102,7 +105,7 @@ const props = defineProps({
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Start a discussion',
|
||||
default: 'Start a Discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
|
||||
240
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
240
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-sm absolute bg-white border rounded-md z-10 w-44"
|
||||
:style="{
|
||||
display: top > 0 ? 'block' : 'none',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="space-y-2 py-2">
|
||||
<div class="text-xs text-ink-gray-5 font-medium px-3">
|
||||
{{ __('Highlight') }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
@click="saveHighLight(color)"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||
}"
|
||||
></span>
|
||||
<span>
|
||||
{{ __(color) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
<div
|
||||
@click="addToNotes"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<NotepadText class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Add to Notes') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="highlightExists()"
|
||||
@click="deleteHighlight"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<Trash2 class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Remove Highlight') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick, highlightText } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const show = defineModel()
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const currentSelection = ref<Selection | null>(null)
|
||||
const selectedText = ref('')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
currentSelection.value = window.getSelection()
|
||||
if (!currentSelection.value?.toString()) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
})
|
||||
|
||||
const updateMenuPosition = () => {
|
||||
selectedText.value = currentSelection.value?.toString() || ''
|
||||
const range = currentSelection.value?.getRangeAt(0)
|
||||
const rect = range?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const offsetY = window.scrollY
|
||||
const offsetX = window.scrollX
|
||||
|
||||
top.value = Math.floor(rect.top + offsetY - 40)
|
||||
left.value = Math.floor(rect.right + offsetX + 10)
|
||||
}
|
||||
|
||||
const resetMenuPosition = () => {
|
||||
top.value = 0
|
||||
left.value = 0
|
||||
}
|
||||
|
||||
const colors = computed(() => {
|
||||
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
|
||||
})
|
||||
|
||||
const highlightExists = () => {
|
||||
return notes.value?.data?.some(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
}
|
||||
|
||||
const saveHighLight = (color: string) => {
|
||||
if (!selectedText.value) return
|
||||
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
highlighted_text: selectedText.value,
|
||||
color: color,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
highlightText(data)
|
||||
resetStates()
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error saving highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHighlight = () => {
|
||||
let notesToDelete = notes.value?.data.find(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
if (!notesToDelete) return
|
||||
notes.value?.delete.submit(notesToDelete.name, {
|
||||
onSuccess() {
|
||||
resetStates()
|
||||
document.querySelectorAll('.highlighted-text').forEach((el) => {
|
||||
if (el.dataset.name === notesToDelete.name) {
|
||||
el.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error deleting highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addToNotes = () => {
|
||||
if (!selectedText.value) return
|
||||
console.log(selectedText.value)
|
||||
let noteToUpdate = notes.value?.data.find((note: Note) => {
|
||||
return !note.highlighted_text && note.note !== ''
|
||||
})
|
||||
if (!noteToUpdate) {
|
||||
createNote()
|
||||
} else {
|
||||
updateNote(noteToUpdate)
|
||||
}
|
||||
console.log(noteToUpdate)
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
color: 'Yellow',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
selectedText.value = ''
|
||||
resetStates()
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
}, 0)
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = (noteToUpdate: Note) => {
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: noteToUpdate.name,
|
||||
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
resetStates()
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
}, 0)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToText = (text: string) => {
|
||||
const elements = document.querySelectorAll('blockquote p')
|
||||
Array.from(elements).forEach((el: HTMLElement) => {
|
||||
if (el.textContent?.toLowerCase().includes(text.toLowerCase())) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetStates = () => {
|
||||
selectedText.value = ''
|
||||
show.value = false
|
||||
resetMenuPosition()
|
||||
emit('updateNotes')
|
||||
}
|
||||
</script>
|
||||
107
frontend/src/components/Notes/Notes.vue
Normal file
107
frontend/src/components/Notes/Notes.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('My Notes') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="note"
|
||||
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||
@change="(val) => updateNoteText(val)"
|
||||
:editable="true"
|
||||
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { TextEditor } from 'frappe-ui'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { inject, ref, onMounted, watch } from 'vue'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick } from '@/utils/'
|
||||
|
||||
const note = ref<string | null>(null)
|
||||
const currentNoteName = ref<string | null>(null)
|
||||
const user = inject<any>('$user')
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
updateCurrentNote()
|
||||
})
|
||||
|
||||
watch(notes.value, () => {
|
||||
updateCurrentNote()
|
||||
blockQuotesClick()
|
||||
})
|
||||
|
||||
const updateCurrentNote = () => {
|
||||
const currentNote = notes.value?.data?.filter((row: Note) => {
|
||||
return !row.highlighted_text && row.note !== ''
|
||||
})
|
||||
if (!currentNote) return
|
||||
if (currentNote.length > 0) {
|
||||
currentNoteName.value = currentNote[0].name
|
||||
note.value = currentNote[0].note
|
||||
}
|
||||
}
|
||||
|
||||
const updateNoteText = (val: string) => {
|
||||
note.value = val
|
||||
debouncedSave()
|
||||
}
|
||||
|
||||
const debouncedSave = useDebounceFn(() => {
|
||||
saveNotes()
|
||||
}, 2000)
|
||||
|
||||
const saveNotes = () => {
|
||||
if (currentNoteName.value) {
|
||||
updateNote()
|
||||
} else {
|
||||
createNote()
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
color: 'Yellow',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
currentNoteName.value = data.name || null
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = () => {
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: currentNoteName.value,
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
32
frontend/src/components/Notes/types.ts
Normal file
32
frontend/src/components/Notes/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
export type Note = {
|
||||
highlighted_text?: string
|
||||
color?: string
|
||||
name?: string
|
||||
note?: string | null
|
||||
lesson?: string
|
||||
member?: string
|
||||
}
|
||||
|
||||
export type Notes = {
|
||||
data: Note[]
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
delete: {
|
||||
submit: (
|
||||
data: Note | string,
|
||||
options?: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
@@ -69,155 +69,179 @@
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
||||
class="border-r pt-5 pb-10 h-full"
|
||||
:class="{
|
||||
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
<div class="px-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ lesson.data.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="zenModeEnabled"
|
||||
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
|
||||
>
|
||||
<span>
|
||||
{{ lesson.data.chapter_title }} -
|
||||
{{ lesson.data.course_title }}
|
||||
</span>
|
||||
<Info class="size-3" />
|
||||
<div
|
||||
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
|
||||
v-if="zenModeEnabled"
|
||||
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
|
||||
>
|
||||
{{ Math.ceil(lesson.data.membership.progress) }}%
|
||||
{{ __('completed') }}
|
||||
<span>
|
||||
{{ lesson.data.chapter_title }} -
|
||||
{{ lesson.data.course_title }}
|
||||
</span>
|
||||
<Info class="size-3" />
|
||||
<div
|
||||
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
|
||||
>
|
||||
{{ Math.ceil(lesson.data.membership.progress) }}%
|
||||
{{ __('completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
||||
<Button
|
||||
v-if="zenModeEnabled"
|
||||
@click="showDiscussionsInZenMode()"
|
||||
>
|
||||
<template #icon>
|
||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
||||
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
|
||||
<template #icon>
|
||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
<UserAvatar
|
||||
v-for="instructor in lesson.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||
>
|
||||
<div class="text-ink-gray-5 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length >
|
||||
1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||
>
|
||||
<div class="text-ink-gray-5 font-medium">
|
||||
{{ __('Instructor Notes') }}
|
||||
</div>
|
||||
<div
|
||||
id="instructor-content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
id="instructor-content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
v-else-if="lesson.data.instructor_notes"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent :content="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.data.content"
|
||||
@mouseup="toggleInlineMenu"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lesson.data.instructor_notes"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
v-if="lesson.data"
|
||||
class="mt-10 pt-5 border-t px-5"
|
||||
ref="discussionsContainer"
|
||||
>
|
||||
<LessonContent :content="lesson.data.instructor_notes" />
|
||||
</div>
|
||||
<div
|
||||
v-if="lesson.data.content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
<TabButtons
|
||||
:buttons="tabs"
|
||||
v-model="currentTab"
|
||||
class="w-fit mb-10"
|
||||
/>
|
||||
<Notes
|
||||
v-if="currentTab === 'Notes'"
|
||||
:lesson="lesson.data?.name"
|
||||
v-model:notes="notes"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20" ref="discussionsContainer">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
v-else-if="allowDiscussions"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
:key="lesson.data.name"
|
||||
:emptyStateText="
|
||||
__('Ask a question to get help from the community.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,10 +271,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InlineMenu
|
||||
v-model="showInlineMenu"
|
||||
:lesson="lesson.data?.name"
|
||||
v-model:notes="notes"
|
||||
/>
|
||||
<VideoStatistics
|
||||
v-model="showStatsDialog"
|
||||
:lessonName="lesson.data?.name"
|
||||
:lessonTitle="lesson.data?.title"
|
||||
@updateNotes="updateNotes"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -259,7 +289,9 @@ import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
createResource,
|
||||
TabButtons,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
@@ -272,8 +304,6 @@ import {
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
@@ -285,16 +315,20 @@ import {
|
||||
MessageCircleQuestion,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, enablePlyr } from '@/utils'
|
||||
import { getEditorTools, enablePlyr, highlightText } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import InlineMenu from '@/components/Notes/InlineLessonMenu.vue'
|
||||
import Notes from '@/components/Notes/Notes.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const socket = inject('$socket')
|
||||
@@ -313,6 +347,8 @@ const timer = ref(0)
|
||||
const { brand } = sessionStore()
|
||||
const sidebarStore = useSidebar()
|
||||
const plyrSources = ref([])
|
||||
const showInlineMenu = ref(false)
|
||||
const currentTab = ref('Notes')
|
||||
let timerInterval
|
||||
|
||||
const props = defineProps({
|
||||
@@ -401,7 +437,6 @@ const setupLesson = (data) => {
|
||||
}
|
||||
|
||||
const renderEditor = (holder, content) => {
|
||||
// empty the holder
|
||||
if (document.getElementById(holder))
|
||||
document.getElementById(holder).innerHTML = ''
|
||||
return new EditorJS({
|
||||
@@ -409,7 +444,7 @@ const renderEditor = (holder, content) => {
|
||||
tools: getEditorTools(),
|
||||
data: JSON.parse(content),
|
||||
readOnly: true,
|
||||
defaultBlock: 'embed', // editor adds an empty block at the top, so to avoid that added default block as embed
|
||||
defaultBlock: 'embed',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,6 +467,23 @@ const progress = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const notes = createListResource({
|
||||
doctype: 'LMS Lesson Note',
|
||||
filters: {
|
||||
lesson: lesson.data?.name,
|
||||
member: user.data?.name,
|
||||
},
|
||||
fields: ['name', 'color', 'highlighted_text', 'note'],
|
||||
cache: ['notes', lesson.data?.name, user.data?.name],
|
||||
onSuccess(data) {
|
||||
data.forEach((note) => {
|
||||
setTimeout(() => {
|
||||
highlightText(note)
|
||||
}, 500)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
@@ -480,6 +532,7 @@ watch(
|
||||
await nextTick()
|
||||
resetLessonState(newChapterNumber, newLessonNumber)
|
||||
startTimer()
|
||||
notes.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -546,6 +599,7 @@ watch(
|
||||
async (data) => {
|
||||
setupLesson(data)
|
||||
getPlyrSource()
|
||||
updateNotes()
|
||||
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
||||
}
|
||||
)
|
||||
@@ -669,6 +723,15 @@ const enrollStudent = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const toggleInlineMenu = async () => {
|
||||
showInlineMenu.value = false
|
||||
await nextTick()
|
||||
let selection = window.getSelection()
|
||||
if (selection.toString()) {
|
||||
showInlineMenu.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const canSeeStats = () => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
@@ -720,6 +783,25 @@ const scrollDiscussionsIntoView = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateNotes = () => {
|
||||
if (!notes.filters?.lesson) {
|
||||
notes.update({
|
||||
filters: {
|
||||
lesson: lesson.data?.name,
|
||||
member: user.data?.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
notes.reload()
|
||||
}
|
||||
|
||||
const tabs = computed(() => {
|
||||
return [
|
||||
{ label: __('Notes'), value: 'Notes' },
|
||||
{ label: __('Community'), value: 'Community' },
|
||||
]
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { call, toast } from 'frappe-ui'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { Quiz } from '@/utils/quiz'
|
||||
import { Program } from '@/utils/program'
|
||||
import { Assignment } from '@/utils/assignment'
|
||||
@@ -673,3 +674,97 @@ export const convertToMinutes = (seconds) => {
|
||||
const remainingSeconds = Math.round(seconds % 60)
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getRootNode = (selector = '#editor') => {
|
||||
const root = document.querySelector(selector)
|
||||
if (!root) {
|
||||
console.warn(`Root node not found for selector: ${selector}`)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const createTextWalker = (root, phrase) => {
|
||||
return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
return node.nodeValue.toLowerCase().includes(phrase.toLowerCase())
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_SKIP
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const findMatchingTextNode = (walker, phrase) => {
|
||||
const node = walker.nextNode()
|
||||
if (!node) return null
|
||||
|
||||
const startIndex = node.nodeValue
|
||||
.toLowerCase()
|
||||
.indexOf(phrase.toLowerCase())
|
||||
const endIndex = startIndex + phrase.length
|
||||
|
||||
return { node, startIndex, endIndex }
|
||||
}
|
||||
|
||||
const createHighlightSpan = (color, name) => {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'highlighted-text'
|
||||
span.style.backgroundColor = theme.backgroundColor[color][200]
|
||||
span.dataset.name = name
|
||||
return span
|
||||
}
|
||||
|
||||
const wrapRangeInHighlight = ({ node, startIndex, endIndex }, color, name) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, startIndex)
|
||||
range.setEnd(node, endIndex)
|
||||
|
||||
const span = createHighlightSpan(color, name)
|
||||
range.surroundContents(span)
|
||||
}
|
||||
|
||||
export const highlightText = (note, scrollIntoView = false) => {
|
||||
if (!note?.highlighted_text) return
|
||||
|
||||
const root = getRootNode()
|
||||
if (!root) return
|
||||
|
||||
const phrase = note.highlighted_text
|
||||
const color = note.color.toLowerCase()
|
||||
|
||||
const walker = createTextWalker(root, phrase)
|
||||
const match = findMatchingTextNode(walker, phrase)
|
||||
if (!match) return
|
||||
|
||||
wrapRangeInHighlight(match, color, note.name)
|
||||
|
||||
if (scrollIntoView) {
|
||||
match.node.parentElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
setTimeout(() => {
|
||||
const highlightedElements =
|
||||
document.querySelectorAll('.highlighted-text')
|
||||
highlightedElements.forEach((el) => {
|
||||
if (el.dataset.name === note.name) {
|
||||
el.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollToReference = (text) => {
|
||||
highlightText({ highlighted_text: text, color: 'yellow', name: '' }, true)
|
||||
}
|
||||
|
||||
export const blockQuotesClick = () => {
|
||||
document.querySelectorAll('blockquote').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const text = e.target.textContent || ''
|
||||
if (text) {
|
||||
scrollToReference(text)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
allowedHosts: ['fs', 'per2'],
|
||||
host: '0.0.0.0', // Accept connections from any network interface
|
||||
allowedHosts: ['ps', 'fs'], // Explicitly allow this host
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user