diff --git a/frontend/package.json b/frontend/package.json index f8bffc9a..6489fd75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "@editorjs/checklist": "^1.6.0", + "@editorjs/code": "^2.9.0", "@editorjs/editorjs": "^2.29.0", "@editorjs/embed": "^2.7.0", "@editorjs/header": "^2.8.1", "@editorjs/image": "^2.9.0", + "@editorjs/inline-code": "^1.5.0", "@editorjs/nested-list": "^1.4.2", "@editorjs/paragraph": "^2.11.3", "chart.js": "^4.4.1", @@ -25,7 +27,7 @@ "pinia": "^2.0.33", "socket.io-client": "^4.7.2", "tailwindcss": "^3.3.3", - "vue": "^3.2.25", + "vue": "^3.4.23", "vue-chartjs": "^5.3.0", "vue-router": "^4.0.12" }, diff --git a/frontend/public/Youtube.mov b/frontend/public/Youtube.mov new file mode 100644 index 00000000..ca6b75a2 Binary files /dev/null and b/frontend/public/Youtube.mov differ diff --git a/frontend/src/components/BatchCourses.vue b/frontend/src/components/BatchCourses.vue index 474e7dab..241c5ae9 100644 --- a/frontend/src/components/BatchCourses.vue +++ b/frontend/src/components/BatchCourses.vue @@ -108,13 +108,16 @@ const getCoursesColumns = () => { { label: 'Title', key: 'title', + width: 2, }, { label: 'Lessons', key: 'lesson_count', + align: 'right', }, { label: 'Enrollments', + align: 'right', key: 'enrollment_count', }, ] @@ -131,7 +134,6 @@ const removeCourse = createResource({ }) const removeCourses = (selections) => { - console.log(selections) selections.forEach(async (course) => { removeCourse.submit({ course }) await setTimeout(1000) diff --git a/frontend/src/components/BatchStudents.vue b/frontend/src/components/BatchStudents.vue index 0144925e..ca3271fc 100644 --- a/frontend/src/components/BatchStudents.vue +++ b/frontend/src/components/BatchStudents.vue @@ -109,6 +109,7 @@ const getStudentColumns = () => { { label: 'Full Name', key: 'full_name', + width: 2, }, { label: 'Courses Done', diff --git a/frontend/src/components/CourseCard.vue b/frontend/src/components/CourseCard.vue index a81a3803..1c305b9f 100644 --- a/frontend/src/components/CourseCard.vue +++ b/frontend/src/components/CourseCard.vue @@ -10,7 +10,13 @@ :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }" >
- + {{ tag }}
@@ -89,17 +95,7 @@ :user="instructor" /> - - {{ course.instructors[0].full_name }} - - - {{ course.instructors[0].first_name }} and - {{ course.instructors[1].first_name }} - - - {{ course.instructors[0].first_name }} and - {{ course.instructors.length - 1 }} others - +
@@ -114,6 +110,7 @@ import { BookOpen, Users, Star } from 'lucide-vue-next' import UserAvatar from '@/components/UserAvatar.vue' import { sessionStore } from '@/stores/session' import { Badge, Tooltip } from 'frappe-ui' +import CourseInstructors from '@/components/CourseInstructors.vue' const { user } = sessionStore() diff --git a/frontend/src/components/CourseInstructors.vue b/frontend/src/components/CourseInstructors.vue new file mode 100644 index 00000000..feaba8f8 --- /dev/null +++ b/frontend/src/components/CourseInstructors.vue @@ -0,0 +1,50 @@ + + diff --git a/frontend/src/components/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index 3cf37fff..598d694a 100644 --- a/frontend/src/components/CourseOutline.vue +++ b/frontend/src/components/CourseOutline.vue @@ -25,7 +25,7 @@ :key="chapter.name" :defaultOpen="openChapterDetail(chapter.idx)" > - + +
- + + +
- - {{ review.owner_details.full_name }} - + + + {{ review.owner_details.full_name }} + + {{ review.creation }} diff --git a/frontend/src/components/Discussions.vue b/frontend/src/components/Discussions.vue index 84a48422..4b5f56d1 100644 --- a/frontend/src/components/Discussions.vue +++ b/frontend/src/components/Discussions.vue @@ -40,13 +40,16 @@
-
- +
+
-
+
{{ __(emptyStateTitle) }}
-
+
{{ __(emptyStateText) }}
@@ -89,7 +92,7 @@ const props = defineProps({ }, emptyStateTitle: { type: String, - default: 'No topics yet', + default: '', }, emptyStateText: { type: String, diff --git a/frontend/src/components/LessonPlugins.vue b/frontend/src/components/LessonPlugins.vue index a7d83112..3f9cb146 100644 --- a/frontend/src/components/LessonPlugins.vue +++ b/frontend/src/components/LessonPlugins.vue @@ -68,13 +68,34 @@
+
+
+ {{ + __( + 'To add a YouTube video, paste the URL of the video in the editor.' + ) + }} +
+ + + +
diff --git a/frontend/src/pages/CourseDetail.vue b/frontend/src/pages/CourseDetail.vue index 18efc13b..89f97f4a 100644 --- a/frontend/src/pages/CourseDetail.vue +++ b/frontend/src/pages/CourseDetail.vue @@ -51,17 +51,7 @@ :user="instructor" /> - - {{ course.data.instructors[0].full_name }} - - - {{ course.data.instructors[0].first_name }} and - {{ course.data.instructors[1].first_name }} - - - {{ course.data.instructors[0].first_name }} and - {{ course.data.instructors.length - 1 }} others - +
@@ -87,7 +77,6 @@ />
-
+
{ - if (!user.data?.is_moderator || !user.data?.is_instructor) { + if (!user.data?.is_moderator && !user.data?.is_instructor) { window.location.href = '/login' } editor.value = renderEditor('content') @@ -440,10 +440,100 @@ const breadcrumbs = computed(() => { } .ce-toolbar__actions { - right: 108%; + right: 108% !important; } .ce-block__content { max-width: none; } + +.codeBoxHolder { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +} + +.codeBoxTextArea { + width: 100%; + min-height: 30px; + padding: 10px; + border-radius: 2px 2px 2px 0; + border: none !important; + outline: none !important; + font: 14px monospace; +} + +.codeBoxSelectDiv { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + position: relative; +} + +.codeBoxSelectInput { + border-radius: 0 0 20px 2px; + padding: 2px 26px; + padding-top: 0; + padding-right: 0; + text-align: left; + cursor: pointer; + border: none !important; + outline: none !important; +} + +.codeBoxSelectDropIcon { + position: absolute !important; + left: 10px !important; + bottom: 0 !important; + width: unset !important; + height: unset !important; + font-size: 16px !important; +} + +.codeBoxSelectPreview { + display: none; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + border-radius: 2px; + box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13); + position: absolute; + top: 100%; + margin: 5px 0; + max-height: 30vh; + overflow-x: hidden; + overflow-y: auto; + z-index: 10000; +} + +.codeBoxSelectItem { + width: 100%; + padding: 5px 20px; + margin: 0; + cursor: pointer; +} + +.codeBoxSelectItem:hover { + opacity: 0.7; +} + +.codeBoxSelectedItem { + background-color: lightblue !important; +} + +.codeBoxShow { + display: flex !important; +} + +.dark { + color: #abb2bf; + background-color: #282c34; +} + +.light { + color: #383a42; + background-color: #fafafa; +} diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index a931b02f..c99185b4 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -5,10 +5,10 @@ >
-
+

{{ @@ -25,7 +25,7 @@

-
+
{{ lesson.data.title }} @@ -101,17 +101,7 @@ :user="instructor" /> - - {{ lesson.data.instructors[0].full_name }} - - - {{ lesson.data.instructors[0].first_name }} and - {{ lesson.data.instructors[1].first_name }} - - - {{ lesson.data.instructors[0].first_name }} and - {{ lesson.data.instructors.length - 1 }} others - +
-
+
{{ lesson.data.course_title }}
@@ -196,6 +186,7 @@ import Discussions from '@/components/Discussions.vue' import { getEditorTools } from '../utils' import EditorJS from '@editorjs/editorjs' import LessonContent from '@/components/LessonContent.vue' +import CourseInstructors from '@/components/CourseInstructors.vue' const user = inject('$user') const route = useRoute() @@ -246,7 +237,6 @@ const lesson = createResource({ const renderEditor = (holder, content) => { // empty the holder - document.getElementById(holder).innerHTML = '' return new EditorJS({ holder: holder, tools: getEditorTools(), @@ -396,4 +386,98 @@ const allowInstructorContent = () => { .embed-tool__caption { display: none; } + +.ce-block__content { + max-width: unset; +} + +.codeBoxHolder { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +} + +.codeBoxTextArea { + width: 100%; + min-height: 30px; + padding: 10px; + border-radius: 2px 2px 2px 0; + border: none !important; + outline: none !important; + font: 14px monospace; +} + +.codeBoxSelectDiv { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + position: relative; +} + +.codeBoxSelectInput { + border-radius: 0 0 20px 2px; + padding: 2px 26px; + padding-top: 0; + padding-right: 0; + text-align: left; + cursor: pointer; + border: none !important; + outline: none !important; +} + +.codeBoxSelectDropIcon { + position: absolute !important; + left: 10px !important; + bottom: 0 !important; + width: unset !important; + height: unset !important; + font-size: 16px !important; +} + +.codeBoxSelectPreview { + display: none; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + border-radius: 2px; + box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13); + position: absolute; + top: 100%; + margin: 5px 0; + max-height: 30vh; + overflow-x: hidden; + overflow-y: auto; + z-index: 10000; +} + +.codeBoxSelectItem { + width: 100%; + padding: 5px 20px; + margin: 0; + cursor: pointer; +} + +.codeBoxSelectItem:hover { + opacity: 0.7; +} + +.codeBoxSelectedItem { + background-color: lightblue !important; +} + +.codeBoxShow { + display: flex !important; +} + +.dark { + color: #abb2bf; + background-color: #282c34; +} + +.light { + color: #383a42; + background-color: #fafafa; +} diff --git a/frontend/src/utils/code.ts b/frontend/src/utils/code.ts new file mode 100644 index 00000000..a8760d1b --- /dev/null +++ b/frontend/src/utils/code.ts @@ -0,0 +1,218 @@ +import { Code } from "lucide-vue-next" +import { h, createApp } from "vue" + +const DEFAULT_THEMES = ['light', 'dark']; +const COMMON_LANGUAGES = { + none: 'Auto-detect', apache: 'Apache', bash: 'Bash', cs: 'C#', cpp: 'C++', css: 'CSS', coffeescript: 'CoffeeScript', diff: 'Diff', + go: 'Go', html: 'HTML, XML', http: 'HTTP', json: 'JSON', java: 'Java', javascript: 'JavaScript', kotlin: 'Kotlin', + less: 'Less', lua: 'Lua', makefile: 'Makefile', markdown: 'Markdown', nginx: 'Nginx', objectivec: 'Objective-C', + php: 'PHP', perl: 'Perl', properties: 'Properties', python: 'Python', ruby: 'Ruby', rust: 'Rust', scss: 'SCSS', + sql: 'SQL', shell: 'Shell Session', swift: 'Swift', toml: 'TOML, also INI', typescript: 'TypeScript', yaml: 'YAML', + plaintext: 'Plaintext' +}; + +export class CodeBox { + api: any; + config: { themeName: any; themeURL: any; useDefaultTheme: any; }; + readOnly: boolean; + data: { code: any; language: any; theme: any; }; + highlightScriptID: string; + highlightCSSID: string; + codeArea: HTMLDivElement; + selectInput: HTMLInputElement; + selectDropIcon: HTMLElement; + + constructor({ data, api, config, readOnly }) { + this.api = api; + this.readOnly = readOnly; + this.config = { + themeName: config.themeName && typeof config.themeName === 'string' ? config.themeName : '', + themeURL: config.themeURL && typeof config.themeURL === 'string' ? config.themeURL : '', + useDefaultTheme: (config.useDefaultTheme && typeof config.useDefaultTheme === 'string' + && DEFAULT_THEMES.includes(config.useDefaultTheme.toLowerCase())) ? config.useDefaultTheme : 'dark', + }; + this.data = { + code: data.code && typeof data.code === 'string' ? data.code : '', + language: data.language && typeof data.language === 'string' ? data.language : 'Auto-detect', + theme: data.theme && typeof data.theme === 'string' ? data.theme : this._getThemeURLFromConfig(), + }; + this.highlightScriptID = 'highlightJSScriptElement'; + this.highlightCSSID = 'highlightJSCSSElement'; + this.codeArea = document.createElement('div'); + this.selectInput = document.createElement('input'); + this.selectDropIcon = document.createElement('i'); + + this._injectHighlightJSScriptElement(); + this._injectHighlightJSCSSElement(); + + this.api.listeners.on(window, 'click', this._closeAllLanguageSelects, true); + } + + static get isReadOnlySupported() { + return true + } + + static get sanitize() { + return { + code: true, + language: false, + theme: false, + } + } + + static get toolbox() { + const app = createApp({ + render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }), + }); + + const div = document.createElement('div'); + app.mount(div); + + return { + title: 'CodeBox', + icon: div.innerHTML + }; + } + + static get displayInToolbox() { + return true; + } + + static get enableLineBreaks() { + return true; + } + + render() { + const codeAreaHolder = document.createElement('pre'); + const languageSelect = this._createLanguageSelectElement(); + + codeAreaHolder.setAttribute('class', 'codeBoxHolder'); + this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`); + this.codeArea.setAttribute('contenteditable', 'true'); + this.codeArea.innerHTML = this.data.code; + this.api.listeners.on(this.codeArea, 'blur', event => this._highlightCodeArea(event), false); + this.api.listeners.on(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false); + + codeAreaHolder.appendChild(this.codeArea); + !this.readOnly && codeAreaHolder.appendChild(languageSelect); + + return codeAreaHolder; + } + + save(blockContent) { + return Object.assign(this.data, { code: this.codeArea.innerHTML, theme: this._getThemeURLFromConfig() }); + } + + validate(savedData) { + if (!savedData.code.trim()) return false; + return true; + } + + destroy() { + this.api.listeners.off(window, 'click', this._closeAllLanguageSelects, true); + this.api.listeners.off(this.codeArea, 'blur', event => this._highlightCodeArea(event), false); + this.api.listeners.off(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false); + this.api.listeners.off(this.selectInput, 'click', event => this._handleSelectInputClick(event), false); + } + + _createLanguageSelectElement() { + const selectHolder = document.createElement('div'); + const selectPreview = document.createElement('div'); + const languages = Object.entries(COMMON_LANGUAGES); + + selectHolder.setAttribute('class', 'codeBoxSelectDiv'); + + this.selectDropIcon.setAttribute('class', `codeBoxSelectDropIcon ${this.config.useDefaultTheme}`); + this.selectDropIcon.innerHTML = '↓'; + this.selectInput.setAttribute('class', `codeBoxSelectInput ${this.config.useDefaultTheme}`); + this.selectInput.setAttribute('type', 'text'); + this.selectInput.setAttribute('readonly', 'true'); + this.selectInput.value = this.data.language; + this.api.listeners.on(this.selectInput, 'click', event => this._handleSelectInputClick(event), false); + + selectPreview.setAttribute('class', 'codeBoxSelectPreview'); + + languages.forEach(language => { + const selectItem = document.createElement('p'); + selectItem.setAttribute('class', `codeBoxSelectItem ${this.config.useDefaultTheme}`); + selectItem.setAttribute('data-key', language[0]); + selectItem.textContent = language[1]; + this.api.listeners.on(selectItem, 'click', event => this._handleSelectItemClick(event, language), false); + + selectPreview.appendChild(selectItem); + }); + + selectHolder.appendChild(this.selectDropIcon); + selectHolder.appendChild(this.selectInput); + selectHolder.appendChild(selectPreview); + + return selectHolder; + } + + _highlightCodeArea(event) { + window.hljs.highlightBlock(this.codeArea); + } + + _handleCodeAreaPaste(event) { + event.stopPropagation(); + } + + _handleSelectInputClick(event) { + event.target.nextSibling.classList.toggle('codeBoxShow'); + } + + _handleSelectItemClick(event, language) { + event.target.parentNode.parentNode.querySelector('.codeBoxSelectInput').value = language[1]; + event.target.parentNode.classList.remove('codeBoxShow'); + this.codeArea.removeAttribute('class'); + this.data.language = language[0]; + this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`); + window.hljs.highlightBlock(this.codeArea); + } + + _closeAllLanguageSelects() { + const selectPreviews = document.querySelectorAll('.codeBoxSelectPreview'); + for (let i = 0, len = selectPreviews.length; i < len; i++) selectPreviews[i].classList.remove('codeBoxShow'); + } + + _injectHighlightJSScriptElement() { + const highlightJSScriptElement = document.querySelector(`#${this.highlightScriptID}`); + const highlightJSScriptURL = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/highlight.min.js'; + if (!highlightJSScriptElement) { + const script = document.createElement('script'); + const head = document.querySelector('head'); + script.setAttribute('src', highlightJSScriptURL); + script.setAttribute('id', this.highlightScriptID); + + if (head) head.appendChild(script); + } + else highlightJSScriptElement.setAttribute('src', highlightJSScriptURL); + } + + _injectHighlightJSCSSElement() { + const highlightJSCSSElement = document.querySelector(`#${this.highlightCSSID}`); + let highlightJSCSSURL = this._getThemeURLFromConfig(); + if (!highlightJSCSSElement) { + const link = document.createElement('link'); + const head = document.querySelector('head'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('href', highlightJSCSSURL); + link.setAttribute('id', this.highlightCSSID); + + if (head) head.appendChild(link); + } + else highlightJSCSSElement.setAttribute('href', highlightJSCSSURL); + } + + _getThemeURLFromConfig() { + let themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-${this.config.useDefaultTheme}.min.css`; + + if (this.config.themeName) themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/${this.config.themeName}.min.css`; + if (this.config.themeURL) themeURL = this.config.themeURL; + + return themeURL; + } +} + + +export default CodeBox; \ No newline at end of file diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 8d328dd9..a0f4cf27 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -6,7 +6,9 @@ import { Upload } from '@/utils/upload' import Header from '@editorjs/header' import Paragraph from '@editorjs/paragraph' import Embed from '@editorjs/embed' +import { CodeBox } from '@/utils/code' import NestedList from '@editorjs/nested-list' +import InlineCode from '@editorjs/inline-code' import { watch } from 'vue' import dayjs from '@/utils/dayjs' @@ -130,12 +132,25 @@ export function getEditorTools() { class: Paragraph, inlineToolbar: true, }, + codeBox: { + class: CodeBox, + config: { + themeURL: + 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional + themeName: 'atom-one-dark', // Optional + useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down + }, + }, list: { class: NestedList, config: { defaultStyle: 'ordered', }, }, + inlineCode: { + class: InlineCode, + shortcut: 'CMD+SHIFT+M', + }, embed: { class: Embed, inlineToolbar: false, @@ -336,3 +351,19 @@ export function getFormattedDateRange( format )}` } + +export function getLineStartPosition(string, position) { + const charLength = 1 + let char = '' + + while (char !== '\n' && position > 0) { + position = position - charLength + char = string.substr(position, charLength) + } + + if (char === '\n') { + position += 1 + } + + return position +} diff --git a/frontend/src/utils/upload.js b/frontend/src/utils/upload.js index 401c6319..61c4a2a0 100644 --- a/frontend/src/utils/upload.js +++ b/frontend/src/utils/upload.js @@ -16,7 +16,7 @@ export class Upload { renderUpload(file) { if (this.isVideo(file.file_type)) { - return `