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']
|
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.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']
|
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||||
JobCard: typeof import('./src/components/JobCard.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']
|
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.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']
|
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||||
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.172",
|
"frappe-ui": "^0.1.182",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
"
|
"
|
||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: __('Edit'),
|
||||||
onClick() {
|
onClick() {
|
||||||
reply.editable = true
|
reply.editable = true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: __('Delete'),
|
||||||
onClick() {
|
onClick() {
|
||||||
deleteReply(reply)
|
deleteReply(reply)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
class="float-right"
|
class="float-right"
|
||||||
@click="openTopicModal()"
|
@click="openTopicModal()"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
</template>
|
||||||
{{ __('New {0}').format(singularize(title)) }}
|
{{ __('New {0}').format(singularize(title)) }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-xl font-semibold text-ink-gray-9">
|
<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"
|
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" />
|
<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">
|
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +76,7 @@ import { singularize, timeAgo } from '@/utils'
|
|||||||
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
import { ref, onMounted, inject, onUnmounted } 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 { MessageSquareText } from 'lucide-vue-next'
|
import { MessageSquareText, Plus } from 'lucide-vue-next'
|
||||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||||
|
|
||||||
const showTopics = ref(true)
|
const showTopics = ref(true)
|
||||||
@@ -102,7 +105,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Start a discussion',
|
default: 'Start a Discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
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
|
<div
|
||||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
class="border-r pt-5 pb-10 h-full"
|
||||||
:class="{
|
:class="{
|
||||||
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div class="px-5">
|
||||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
<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">
|
<div class="flex flex-col">
|
||||||
{{ lesson.data.title }}
|
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||||
</div>
|
{{ 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
|
<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) }}%
|
<span>
|
||||||
{{ __('completed') }}
|
{{ 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>
|
</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>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
|
||||||
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
|
<span
|
||||||
<template #icon>
|
class="h-6 mr-1"
|
||||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
:class="{
|
||||||
</template>
|
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||||
</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>
|
<UserAvatar
|
||||||
{{ __('Edit') }}
|
v-for="instructor in lesson.data.instructors"
|
||||||
</Button>
|
:user="instructor"
|
||||||
</router-link>
|
/>
|
||||||
|
</span>
|
||||||
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
<CourseInstructors
|
||||||
<template #suffix>
|
v-if="lesson.data?.instructors"
|
||||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
:instructors="lesson.data.instructors"
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
<CourseInstructors
|
|
||||||
v-if="lesson.data?.instructors"
|
|
||||||
:instructors="lesson.data.instructors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
lesson.data.instructor_content &&
|
lesson.data.instructor_content &&
|
||||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
JSON.parse(lesson.data.instructor_content)?.blocks?.length >
|
||||||
allowInstructorContent()
|
1 &&
|
||||||
"
|
allowInstructorContent()
|
||||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
"
|
||||||
>
|
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||||
<div class="text-ink-gray-5 font-medium">
|
>
|
||||||
{{ __('Instructor Notes') }}
|
<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>
|
||||||
<div
|
<div
|
||||||
id="instructor-content"
|
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"
|
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>
|
>
|
||||||
|
<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>
|
||||||
<div
|
<div
|
||||||
v-else-if="lesson.data.instructor_notes"
|
v-if="lesson.data"
|
||||||
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"
|
class="mt-10 pt-5 border-t px-5"
|
||||||
|
ref="discussionsContainer"
|
||||||
>
|
>
|
||||||
<LessonContent :content="lesson.data.instructor_notes" />
|
<TabButtons
|
||||||
</div>
|
:buttons="tabs"
|
||||||
<div
|
v-model="currentTab"
|
||||||
v-if="lesson.data.content"
|
class="w-fit mb-10"
|
||||||
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"
|
/>
|
||||||
>
|
<Notes
|
||||||
<div id="editor"></div>
|
v-if="currentTab === 'Notes'"
|
||||||
</div>
|
:lesson="lesson.data?.name"
|
||||||
<div
|
v-model:notes="notes"
|
||||||
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 class="mt-20" ref="discussionsContainer">
|
|
||||||
<Discussions
|
<Discussions
|
||||||
v-if="allowDiscussions"
|
v-else-if="allowDiscussions"
|
||||||
:title="'Questions'"
|
:title="'Questions'"
|
||||||
:doctype="'Course Lesson'"
|
:doctype="'Course Lesson'"
|
||||||
:docname="lesson.data.name"
|
:docname="lesson.data.name"
|
||||||
:key="lesson.data.name"
|
:key="lesson.data.name"
|
||||||
|
:emptyStateText="
|
||||||
|
__('Ask a question to get help from the community.')
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,10 +271,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<InlineMenu
|
||||||
|
v-model="showInlineMenu"
|
||||||
|
:lesson="lesson.data?.name"
|
||||||
|
v-model:notes="notes"
|
||||||
|
/>
|
||||||
<VideoStatistics
|
<VideoStatistics
|
||||||
v-model="showStatsDialog"
|
v-model="showStatsDialog"
|
||||||
:lessonName="lesson.data?.name"
|
:lessonName="lesson.data?.name"
|
||||||
:lessonTitle="lesson.data?.title"
|
:lessonTitle="lesson.data?.title"
|
||||||
|
@updateNotes="updateNotes"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -259,7 +289,9 @@ import {
|
|||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
call,
|
call,
|
||||||
|
createListResource,
|
||||||
createResource,
|
createResource,
|
||||||
|
TabButtons,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
@@ -272,8 +304,6 @@ import {
|
|||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
nextTick,
|
nextTick,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -285,16 +315,20 @@ import {
|
|||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import { getEditorTools, enablePlyr, highlightText } from '@/utils'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useSidebar } from '@/stores/sidebar'
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
import VideoStatistics from '@/components/Modals/VideoStatistics.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 user = inject('$user')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -313,6 +347,8 @@ const timer = ref(0)
|
|||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const sidebarStore = useSidebar()
|
const sidebarStore = useSidebar()
|
||||||
const plyrSources = ref([])
|
const plyrSources = ref([])
|
||||||
|
const showInlineMenu = ref(false)
|
||||||
|
const currentTab = ref('Notes')
|
||||||
let timerInterval
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -401,7 +437,6 @@ const setupLesson = (data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderEditor = (holder, content) => {
|
const renderEditor = (holder, content) => {
|
||||||
// empty the holder
|
|
||||||
if (document.getElementById(holder))
|
if (document.getElementById(holder))
|
||||||
document.getElementById(holder).innerHTML = ''
|
document.getElementById(holder).innerHTML = ''
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
@@ -409,7 +444,7 @@ const renderEditor = (holder, content) => {
|
|||||||
tools: getEditorTools(),
|
tools: getEditorTools(),
|
||||||
data: JSON.parse(content),
|
data: JSON.parse(content),
|
||||||
readOnly: true,
|
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(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
@@ -480,6 +532,7 @@ watch(
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
resetLessonState(newChapterNumber, newLessonNumber)
|
resetLessonState(newChapterNumber, newLessonNumber)
|
||||||
startTimer()
|
startTimer()
|
||||||
|
notes.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -546,6 +599,7 @@ watch(
|
|||||||
async (data) => {
|
async (data) => {
|
||||||
setupLesson(data)
|
setupLesson(data)
|
||||||
getPlyrSource()
|
getPlyrSource()
|
||||||
|
updateNotes()
|
||||||
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
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 = () => {
|
const canSeeStats = () => {
|
||||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||||
return false
|
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 = () => {
|
const redirectToLogin = () => {
|
||||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { call, toast } from 'frappe-ui'
|
import { call, toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
import { Program } from '@/utils/program'
|
import { Program } from '@/utils/program'
|
||||||
import { Assignment } from '@/utils/assignment'
|
import { Assignment } from '@/utils/assignment'
|
||||||
@@ -673,3 +674,97 @@ export const convertToMinutes = (seconds) => {
|
|||||||
const remainingSeconds = Math.round(seconds % 60)
|
const remainingSeconds = Math.round(seconds % 60)
|
||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
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: {
|
server: {
|
||||||
allowedHosts: ['fs', 'per2'],
|
host: '0.0.0.0', // Accept connections from any network interface
|
||||||
|
allowedHosts: ['ps', 'fs'], // Explicitly allow this host
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
0
lms/lms/doctype/lms_lesson_note/__init__.py
Normal file
0
lms/lms/doctype/lms_lesson_note/__init__.py
Normal file
8
lms/lms/doctype/lms_lesson_note/lms_lesson_note.js
Normal file
8
lms/lms/doctype/lms_lesson_note/lms_lesson_note.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("LMS Lesson Note", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
140
lms/lms/doctype/lms_lesson_note/lms_lesson_note.json
Normal file
140
lms/lms/doctype/lms_lesson_note/lms_lesson_note.json
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "hash",
|
||||||
|
"creation": "2025-08-04 13:17:19.497483",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"lesson",
|
||||||
|
"course",
|
||||||
|
"column_break_qgrb",
|
||||||
|
"member",
|
||||||
|
"color",
|
||||||
|
"section_break_smzm",
|
||||||
|
"highlighted_text",
|
||||||
|
"column_break_zvrs",
|
||||||
|
"note"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "lesson",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Lesson",
|
||||||
|
"options": "Course Lesson",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "lesson.course",
|
||||||
|
"fieldname": "course",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Course",
|
||||||
|
"options": "LMS Course"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_qgrb",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "member",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Member",
|
||||||
|
"options": "User",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "color",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Color",
|
||||||
|
"options": "Red\nBlue\nGreen\nYellow\nPurple",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_smzm",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "highlighted_text",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"label": "Highlighted Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_zvrs",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "note",
|
||||||
|
"fieldtype": "Text Editor",
|
||||||
|
"label": "Note"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-08-05 19:08:47.858172",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "LMS",
|
||||||
|
"name": "LMS Lesson Note",
|
||||||
|
"naming_rule": "Random",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"if_owner": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Course Creator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "member"
|
||||||
|
}
|
||||||
9
lms/lms/doctype/lms_lesson_note/lms_lesson_note.py
Normal file
9
lms/lms/doctype/lms_lesson_note/lms_lesson_note.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class LMSLessonNote(Document):
|
||||||
|
pass
|
||||||
21
lms/lms/doctype/lms_lesson_note/test_lms_lesson_note.py
Normal file
21
lms/lms/doctype/lms_lesson_note/test_lms_lesson_note.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestLMSLessonNote(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for LMSLessonNote.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user