Compare commits
1 Commits
v2.0.2
...
copy-minor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bf4e2c58 |
40
.github/helper/update_pot_file.sh
vendored
40
.github/helper/update_pot_file.sh
vendored
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd ~ || exit
|
||||
|
||||
echo "Setting Up Bench..."
|
||||
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
||||
cd ./frappe-bench || exit
|
||||
|
||||
echo "Get LMS..."
|
||||
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "Generating POT file..."
|
||||
bench generate-pot-file --app lms
|
||||
|
||||
cd ./apps/lms || exit
|
||||
|
||||
echo "Configuring git user..."
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
|
||||
echo "Setting the correct git remote..."
|
||||
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
|
||||
git remote add upstream https://github.com/frappe/lms.git
|
||||
|
||||
echo "Creating a new branch..."
|
||||
isodate=$(date -u +"%Y-%m-%d")
|
||||
branch_name="pot_${BASE_BRANCH}_${isodate}"
|
||||
git checkout -b "${branch_name}"
|
||||
|
||||
echo "Commiting changes..."
|
||||
git add lms/locale/main.pot
|
||||
git commit -m "chore: update POT file"
|
||||
|
||||
gh auth setup-git
|
||||
git push -u upstream "${branch_name}"
|
||||
|
||||
echo "Creating a PR..."
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms
|
||||
34
.github/workflows/generate-pot-file.yml
vendored
34
.github/workflows/generate-pot-file.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: Regenerate POT file (translatable strings)
|
||||
on:
|
||||
schedule:
|
||||
- cron: "00 16 * * 5"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
regeneratee-pot-file:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
BASE_BRANCH: ${{ matrix.branch }}
|
||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -30,4 +30,4 @@ jobs:
|
||||
run: pip install semgrep
|
||||
|
||||
- name: Run Semgrep rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,5 +11,4 @@ __pycache__/
|
||||
node_modules
|
||||
package-lock.json
|
||||
lms/public/frontend
|
||||
lms/www/lms.html
|
||||
frappe-ui
|
||||
lms/www/lms.html
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "frappe-ui"]
|
||||
path = frappe-ui
|
||||
url = https://github.com/pateljannat/frappe-ui
|
||||
@@ -75,13 +75,7 @@ cd apps/lms/docker
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should appear.
|
||||
You'll have to go through the setup wizard to set up the website the first time you access it. Log in using the following credentials to complete the setup wizard.
|
||||
|
||||
```
|
||||
Username: Administrator
|
||||
password: admin
|
||||
```
|
||||
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should show up.
|
||||
|
||||
### Frappe Bench
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
files:
|
||||
- source: /lms/locale/main.pot
|
||||
translation: /lms/locale/%two_letters_code%.po
|
||||
pull_request_title: "chore: sync translations from crowdin"
|
||||
pull_request_labels:
|
||||
- translation
|
||||
commit_message: "chore: %language% translations"
|
||||
append_commit_message: false
|
||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
||||
openMode: 0,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://test_site_ui:8000",
|
||||
baseUrl: "http://pyp:8000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,156 +1,133 @@
|
||||
describe("Course Creation", () => {
|
||||
it("creates a new course", () => {
|
||||
cy.login();
|
||||
cy.wait(1000);
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
cy.visit("/courses");
|
||||
// Create a course
|
||||
cy.get("a").contains("New Course").click();
|
||||
cy.get("a.btn").contains("Create a Course").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
.contains("Short Introduction")
|
||||
.type("Test Course Short Introduction to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent,
|
||||
fileName: "profile.png",
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get(".search-input").click().type("frappe");
|
||||
cy.url().should("include", "/courses/new-course/edit");
|
||||
cy.get("#title").type("Test Course");
|
||||
cy.get("#intro").type("Test Course Short Introduction");
|
||||
cy.get("#description").type("Test Course Description");
|
||||
cy.get("#video-link").type("-LPmw2Znl2c");
|
||||
cy.get("#tags-input").type("Test");
|
||||
cy.get("#published").check();
|
||||
cy.wait(1000);
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.button("Add Chapter").click();
|
||||
cy.link("Course Outline").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get("[id^=headlessui-dialog-panel-")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
cy.button("Add Chapter").click();
|
||||
});
|
||||
cy.get(".edit-header .btn-add-chapter").click();
|
||||
cy.wait(500);
|
||||
cy.get("#chapter-title").type("Test Chapter");
|
||||
cy.get("#chapter-description").type("Test Chapter Description");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Lesson
|
||||
cy.wait(1000);
|
||||
cy.button("Add Lesson").click();
|
||||
cy.link("Add Lesson").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/learn/1-1/edit");
|
||||
cy.get("#lesson-title").type("Test Lesson");
|
||||
|
||||
// Content
|
||||
cy.get(".collapse-section.collapsed:first").click();
|
||||
cy.get("#lesson-content .ce-block")
|
||||
.click()
|
||||
.type(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
|
||||
);
|
||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
||||
cy.button("Insert").click();
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
/* cy.get("#content .ce-block")
|
||||
.click()
|
||||
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||
/* cy.get("#content .ce-block")
|
||||
.click()
|
||||
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||
|
||||
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent,
|
||||
fileName: "Youtube.mov",
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
cy.get("#content .ce-block").type(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
cy.button("Save").click();
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.visit("/lms");
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get(".grid a:first").within(() => {
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".course-image")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get(".grid a:first").click();
|
||||
cy.url().should("include", "/lms/courses/test-course");
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||
cy.get("div").contains("Learning");
|
||||
cy.get("div").contains("Frappe");
|
||||
cy.get("div").contains("ERPNext");
|
||||
cy.get("iframe").should(
|
||||
cy.visit("/courses");
|
||||
cy.get(".course-card-title:first").contains("Test Course");
|
||||
cy.get(".course-card:first").click();
|
||||
cy.url().should("include", "/courses/test-course");
|
||||
cy.get("#title").contains("Test Course");
|
||||
cy.get(".preview-video").should(
|
||||
"have.attr",
|
||||
"src",
|
||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||
);
|
||||
cy.get("#intro").contains("Test Course Short Introduction");
|
||||
|
||||
// View Chapter
|
||||
cy.get("div").contains("Test Chapter");
|
||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||
cy.get("div").contains("Test Lesson").click();
|
||||
});
|
||||
cy.wait(3000);
|
||||
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
||||
cy.get(".chapter-description:first").contains(
|
||||
"Test Chapter Description"
|
||||
);
|
||||
cy.get(".lesson-info:first").contains("Test Lesson");
|
||||
cy.get(".lesson-info:first").click();
|
||||
|
||||
// View Lesson
|
||||
cy.url().should("include", "/learn/1-1");
|
||||
cy.get("div").contains("Test Lesson");
|
||||
|
||||
cy.get("video")
|
||||
.should("be.visible")
|
||||
.children("source")
|
||||
.invoke("attr", "src")
|
||||
.should("include", "/files/Youtube");
|
||||
|
||||
cy.get("div").contains(
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "learn/1.1");
|
||||
cy.get("#title").contains("Test Lesson");
|
||||
cy.get(".lesson-video iframe").should(
|
||||
"have.attr",
|
||||
"src",
|
||||
"https://www.youtube.com/embed/GoDtyItReto"
|
||||
);
|
||||
cy.get(".lesson-content-card").contains(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
// Add Discussion
|
||||
cy.button("New Question").click();
|
||||
cy.get(".reply").click();
|
||||
cy.wait(500);
|
||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||
cy.get("label").contains("Title").type("Test Discussion");
|
||||
cy.get("div[contenteditable=true]").invoke(
|
||||
"text",
|
||||
"This is a test discussion. This will check if the UI is working properly."
|
||||
cy.get(".discussion-modal").should("be.visible");
|
||||
|
||||
// Enter title
|
||||
cy.get(".modal .topic-title")
|
||||
.type("Discussion from tests")
|
||||
.should("have.value", "Discussion from tests");
|
||||
|
||||
// Enter comment
|
||||
cy.get(".modal .discussions-comment").type(
|
||||
"This is a discussion from the cypress ui tests."
|
||||
);
|
||||
|
||||
// Submit
|
||||
cy.get(".modal .submit-discussion").click();
|
||||
cy.wait(2000);
|
||||
|
||||
// Check if discussion is added to page and content is visible
|
||||
cy.get(".sidebar-parent:first .discussion-topic-title").should(
|
||||
"have.text",
|
||||
"Discussion from tests"
|
||||
);
|
||||
cy.get(".sidebar-parent:first .discussion-topic-title").click();
|
||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||
cy.get(
|
||||
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
|
||||
).should(
|
||||
"have.text",
|
||||
"This is a discussion from the cypress ui tests."
|
||||
);
|
||||
|
||||
cy.get(".discussion-form:visible .discussions-comment").type(
|
||||
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
|
||||
);
|
||||
|
||||
cy.get(".discussion-form:visible .submit-discussion").click();
|
||||
cy.wait(3000);
|
||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||
cy.get(".discussion-on-page:visible")
|
||||
.children(".reply-card")
|
||||
.eq(1)
|
||||
.find(".reply-text")
|
||||
.should(
|
||||
"have.text",
|
||||
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
|
||||
);
|
||||
cy.button("Post").click();
|
||||
});
|
||||
|
||||
// View Discussion
|
||||
cy.wait(500);
|
||||
cy.get("div").contains("Test Discussion").click();
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
|
||||
cy.get("div").contains(
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -24,8 +24,6 @@
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
|
||||
import "cypress-file-upload";
|
||||
|
||||
Cypress.Commands.add("login", (email, password) => {
|
||||
if (!email) {
|
||||
email = Cypress.config("testUser") || "Administrator";
|
||||
@@ -55,13 +53,3 @@ Cypress.Commands.add("iconButton", (text) => {
|
||||
Cypress.Commands.add("dialog", (selector) => {
|
||||
return cy.get(`[role=dialog] ${selector}`);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||
cy.wrap(subject).then(($element) => {
|
||||
const element = $element[0];
|
||||
element.focus();
|
||||
element.textContent = text;
|
||||
const event = new Event("paste", { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
|
||||
Submodule frappe-ui updated: a349ab070a...c5faaae38e
@@ -10,28 +10,24 @@
|
||||
},
|
||||
"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/inline-code": "^1.5.0",
|
||||
"@editorjs/image": "^2.9.0",
|
||||
"@editorjs/nested-list": "^1.4.2",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"chart.js": "^4.4.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.56",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"frappe-ui": "^0.1.50",
|
||||
"lucide-vue-next": "^0.309.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vue": "^3.4.23",
|
||||
"vue": "^3.2.25",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"vue-router": "^4.0.12",
|
||||
"vuedraggable": "4.1.0"
|
||||
"vue-router": "^4.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
|
||||
Binary file not shown.
@@ -8,59 +8,14 @@
|
||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<SidebarLink
|
||||
v-for="link in sidebarLinks"
|
||||
v-for="link in links"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||
class="mt-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
@click="showWebPages = !showWebPages"
|
||||
>
|
||||
<div
|
||||
v-if="!isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-gray-600 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<ChevronRight
|
||||
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': showWebPages }"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
{{ __('More') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
||||
<template #icon>
|
||||
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length"
|
||||
class="flex flex-col transition-all duration-300 ease-in-out"
|
||||
:class="showWebPages ? 'block' : 'hidden'"
|
||||
>
|
||||
<SidebarLink
|
||||
v-for="link in sidebarSettings.data.web_pages"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
:showControls="isModerator ? true : false"
|
||||
@openModal="openPageModal"
|
||||
@deletePage="deletePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarLink
|
||||
:link="{
|
||||
@@ -80,11 +35,6 @@
|
||||
</template>
|
||||
</SidebarLink>
|
||||
</div>
|
||||
<PageModal
|
||||
v-model="showPageModal"
|
||||
v-model:reloadSidebar="sidebarSettings"
|
||||
:page="pageToEdit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -92,114 +42,14 @@ import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { ref, onMounted, inject, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { ChevronRight, Plus } from 'lucide-vue-next'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import PageModal from '@/components/Modals/PageModal.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
const { userResource } = usersStore()
|
||||
const socket = inject('$socket')
|
||||
const unreadCount = ref(0)
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const showPageModal = ref(false)
|
||||
const isModerator = ref(false)
|
||||
const pageToEdit = ref(null)
|
||||
const showWebPages = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unreadNotifications.reload()
|
||||
})
|
||||
addNotifications()
|
||||
})
|
||||
|
||||
const unreadNotifications = createResource({
|
||||
cache: 'Unread Notifications Count',
|
||||
url: 'frappe.client.get_count',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Notification Log',
|
||||
filters: {
|
||||
for_user: user,
|
||||
read: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
unreadCount.value = data
|
||||
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
||||
if (link.label === 'Notifications') {
|
||||
link.count = data
|
||||
}
|
||||
return link
|
||||
})
|
||||
},
|
||||
auto: user ? true : false,
|
||||
})
|
||||
|
||||
const addNotifications = () => {
|
||||
if (user) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
activeFor: ['Notifications'],
|
||||
count: unreadCount.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarSettings = createResource({
|
||||
url: 'lms.lms.api.get_sidebar_settings',
|
||||
cache: 'Sidebar Settings',
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!parseInt(data[key])) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const openPageModal = (link) => {
|
||||
showPageModal.value = true
|
||||
pageToEdit.value = link
|
||||
}
|
||||
|
||||
const deletePage = (link) => {
|
||||
createResource({
|
||||
url: 'lms.lms.api.delete_sidebar_item',
|
||||
makeParams(values) {
|
||||
return {
|
||||
webpage: link.web_page,
|
||||
}
|
||||
},
|
||||
}).submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
sidebarSettings.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
const links = getSidebarLinks()
|
||||
|
||||
const getSidebarFromStorage = () => {
|
||||
return useStorage('sidebar_is_collapsed', false)
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
}
|
||||
})
|
||||
|
||||
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||
</script>
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- <audio width="100%" controls controlsList="nodownload" class="mb-4">
|
||||
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||
</audio> -->
|
||||
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
|
||||
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||
</audio>
|
||||
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
|
||||
<Button variant="ghost" @click="togglePlay">
|
||||
<template #icon>
|
||||
<Play v-if="!isPlaying" class="w-4 h-4 text-gray-900" />
|
||||
<Pause v-else class="w-4 h-4 text-gray-900" />
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
step="0.1"
|
||||
v-model="currentTime"
|
||||
@input="changeCurrentTime"
|
||||
class="duration-slider w-full h-1"
|
||||
/>
|
||||
<span class="text-xs text-gray-900 font-medium">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</span>
|
||||
<Button variant="ghost" @click="toggleMute">
|
||||
<template #icon>
|
||||
<Volume2 v-if="!isMuted" class="w-4 h-4 text-gray-900" />
|
||||
<VolumeX v-else class="w-4 h-4 text-gray-900" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const audio = ref(null)
|
||||
let isMuted = ref(false)
|
||||
let currentTime = ref(0)
|
||||
let duration = ref(0)
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
audio.value = document.querySelector('audio')
|
||||
console.log(audio.value)
|
||||
audio.value.onloadedmetadata = () => {
|
||||
duration.value = audio.value.duration
|
||||
}
|
||||
audio.value.ontimeupdate = () => {
|
||||
currentTime.value = audio.value.currentTime
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const togglePlay = () => {
|
||||
if (audio.value.paused) {
|
||||
audio.value.play()
|
||||
isPlaying.value = true
|
||||
} else {
|
||||
audio.value.pause()
|
||||
isPlaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
audio.value.muted = !audio.value.muted
|
||||
isMuted.value = audio.value.muted
|
||||
}
|
||||
|
||||
const changeCurrentTime = () => {
|
||||
audio.value.currentTime = currentTime.value
|
||||
}
|
||||
|
||||
const handleAudioEnd = () => {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||
}
|
||||
|
||||
watch(isPlaying, (newVal) => {
|
||||
if (newVal) {
|
||||
audio.value.play()
|
||||
} else {
|
||||
audio.value.pause()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.duration-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: theme('colors.gray.400');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duration-slider::-webkit-slider-thumb {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
-webkit-appearance: none;
|
||||
background-color: theme('colors.gray.900');
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
input[type='range'] {
|
||||
overflow: hidden;
|
||||
width: 150px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
box-shadow: -150px 0 0 150px theme('colors.gray.900');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
|
||||
class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
|
||||
style="min-height: 150px"
|
||||
>
|
||||
<div class="text-xl font-semibold mb-2">
|
||||
{{ batch.title }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="batch.seat_count && batch.seats_left > 0"
|
||||
theme="green"
|
||||
class="self-start mb-2"
|
||||
>
|
||||
{{ batch.seats_left }}
|
||||
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
|
||||
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
|
||||
{{ batch.seats_left }} {{ __('Seat Left') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||
@@ -22,57 +17,43 @@
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</Badge>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ batch.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ batch.description }}
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2 mt-auto">
|
||||
<div v-if="batch.amount" class="font-semibold text-lg">
|
||||
<div class="mt-auto">
|
||||
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
|
||||
{{ batch.price }}
|
||||
</div>
|
||||
|
||||
<DateRange
|
||||
:startDate="batch.start_date"
|
||||
:endDate="batch.end_date"
|
||||
class="text-sm text-gray-700 mb-3"
|
||||
/>
|
||||
<div class="flex items-center text-sm text-gray-700">
|
||||
<div class="flex items-center mb-3">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ dayjs(batch.start_date).format('DD MMM YYYY') }} -
|
||||
{{ dayjs(batch.end_date).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.timezone"
|
||||
class="flex items-center text-sm text-gray-700"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
|
||||
<span>
|
||||
{{ batch.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.instructors?.length" class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
|
||||
import { inject } from 'vue'
|
||||
import { Badge } from 'frappe-ui'
|
||||
import { formatTime } from '../utils'
|
||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
@@ -91,17 +72,4 @@ const props = defineProps({
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,14 +19,8 @@
|
||||
<ListView
|
||||
:columns="getCoursesColumns()"
|
||||
:rows="courses.data"
|
||||
row-key="batch_course"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: row.name },
|
||||
}),
|
||||
}"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
@@ -55,10 +49,7 @@
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeCourses(selections, unselectAll)"
|
||||
>
|
||||
<Button variant="ghost" @click="removeCourses(selections)">
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -117,16 +108,13 @@ const getCoursesColumns = () => {
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Lessons',
|
||||
key: 'lesson_count',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
label: 'Enrollments',
|
||||
align: 'right',
|
||||
key: 'enrollment_count',
|
||||
},
|
||||
]
|
||||
@@ -142,13 +130,12 @@ const removeCourse = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const removeCourses = (selections, unselectAll) => {
|
||||
const removeCourses = (selections) => {
|
||||
console.log(selections)
|
||||
selections.forEach(async (course) => {
|
||||
removeCourse.submit({ course })
|
||||
await setTimeout(1000)
|
||||
})
|
||||
setTimeout(() => {
|
||||
courses.reload()
|
||||
unselectAll()
|
||||
}, 1000)
|
||||
courses.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
|
||||
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && seats_left > 0"
|
||||
theme="green"
|
||||
class="self-start mb-2 float-right"
|
||||
>
|
||||
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
|
||||
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
|
||||
{{ seats_left }} {{ __('Seat Left') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||
@@ -22,26 +21,22 @@
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
||||
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.data.timezone" class="flex items-center">
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="isModerator || isStudent"
|
||||
v-if="user?.data?.is_moderator"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
@@ -51,7 +46,7 @@
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<span>
|
||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||
{{ __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
@@ -63,9 +58,9 @@
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="batch.data.paid_batch && batch.data.seats_left"
|
||||
v-else-if="batch.data.paid_batch"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<Button class="w-full mt-4" variant="solid">
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
@@ -74,12 +69,12 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||
v-else-if="batch.data.allow_self_enrollment"
|
||||
>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="isModerator"
|
||||
v-if="user?.data?.is_moderator"
|
||||
:to="{
|
||||
name: 'BatchCreation',
|
||||
params: {
|
||||
@@ -96,12 +91,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||
import { inject, computed } from 'vue'
|
||||
import { Badge, Button } from 'frappe-ui'
|
||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
@@ -117,12 +112,4 @@ const seats_left = computed(() => {
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return props.batch.data?.students?.includes(user.data?.name)
|
||||
})
|
||||
|
||||
const isModerator = computed(() => {
|
||||
return user.data?.is_moderator
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -52,10 +52,7 @@
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeStudents(selections, unselectAll)"
|
||||
>
|
||||
<Button variant="ghost" @click="removeStudents(selections)">
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -112,7 +109,6 @@ const getStudentColumns = () => {
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Courses Done',
|
||||
@@ -145,13 +141,11 @@ const removeStudent = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
const removeStudents = (selections) => {
|
||||
selections.forEach(async (student) => {
|
||||
removeStudent.submit({ student })
|
||||
await setTimeout(1000)
|
||||
})
|
||||
setTimeout(() => {
|
||||
students.reload()
|
||||
unselectAll()
|
||||
}, 500)
|
||||
students.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ getFormattedDateRange(props.startDate, props.endDate) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { getFormattedDateRange } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
startDate: {
|
||||
type: String,
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs text-gray-600">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
@click="openPopover(togglePopover)"
|
||||
class="flex w-full items-center space-x-2 focus:outline-none bg-gray-100 rounded h-7 py-1.5 px-2 hover:bg-gray-200 focus:bg-white border border-gray-100 hover:border-gray-200 focus:border-gray-500"
|
||||
>
|
||||
<component
|
||||
v-if="selectedIcon"
|
||||
class="w-4 h-4 text-gray-700 stroke-1.5"
|
||||
:is="icons[selectedIcon]"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
class="w-4 h-4 text-gray-700 stroke-1.5"
|
||||
:is="icons.Folder"
|
||||
/>
|
||||
<span v-if="selectedIcon">
|
||||
{{ selectedIcon }}
|
||||
</span>
|
||||
<span v-else class="text-gray-600">
|
||||
{{ __('Choose an icon') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #body-main="{ close, isOpen }" class="w-full">
|
||||
<div class="p-3 max-h-56 overflow-auto w-full">
|
||||
<FormControl
|
||||
ref="search"
|
||||
v-model="iconQuery"
|
||||
:placeholder="__('Search for an icon')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="grid grid-cols-10 gap-4 mt-4">
|
||||
<div v-for="(iconComponent, iconName) in filteredIcons">
|
||||
<component
|
||||
:is="iconComponent"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-700 cursor-pointer"
|
||||
@click="setIcon(iconName, close)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, Popover } from 'frappe-ui'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
|
||||
const iconQuery = ref('')
|
||||
const selectedIcon = ref('')
|
||||
const search = ref(null)
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const iconArray = ref(
|
||||
Object.keys(icons)
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, 100)
|
||||
.reduce((result, key) => {
|
||||
result[key] = icons[key]
|
||||
return result
|
||||
}, {})
|
||||
)
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Icon',
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
selectedIcon.value = props.modelValue
|
||||
})
|
||||
|
||||
const setIcon = (icon, close) => {
|
||||
emit('update:modelValue', icon)
|
||||
selectedIcon.value = icon
|
||||
iconQuery.value = ''
|
||||
close()
|
||||
}
|
||||
|
||||
const filteredIcons = computed(() => {
|
||||
if (!iconQuery.value) {
|
||||
return iconArray.value
|
||||
}
|
||||
|
||||
return Object.keys(icons)
|
||||
.filter((icon) =>
|
||||
icon.toLowerCase().includes(iconQuery.value.toLowerCase())
|
||||
)
|
||||
.reduce((result, key) => {
|
||||
result[key] = icons[key]
|
||||
return result
|
||||
}, {})
|
||||
})
|
||||
|
||||
const openPopover = (togglePopover) => {
|
||||
togglePopover()
|
||||
}
|
||||
</script>
|
||||
@@ -1,255 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
class="rounded-md"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
},
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
cache: [text.value, props.doctype],
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
/* transform: (data) => {
|
||||
let allData = data
|
||||
.filter((c) => {
|
||||
return c.description.split(', ')[1]
|
||||
})
|
||||
.map((option) => {
|
||||
let email = option.description.split(', ')[1]
|
||||
return {
|
||||
label: option.label || email,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
return allData
|
||||
}, */
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return filterOptions.data || []
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
filterOptions.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
})
|
||||
filterOptions.reload()
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[props.size || 'sm'],
|
||||
'text-gray-600',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -2,25 +2,15 @@
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
||||
style="min-height: 350px"
|
||||
style="min-height: 320px"
|
||||
>
|
||||
<div
|
||||
class="course-image"
|
||||
:class="{ 'default-image': !course.image }"
|
||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
||||
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
||||
>
|
||||
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
||||
{{ __('Featured') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
theme="gray"
|
||||
size="md"
|
||||
v-for="tag in course.tags"
|
||||
>
|
||||
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
||||
<Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -75,12 +65,15 @@
|
||||
<div class="short-introduction">
|
||||
{{ course.short_introduction }}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
<div
|
||||
v-if="user && course.membership"
|
||||
:progress="course.membership.progress"
|
||||
/>
|
||||
|
||||
class="w-full bg-gray-200 rounded-full h-1 mb-2"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-900 h-1 rounded-full"
|
||||
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||
{{ Math.ceil(course.membership.progress) }}% completed
|
||||
</div>
|
||||
@@ -88,7 +81,7 @@
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
class="mr-1"
|
||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -96,7 +89,17 @@
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
<span v-if="course.instructors.length == 1">
|
||||
{{ course.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.instructors.length == 2">
|
||||
{{ course.instructors[0].first_name }} and
|
||||
{{ course.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.instructors.length > 2">
|
||||
{{ course.instructors[0].first_name }} and
|
||||
{{ course.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold">
|
||||
@@ -111,8 +114,6 @@ 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'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
@@ -149,8 +150,8 @@ const props = defineProps({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: theme('colors.green.100');
|
||||
color: theme('colors.green.600');
|
||||
background-color: theme('colors.orange.100');
|
||||
color: theme('colors.orange.600');
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[0]
|
||||
? course.data.current_lesson.split('.')[0]
|
||||
: 1,
|
||||
lessonNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[1]
|
||||
? course.data.current_lesson.split('.')[1]
|
||||
: 1,
|
||||
},
|
||||
}"
|
||||
@@ -46,12 +46,6 @@
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
@click="enrollStudent()"
|
||||
@@ -63,15 +57,6 @@
|
||||
{{ __('Start Learning') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canGetCertificate"
|
||||
@click="fetchCertificate()"
|
||||
variant="subtle"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
>
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator || is_instructor()"
|
||||
:to="{
|
||||
@@ -145,11 +130,12 @@ function enrollStudent() {
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 2000)
|
||||
}, 3000)
|
||||
} else {
|
||||
const enrollStudentResource = createResource({
|
||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||
})
|
||||
console.log(props.course)
|
||||
enrollStudentResource
|
||||
.submit({
|
||||
course: props.course.data.name,
|
||||
@@ -174,48 +160,5 @@ function enrollStudent() {
|
||||
}
|
||||
}
|
||||
|
||||
const is_instructor = () => {
|
||||
let user_is_instructor = false
|
||||
props.course.data.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const canGetCertificate = computed(() => {
|
||||
if (
|
||||
props.course.data?.enable_certification &&
|
||||
props.course.data?.membership?.progress == 100
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const certificate = createResource({
|
||||
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: values.course,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
console.log(data)
|
||||
window.open(
|
||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||
data.name
|
||||
}&format=${encodeURIComponent(data.template)}`,
|
||||
'_blank'
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCertificate = () => {
|
||||
certificate.submit({
|
||||
course: props.course.data?.name,
|
||||
member: user.data?.name,
|
||||
})
|
||||
}
|
||||
const is_instructor = () => {}
|
||||
</script>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<span v-if="instructors.length == 1">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
>
|
||||
{{ instructors[0].full_name }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="instructors.length == 2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
and
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: instructors[1].username },
|
||||
}"
|
||||
>
|
||||
{{ instructors[1].first_name }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="instructors.length > 2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
and {{ instructors.length - 1 }} others
|
||||
</span>
|
||||
</template>
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
instructors: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="text-base">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="grid grid-cols-[70%,30%] mb-4 px-2"
|
||||
class="flex items-center justify-between mb-4"
|
||||
>
|
||||
<div class="font-semibold text-lg">
|
||||
<div class="font-semibold" :class="allowEdit ? 'text-base' : 'text-lg'">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
@@ -25,7 +25,7 @@
|
||||
:key="chapter.name"
|
||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||
>
|
||||
<DisclosureButton ref="" class="flex w-full p-2">
|
||||
<DisclosureButton ref="" class="flex w-full px-2 py-3">
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
@@ -38,55 +38,42 @@
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel>
|
||||
<Draggable
|
||||
:list="chapter.lessons"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOutline"
|
||||
:data-chapter="chapter.name"
|
||||
>
|
||||
<template #item="{ element: lesson }">
|
||||
<div class="outline-lesson pl-8 py-2 pr-4">
|
||||
<router-link
|
||||
:to="{
|
||||
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center text-sm leading-5 group">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<DisclosurePanel class="pb-2">
|
||||
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
||||
<div class="outline-lesson pl-8 py-2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center text-sm leading-5">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="allowEdit" class="flex mt-2 pl-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CreateLesson',
|
||||
@@ -119,7 +106,6 @@
|
||||
<script setup>
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -127,11 +113,9 @@ import {
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Check,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const route = useRoute()
|
||||
const expandAll = ref(true)
|
||||
@@ -155,10 +139,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
getProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const outline = createResource({
|
||||
@@ -166,47 +146,10 @@ const outline = createResource({
|
||||
cache: ['course_outline', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
progress: props.getProgress,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const deleteLesson = createResource({
|
||||
url: 'lms.lms.api.delete_lesson',
|
||||
makeParams(values) {
|
||||
return {
|
||||
lesson: values.lesson,
|
||||
chapter: values.chapter,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
outline.reload()
|
||||
showToast('Success', 'Lesson deleted successfully', 'check')
|
||||
},
|
||||
})
|
||||
|
||||
const updateLessonIndex = createResource({
|
||||
url: 'lms.lms.api.update_lesson_index',
|
||||
makeParams(values) {
|
||||
return {
|
||||
lesson: values.lesson,
|
||||
sourceChapter: values.sourceChapter,
|
||||
targetChapter: values.targetChapter,
|
||||
idx: values.idx,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Lesson moved successfully', 'check')
|
||||
},
|
||||
})
|
||||
|
||||
const trashLesson = (lessonName, chapterName) => {
|
||||
deleteLesson.submit({
|
||||
lesson: lessonName,
|
||||
chapter: chapterName,
|
||||
})
|
||||
}
|
||||
|
||||
const openChapterDetail = (index) => {
|
||||
return index == route.params.chapterNumber || index == 1
|
||||
}
|
||||
@@ -219,15 +162,6 @@ const openChapterModal = (chapter = null) => {
|
||||
const getCurrentChapter = () => {
|
||||
return currentChapter.value
|
||||
}
|
||||
|
||||
const updateOutline = (e) => {
|
||||
updateLessonIndex.submit({
|
||||
lesson: e.item.__draggable_context.element.name,
|
||||
sourceChapter: e.from.dataset.chapter,
|
||||
targetChapter: e.to.dataset.chapter,
|
||||
idx: e.newIndex,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.outline-lesson:has(.router-link-active) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
|
||||
<div v-if="reviews.data" class="mt-20 mb-10">
|
||||
<Button
|
||||
v-if="membership && !hasReviewed.data"
|
||||
@click="openReviewModal()"
|
||||
@@ -8,30 +8,18 @@
|
||||
{{ __('Write a Review') }}
|
||||
</Button>
|
||||
<div class="flex items-center font-semibold text-2xl">
|
||||
{{ __('Student Reviews') }}
|
||||
<Star class="h-6 w-6 stroke-1 text-gray-50 fill-orange-500 mr-1" />
|
||||
{{ avg_rating }} {{ __('ratings and ') }} {{ reviews.data.length }}
|
||||
{{ __('reviews') }}
|
||||
</div>
|
||||
<div class="grid gap-8 mt-10">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="flex items-center">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: review.owner_details.username },
|
||||
}"
|
||||
>
|
||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||
</router-link>
|
||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||
<div class="mx-4">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: review.owner_details.username },
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-medium mr-4">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
</router-link>
|
||||
<span class="text-lg font-medium mr-4">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
<span>
|
||||
{{ review.creation }}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div
|
||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="w-full overflow-auto" id="scrollContainer">
|
||||
|
||||
@@ -69,11 +69,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextEditor
|
||||
class="mt-5"
|
||||
:content="newReply"
|
||||
:mentions="mentionUsers"
|
||||
@change="(val) => (newReply = val)"
|
||||
placeholder="Type your reply here..."
|
||||
:fixedMenu="true"
|
||||
@@ -94,14 +92,13 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
||||
import { timeAgo } from '../utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted, computed } from 'vue'
|
||||
import { ref, inject, onMounted } from 'vue'
|
||||
import { createToast } from '../utils'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
const socket = inject('$socket')
|
||||
const user = inject('$user')
|
||||
const allUsers = inject('$allUsers')
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
@@ -150,16 +147,6 @@ const newReplyResource = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const mentionUsers = computed(() => {
|
||||
let users = Object.values(allUsers.data).map((user) => {
|
||||
return {
|
||||
value: user.name,
|
||||
label: user.full_name,
|
||||
}
|
||||
})
|
||||
return users
|
||||
})
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
|
||||
@@ -40,16 +40,13 @@
|
||||
<div v-else-if="singleThread && topics.data">
|
||||
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
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-gray-500 stroke-1.5 mr-2" />
|
||||
<div class="">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
<div v-else class="flex justify-center border mt-5 p-5 rounded-md">
|
||||
<MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
<div class="text-gray-600">
|
||||
<div>
|
||||
{{ __(emptyStateText) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,14 +60,13 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { createResource, Button, TextEditor } from 'frappe-ui'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { timeAgo } from '../utils'
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||
import { MessageSquareText } from 'lucide-vue-next'
|
||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||
import { MessageSquareIcon } from 'lucide-vue-next'
|
||||
|
||||
const showTopics = ref(true)
|
||||
const currentTopic = ref(null)
|
||||
@@ -93,20 +89,16 @@ const props = defineProps({
|
||||
},
|
||||
emptyStateTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
default: 'No topics yet',
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Start a discussion',
|
||||
default: 'Be the first to start a discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollToBottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -115,19 +107,8 @@ onMounted(() => {
|
||||
socket.on('new_discussion_topic', (data) => {
|
||||
topics.refresh()
|
||||
})
|
||||
|
||||
if (props.scrollToBottom) {
|
||||
setTimeout(() => {
|
||||
scrollToEnd()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
const scrollToEnd = () => {
|
||||
let scrollContainer = getScrollContainer()
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
|
||||
const topics = createResource({
|
||||
url: 'lms.lms.utils.get_discussion_topics',
|
||||
cache: ['topics', props.doctype, props.docname],
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
<template>
|
||||
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
|
||||
<div class="flex w-3/5 md:w-2/5">
|
||||
<img
|
||||
:src="job.company_logo"
|
||||
class="w-12 h-12 rounded-lg object-contain mr-4"
|
||||
:alt="job.company_name"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-medium mb-1">
|
||||
{{ job.job_title }}
|
||||
</div>
|
||||
<div class="text-gray-700">
|
||||
{{ job.company_name }}
|
||||
</div>
|
||||
<div class="flex shadow rounded-md p-4 h-full">
|
||||
<img
|
||||
:src="job.company_logo"
|
||||
class="w-12 h-12 rounded-lg object-contain mr-4"
|
||||
:alt="job.company_name"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-2">
|
||||
{{ job.job_title }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('posted by') }}
|
||||
<span class="font-medium">
|
||||
{{ job.company_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center my-4">
|
||||
<Badge :label="job.type" theme="green" size="lg" class="mr-4" />
|
||||
<Badge :label="job.location.split(' ')[0]" theme="gray" size="lg">
|
||||
<template #prefix>
|
||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
{{ __('posted on') }}
|
||||
<span class="font-medium">
|
||||
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end w-1/5 text-gray-700">
|
||||
{{ job.location.replace(',', '').split(' ')[0] }}
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
|
||||
>
|
||||
{{ job.type }}
|
||||
</div>
|
||||
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
|
||||
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
||||
|
||||
@@ -24,12 +24,7 @@
|
||||
<Quiz :quiz="getId(block)" />
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Video')">
|
||||
<video
|
||||
controls
|
||||
width="100%"
|
||||
controlsList="nodownload"
|
||||
oncontextmenu="return false;"
|
||||
>
|
||||
<video controls width="100%" controlsList="nodownload">
|
||||
<source :src="getId(block)" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
{{
|
||||
uploading
|
||||
? __('Uploading {0}%').format(progress)
|
||||
: __('Upload a File')
|
||||
: __('Upload an File')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -68,34 +68,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{
|
||||
__(
|
||||
'To add a YouTube video, paste the URL of the video in the editor.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<YouTubeExplanation>
|
||||
<template v-slot="{ togglePopover }">
|
||||
<div
|
||||
@click="togglePopover()"
|
||||
class="flex items-center text-sm underline cursor-pointer"
|
||||
>
|
||||
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ __('Learn More') }}
|
||||
</div>
|
||||
</template>
|
||||
</YouTubeExplanation>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
||||
import { Plus, FileText, Info } from 'lucide-vue-next'
|
||||
import { Plus, FileText } from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
|
||||
|
||||
const quiz = ref(null)
|
||||
const file = ref(null)
|
||||
@@ -129,7 +108,7 @@ const addFile = (data) => {
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
|
||||
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3'].includes(extension)) {
|
||||
return 'Only image and video files are allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Button
|
||||
v-if="user.data.is_moderator"
|
||||
variant="solid"
|
||||
class="float-right mb-5"
|
||||
class="float-right mb-3"
|
||||
@click="openLiveClassModal"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -12,49 +12,48 @@
|
||||
{{ __('Add Live Class') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full p-3"
|
||||
>
|
||||
<div class="font-semibold text-lg mb-4">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-5">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ formatTime(cls.time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-auto">
|
||||
<a
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
<div v-for="cls in liveClasses.data">
|
||||
<div class="border rounded-md p-3">
|
||||
<div class="font-semibold text-lg mb-4">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-5">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ formatTime(cls.time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
@click="handleClick(tab)"
|
||||
>
|
||||
<component
|
||||
:is="icons[tab.icon]"
|
||||
:is="tab.icon"
|
||||
class="h-6 w-6 stroke-1.5"
|
||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||
/>
|
||||
@@ -29,42 +29,15 @@
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
const { logout, user } = sessionStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
const tabs = computed(() => {
|
||||
let links = getSidebarLinks()
|
||||
|
||||
if (user) {
|
||||
links.push({
|
||||
label: 'Profile',
|
||||
icon: 'UserRound',
|
||||
activeFor: [
|
||||
'Profile',
|
||||
'ProfileAbout',
|
||||
'ProfileCertification',
|
||||
'ProfileEvaluator',
|
||||
'ProfileRoles',
|
||||
],
|
||||
})
|
||||
links.push({
|
||||
label: 'Log out',
|
||||
icon: 'LogOut',
|
||||
})
|
||||
} else {
|
||||
links.push({
|
||||
label: 'Log in',
|
||||
icon: 'LogIn',
|
||||
})
|
||||
}
|
||||
return links
|
||||
return getSidebarLinks()
|
||||
})
|
||||
|
||||
let isActive = (tab) => {
|
||||
@@ -77,13 +50,6 @@ const handleClick = (tab) => {
|
||||
logout.submit().then(() => {
|
||||
isLoggedIn = false
|
||||
})
|
||||
else if (tab.label == 'Profile')
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
})
|
||||
else router.push({ name: tab.to })
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
|
||||
<Link
|
||||
doctype="Course Evaluator"
|
||||
v-model="evaluator"
|
||||
:label="__('Evaluator')"
|
||||
class="mt-4"
|
||||
/>
|
||||
<Link doctype="LMS Course" v-model="course" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -32,7 +26,6 @@ import { showToast } from '@/utils'
|
||||
|
||||
const show = defineModel()
|
||||
const course = ref(null)
|
||||
const evaluator = ref(null)
|
||||
const courses = defineModel('courses')
|
||||
|
||||
const props = defineProps({
|
||||
@@ -52,7 +45,6 @@ const createBatchCourse = createResource({
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -66,7 +58,6 @@ const addCourse = (close) => {
|
||||
courses.value.reload()
|
||||
close()
|
||||
course.value = null
|
||||
evaluator.value = null
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message[0] || err, 'x')
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
size: '2xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Post',
|
||||
label: 'Submit',
|
||||
variant: 'solid',
|
||||
onClick: (close) => submitTopic(close),
|
||||
},
|
||||
@@ -15,7 +15,10 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormControl v-model="topic.title" :label="__('Title')" type="text" />
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Title') }}
|
||||
</div>
|
||||
<Input type="text" v-model="topic.title" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
@@ -34,9 +37,8 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive, defineModel, computed } from 'vue'
|
||||
import { showToast } from '@/utils'
|
||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive, defineModel } from 'vue'
|
||||
|
||||
const topics = defineModel('reloadTopics')
|
||||
|
||||
@@ -91,14 +93,6 @@ const submitTopic = (close) => {
|
||||
topicResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!topic.title) {
|
||||
return 'Title cannot be empty.'
|
||||
}
|
||||
if (!topic.reply) {
|
||||
return 'Reply cannot be empty.'
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
replyResource.submit(
|
||||
{
|
||||
@@ -114,9 +108,6 @@ const submitTopic = (close) => {
|
||||
}
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
class="flex-1"
|
||||
/>
|
||||
<FileUploader
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div
|
||||
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
v-for="image in images.data"
|
||||
:key="image.id"
|
||||
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
|
||||
@click="$emit('select', image.urls.raw)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
image.urls.raw +
|
||||
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="images.data"
|
||||
class="mt-2 text-center text-sm text-gray-500"
|
||||
>
|
||||
{{ __('Image search powered by') }}
|
||||
<a class="underline" target="_blank" href="https://unsplash.com">
|
||||
{{ __('Unsplash') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Popover,
|
||||
TextInput,
|
||||
FileUploader,
|
||||
Button,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const search = ref(null)
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const images = createResource({
|
||||
url: 'lms.lms.api.get_unsplash_photos',
|
||||
makeParams: () => {
|
||||
return {
|
||||
keyword: search.value,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
debounce: 500,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => search.value,
|
||||
() => {
|
||||
images.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const saveImage = (file) => {
|
||||
emit('select', file.file_url)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,178 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:options="{
|
||||
title: 'Edit your profile',
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Save',
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveProfile(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="text-base flex flex-col">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.headline"
|
||||
:label="__('Headline')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
Button,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, defineModel } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize, showToast } from '@/utils'
|
||||
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const profile = reactive({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
headline: '',
|
||||
bio: '',
|
||||
image: '',
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
profile.image = data
|
||||
},
|
||||
})
|
||||
|
||||
const updateProfile = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'User',
|
||||
name: props.profile.data.name,
|
||||
fieldname: {
|
||||
user_image: profile.image.file_url,
|
||||
...profile,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
props.profile.data = data
|
||||
},
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
reloadProfile.value.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
profile.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
profile.image = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.profile.data,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
profile.first_name = newVal.first_name
|
||||
profile.last_name = newVal.last_name
|
||||
profile.headline = newVal.headline
|
||||
profile.bio = newVal.bio
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<FormControl type="date" v-model="evaluation.date" />
|
||||
<DatePicker v-model="evaluation.date" />
|
||||
</div>
|
||||
<div v-if="slots.data?.length">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
@@ -46,10 +46,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="evaluation.course && evaluation.date"
|
||||
class="text-sm italic text-red-600"
|
||||
>
|
||||
<div v-else class="text-sm italic text-red-600">
|
||||
{{ __('No slots available for this date.') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +54,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
|
||||
import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
|
||||
import { defineModel, reactive, watch, inject } from 'vue'
|
||||
import { createToast, formatTime } from '@/utils/'
|
||||
|
||||
@@ -116,7 +113,7 @@ function submitEvaluation(close) {
|
||||
if (!evaluation.start_time) {
|
||||
return 'Please select a slot.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
|
||||
if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||
@@ -130,14 +127,11 @@ function submitEvaluation(close) {
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
let message = err.messages?.[0] || err
|
||||
let unavailabilityMessage = message.includes('unavailable')
|
||||
|
||||
createToast({
|
||||
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
|
||||
text: message,
|
||||
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
||||
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
@@ -171,7 +165,7 @@ watch(
|
||||
() => evaluation.date,
|
||||
(date) => {
|
||||
evaluation.start_time = ''
|
||||
if (date && evaluation.course) {
|
||||
if (date) {
|
||||
slots.submit(evaluation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,61 +17,78 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormControl
|
||||
type="text"
|
||||
v-model="liveClass.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Tooltip
|
||||
:text="
|
||||
__(
|
||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||
)
|
||||
"
|
||||
>
|
||||
<FormControl
|
||||
v-model="liveClass.time"
|
||||
type="time"
|
||||
:label="__('Time')"
|
||||
class="mb-4"
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Title') }}
|
||||
</div>
|
||||
<Input type="text" v-model="liveClass.title" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
<Tooltip
|
||||
class="flex items-center"
|
||||
:text="
|
||||
__(
|
||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ __('Time') }}
|
||||
</span>
|
||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input v-model="liveClass.time" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Timezone') }}
|
||||
</div>
|
||||
<Select
|
||||
v-model="liveClass.timezone"
|
||||
:options="getTimezoneOptions()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
v-model="liveClass.timezone"
|
||||
type="select"
|
||||
:options="getTimezoneOptions()"
|
||||
:label="__('Timezone')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="liveClass.date"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:label="__('Date')"
|
||||
/>
|
||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration')"
|
||||
class="mb-4"
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<DatePicker v-model="liveClass.date" inputClass="w-full" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
<Tooltip
|
||||
class="flex items-center"
|
||||
:text="__('Duration of the live class in minutes')"
|
||||
>
|
||||
<span>
|
||||
{{ __('Duration') }}
|
||||
</span>
|
||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input type="number" v-model="liveClass.duration" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Auto Recording') }}
|
||||
</div>
|
||||
<Select
|
||||
v-model="liveClass.auto_recording"
|
||||
:options="getRecordingOptions()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
v-model="liveClass.auto_recording"
|
||||
type="select"
|
||||
:options="getRecordingOptions()"
|
||||
:label="__('Auto Recording')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="liveClass.description"
|
||||
type="textarea"
|
||||
:label="__('Description')"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Description') }}
|
||||
</div>
|
||||
<Textarea v-model="liveClass.description" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -85,7 +102,6 @@ import {
|
||||
Dialog,
|
||||
createResource,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject } from 'vue'
|
||||
import { getTimezones, createToast } from '@/utils/'
|
||||
@@ -153,7 +169,7 @@ const createLiveClass = createResource({
|
||||
})
|
||||
|
||||
const submitLiveClass = (close) => {
|
||||
return createLiveClass.submit(liveClass, {
|
||||
createLiveClass.submit(liveClass, {
|
||||
validate() {
|
||||
if (!liveClass.title) {
|
||||
return 'Please enter a title.'
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
class="text-base"
|
||||
:options="{
|
||||
title: __('Add web page to sidebar'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: 'Add',
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
addWebPage(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link
|
||||
v-model="page.webpage"
|
||||
doctype="Web Page"
|
||||
:label="__('Web Page')"
|
||||
:filters="{
|
||||
published: 1,
|
||||
}"
|
||||
/>
|
||||
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource } from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { reactive, watch } from 'vue'
|
||||
import IconPicker from '@/components/Controls/IconPicker.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const sidebar = defineModel('reloadSidebar')
|
||||
const show = defineModel()
|
||||
const page = reactive({
|
||||
icon: '',
|
||||
webpage: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const webPage = createResource({
|
||||
url: 'lms.lms.api.update_sidebar_item',
|
||||
makeParams(values) {
|
||||
return {
|
||||
webpage: page.webpage,
|
||||
icon: page.icon,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.page,
|
||||
(newPage) => {
|
||||
if (newPage) {
|
||||
page.icon = newPage.icon
|
||||
page.webpage = newPage.web_page
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const addWebPage = (close) => {
|
||||
webPage.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
sidebar.value.reload()
|
||||
close()
|
||||
showToast('Success', 'Web page added to sidebar', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message[0] || err, 'x')
|
||||
close()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,147 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="dialogOptions">
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">
|
||||
{{ __('Question') }}
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="question.question"
|
||||
@change="(val) => (question.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
:label="__('Type')"
|
||||
v-model="question.type"
|
||||
type="select"
|
||||
:options="['Choices', 'User Input']"
|
||||
class="pb-2"
|
||||
/>
|
||||
<div v-if="question.type == 'Choices'" class="divide-y">
|
||||
<div v-for="n in 4" class="space-y-4 py-2">
|
||||
<FormControl
|
||||
:label="__('Option') + ' ' + n"
|
||||
v-model="question[`option_${n}`]"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Explanation')"
|
||||
v-model="question[`explanation_${n}`]"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Correct Answer')"
|
||||
v-model="question[`correct_answer_${n}`]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-for="n in 4" class="space-y-2">
|
||||
<FormControl
|
||||
:label="__('Possibility') + ' ' + n"
|
||||
v-model="question[`possibility_${n}`]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||
import { computed, onMounted, reactive, inject } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
const user = inject('$user')
|
||||
const question = reactive({
|
||||
question: '',
|
||||
type: 'Choices',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
populateFields()
|
||||
console.log(props.questionName)
|
||||
if (
|
||||
props.questionName == 'new' &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (props.courseName !== 'new') {
|
||||
questionDoc.reload()
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: __('Add a Question'),
|
||||
},
|
||||
questionName: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const questionDoc = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams: (values) => {
|
||||
return {
|
||||
doctype: 'LMS Question',
|
||||
name: props.questionName,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
let counter = 1
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(question, key)) question[key] = data[key]
|
||||
})
|
||||
while (counter <= 4) {
|
||||
question[`is_correct_${counter}`] = question[`is_correct_${counter}`]
|
||||
? true
|
||||
: false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const populateFields = () => {
|
||||
let fields = ['option', 'correct_answer', 'explanation', 'possibility']
|
||||
let counter = 1
|
||||
fields.forEach((field) => {
|
||||
while (counter <= 4) {
|
||||
question[`${field}_${counter}`] = field === 'correct_answer' ? false : ''
|
||||
counter++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
submitQuestion()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const dialogOptions = computed(() => {
|
||||
return {
|
||||
title: __(props.title),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Submit'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
submitQuestion(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-0 mt-3 w-[35rem] max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<video
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
width="100%"
|
||||
controlsList="nodownload"
|
||||
oncontextmenu="return false;"
|
||||
class="rounded-sm"
|
||||
>
|
||||
<source src="/Youtube.mov" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Popover } from 'frappe-ui'
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div class="border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div>
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="mt-2">
|
||||
{{ __('Checkout Courses') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div>
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
||||
<div
|
||||
class="bg-gray-900 h-1 rounded-full"
|
||||
:style="{ width: progressBarWidth }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const progressBarWidth = computed(() => {
|
||||
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
||||
return `${formattedPercentage}%`
|
||||
})
|
||||
</script>
|
||||
@@ -69,10 +69,7 @@
|
||||
<span class="mr-2">
|
||||
{{ __('Question {0}').format(activeQuestion) }}:
|
||||
</span>
|
||||
<span v-if="questionDetails.data.type == 'User Input'">
|
||||
{{ __('Type your answer') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<span>
|
||||
{{
|
||||
questionDetails.data.multiple
|
||||
? __('Choose all answers that apply')
|
||||
@@ -85,10 +82,9 @@
|
||||
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-gray-900 font-semibold mt-2"
|
||||
v-html="questionDetails.data.question"
|
||||
></div>
|
||||
<div class="text-gray-900 font-semibold mt-2">
|
||||
{{ questionDetails.data.question }}
|
||||
</div>
|
||||
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
|
||||
<label
|
||||
v-if="questionDetails.data[`option_${index}`]"
|
||||
@@ -127,41 +123,18 @@
|
||||
<MinusCircle v-else class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2"
|
||||
v-html="questionDetails.data[`option_${index}`]"
|
||||
>
|
||||
<span class="ml-2">
|
||||
{{ questionDetails.data[`option_${index}`] }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="questionDetails.data[`explanation_${index}`]"
|
||||
class="mt-2 text-xs"
|
||||
v-show="showAnswers.length"
|
||||
class="mt-2 text-sm hidden"
|
||||
>
|
||||
{{ questionDetails.data[`explanation_${index}`] }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<FormControl
|
||||
v-model="possibleAnswer"
|
||||
type="textarea"
|
||||
:disabled="showAnswers.length ? true : false"
|
||||
class="my-2"
|
||||
/>
|
||||
<div v-if="showAnswers.length">
|
||||
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
|
||||
<template #prefix>
|
||||
<CheckCircle class="w-4 h-4 text-green-500 mr-1" />
|
||||
</template>
|
||||
</Badge>
|
||||
<Badge v-else theme="red" :label="__('Incorrect')">
|
||||
<template #prefix>
|
||||
<XCircle class="w-4 h-4 text-red-500 mr-1" />
|
||||
</template>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<div>
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
@@ -238,19 +211,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, Button, createResource, ListView } from 'frappe-ui'
|
||||
import {
|
||||
createDocumentResource,
|
||||
Button,
|
||||
createResource,
|
||||
ListView,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import FormControl from 'frappe-ui/src/components/FormControl.vue'
|
||||
const user = inject('$user')
|
||||
|
||||
const activeQuestion = ref(0)
|
||||
const currentQuestion = ref('')
|
||||
const selectedOptions = reactive([0, 0, 0, 0])
|
||||
const showAnswers = reactive([])
|
||||
const possibleAnswer = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
quizName: {
|
||||
@@ -270,12 +246,8 @@ const quiz = createResource({
|
||||
cache: ['quiz', props.quizName],
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.shuffle_questions) {
|
||||
data.questions = data.questions.sort(() => Math.random() - 0.5)
|
||||
}
|
||||
if (data.limit_questions_to) {
|
||||
data.questions = data.questions.slice(0, data.limit_questions_to)
|
||||
}
|
||||
attempts.reload()
|
||||
resetQuiz()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -307,16 +279,6 @@ const attempts = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => quiz.data,
|
||||
() => {
|
||||
if (quiz.data) {
|
||||
attempts.reload()
|
||||
resetQuiz()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const quizSubmission = createResource({
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||
makeParams(values) {
|
||||
@@ -346,6 +308,7 @@ watch(activeQuestion, (value) => {
|
||||
watch(
|
||||
() => props.quizName,
|
||||
(newName) => {
|
||||
console.log(newName)
|
||||
if (newName) {
|
||||
quiz.reload()
|
||||
}
|
||||
@@ -365,17 +328,10 @@ const markAnswer = (index) => {
|
||||
|
||||
const getAnswers = () => {
|
||||
let answers = []
|
||||
const type = questionDetails.data.type
|
||||
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((value, index) => {
|
||||
if (selectedOptions[index])
|
||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||
})
|
||||
} else {
|
||||
answers.push(possibleAnswer.value)
|
||||
}
|
||||
|
||||
selectedOptions.forEach((value, index) => {
|
||||
if (selectedOptions[index])
|
||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||
})
|
||||
return answers
|
||||
}
|
||||
|
||||
@@ -385,8 +341,7 @@ const checkAnswer = () => {
|
||||
createToast({
|
||||
title: 'Please select an option',
|
||||
icon: 'alert-circle',
|
||||
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
|
||||
position: 'top-center',
|
||||
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -400,20 +355,15 @@ const checkAnswer = () => {
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
let type = questionDetails.data.type
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((option, index) => {
|
||||
if (option) {
|
||||
showAnswers[index] = option && data[index]
|
||||
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
||||
showAnswers[index] = 0
|
||||
} else {
|
||||
showAnswers[index] = undefined
|
||||
}
|
||||
})
|
||||
} else {
|
||||
showAnswers.push(data)
|
||||
}
|
||||
selectedOptions.forEach((option, index) => {
|
||||
if (option) {
|
||||
showAnswers[index] = option && data[index]
|
||||
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
||||
showAnswers[index] = 0
|
||||
} else {
|
||||
showAnswers[index] = undefined
|
||||
}
|
||||
})
|
||||
addToLocalStorage()
|
||||
if (!quiz.data.show_answers) {
|
||||
resetQuestion()
|
||||
@@ -426,7 +376,7 @@ const addToLocalStorage = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
let questionData = {
|
||||
question_index: activeQuestion.value,
|
||||
answer: getAnswers().join(),
|
||||
answers: getAnswers().join(),
|
||||
is_correct: showAnswers.filter((answer) => {
|
||||
return answer != undefined
|
||||
}),
|
||||
@@ -448,7 +398,6 @@ const resetQuestion = () => {
|
||||
activeQuestion.value = activeQuestion.value + 1
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
}
|
||||
|
||||
const submitQuiz = () => {
|
||||
|
||||
@@ -23,8 +23,4 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,21 +6,21 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
class="flex items-center w-full duration-300 ease-in-out group"
|
||||
class="flex items-center duration-300 ease-in-out"
|
||||
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||
>
|
||||
<Tooltip :text="link.label" placement="right">
|
||||
<slot name="icon">
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<component
|
||||
:is="icons[link.icon]"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-800"
|
||||
:is="link.icon"
|
||||
class="h-5 w-5 stroke-1.5 text-gray-800"
|
||||
/>
|
||||
</span>
|
||||
</slot>
|
||||
</Tooltip>
|
||||
<span
|
||||
class="flex-shrink-0 text-sm duration-300 ease-in-out"
|
||||
class="flex-shrink-0 text-base duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||
@@ -29,35 +29,16 @@
|
||||
>
|
||||
{{ link.label }}
|
||||
</span>
|
||||
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||
{{ link.count }}
|
||||
</span>
|
||||
<div
|
||||
v-if="showControls && !isCollapsed"
|
||||
class="flex items-center space-x-2 !ml-auto block text-xs text-gray-600 group-hover:visible invisible"
|
||||
>
|
||||
<component
|
||||
:is="icons['Edit']"
|
||||
class="h-3 w-3 stroke-1.5 text-gray-700"
|
||||
@click.stop="openModal(link)"
|
||||
/>
|
||||
<component
|
||||
:is="icons['X']"
|
||||
class="h-3 w-3 stroke-1.5 text-gray-700"
|
||||
@click.stop="deletePage(link)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Tooltip, Button } from 'frappe-ui'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const emit = defineEmits(['openModal', 'deletePage'])
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
@@ -68,29 +49,13 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showControls: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (router.hasRoute(props.link.to)) {
|
||||
router.push({ name: props.link.to })
|
||||
} else if (props.link.to) {
|
||||
window.location.href = `/${props.link.to}`
|
||||
}
|
||||
router.push({ name: props.link.to })
|
||||
}
|
||||
|
||||
const isActive = computed(() => {
|
||||
let isActive = computed(() => {
|
||||
return props.link?.activeFor?.includes(router.currentRoute.value.name)
|
||||
})
|
||||
|
||||
const openModal = (link) => {
|
||||
emit('openModal', link)
|
||||
}
|
||||
|
||||
const deletePage = (link) => {
|
||||
emit('deletePage', link)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,9 @@ const props = defineProps({
|
||||
default: 'Tags',
|
||||
},
|
||||
})
|
||||
console.log(props.modelValue)
|
||||
let tags = ref(props.modelValue)
|
||||
console.log(tags.value)
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
let newTag = ref('')
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
/>
|
||||
</div>
|
||||
<FileUploader @success="(file) => $emit('select', file.file_url)">
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="w-full text-center">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div
|
||||
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
|
||||
>
|
||||
<Button
|
||||
v-for="image in $resources.images.data"
|
||||
:key="image.id"
|
||||
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
|
||||
@click="$emit('select', image.urls.raw)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
image.urls.raw +
|
||||
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-2 text-center text-sm text-gray-500">
|
||||
{{ __('Image search powered by') }}
|
||||
<a class="underline" target="_blank" href="https://unsplash.com">
|
||||
{{ __('Unsplash') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { Popover, FileUploader, Button } from 'frappe-ui'
|
||||
|
||||
export default {
|
||||
name: 'UnsplashImageBrowser',
|
||||
components: {
|
||||
Popover,
|
||||
FileUploader,
|
||||
},
|
||||
emits: ['select'],
|
||||
resources: {
|
||||
images() {
|
||||
return {
|
||||
url: 'gameplan.api.get_unsplash_photos',
|
||||
params: { keyword: this.search },
|
||||
auto: true,
|
||||
debounce: 500,
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,15 @@
|
||||
<template>
|
||||
<Tooltip :text="user.full_name">
|
||||
<Avatar
|
||||
class="avatar border border-gray-300 cursor-auto"
|
||||
v-if="user"
|
||||
:label="user.full_name"
|
||||
:image="user.user_image"
|
||||
:size="size"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Avatar
|
||||
class="avatar border border-gray-300"
|
||||
v-if="user"
|
||||
:label="user.full_name"
|
||||
:image="user.user_image"
|
||||
:size="size"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar, Tooltip } from 'frappe-ui'
|
||||
import { Avatar } from 'frappe-ui'
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
|
||||
@@ -26,21 +26,13 @@
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-gray-900 leading-none">
|
||||
<span
|
||||
v-if="
|
||||
branding.data?.brand_name &&
|
||||
branding.data?.brand_name != 'Frappe'
|
||||
"
|
||||
>
|
||||
<span v-if="branding.data?.brand_name">
|
||||
{{ branding.data?.brand_name }}
|
||||
</span>
|
||||
<span v-else> Learning </span>
|
||||
</div>
|
||||
<div
|
||||
v-if="userResource"
|
||||
class="mt-1 text-sm text-gray-700 leading-none"
|
||||
>
|
||||
{{ convertToTitleCase(userResource.data?.full_name) }}
|
||||
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
|
||||
{{ convertToTitleCase(user.split('@')[0]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -61,23 +53,13 @@
|
||||
<script setup>
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import {
|
||||
ChevronDown,
|
||||
LogIn,
|
||||
LogOut,
|
||||
User,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-vue-next'
|
||||
import { Dropdown, createResource } from 'frappe-ui'
|
||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '../utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { logout, branding } = sessionStore()
|
||||
let { userResource } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
@@ -85,30 +67,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
cache: true,
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
document.querySelector("link[rel='icon']").href = data.favicon
|
||||
},
|
||||
})
|
||||
|
||||
const { logout, user } = sessionStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const userDropdownOptions = [
|
||||
{
|
||||
/* {
|
||||
icon: User,
|
||||
label: 'My Profile',
|
||||
onClick: () => {
|
||||
router.push(`/user/${userResource.data?.username}`)
|
||||
router.push(`/user/${user.data?.username}`)
|
||||
},
|
||||
condition: () => {
|
||||
return isLoggedIn
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ArrowRightLeft,
|
||||
label: 'Switch to Desk',
|
||||
onClick: () => {
|
||||
window.location.href = '/app'
|
||||
},
|
||||
condition: () => {
|
||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||
let system_user = cookies.get('system_user')
|
||||
if (system_user === 'yes') return true
|
||||
else return false
|
||||
},
|
||||
},
|
||||
}, */
|
||||
{
|
||||
icon: LogOut,
|
||||
label: 'Log out',
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<template>
|
||||
<div ref="videoContainer" class="video-block group relative">
|
||||
<video
|
||||
@timeupdate="updateTime"
|
||||
@ended="videoEnded"
|
||||
class="rounded-lg border border-gray-100"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
<div
|
||||
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
<Play
|
||||
v-if="!playing"
|
||||
@click="playVideo"
|
||||
class="w-4 h-4 text-gray-900"
|
||||
/>
|
||||
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-gray-900" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" @click="toggleMute">
|
||||
<template #icon>
|
||||
<Volume2 v-if="!muted" class="w-4 h-4 text-gray-900" />
|
||||
<VolumeX v-else class="w-4 h-4 text-gray-900" />
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
step="0.1"
|
||||
v-model="currentTime"
|
||||
@input="changeCurrentTime"
|
||||
class="duration-slider w-full h-1"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</span>
|
||||
<Button variant="ghost" @click="toggleFullscreen">
|
||||
<template #icon>
|
||||
<Maximize class="w-4 h-4 text-gray-900" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const videoRef = ref(null)
|
||||
const videoContainer = ref(null)
|
||||
let playing = ref(false)
|
||||
let currentTime = ref(0)
|
||||
let duration = ref(0)
|
||||
let muted = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'video/mp4',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
videoRef.value = document.querySelector('video')
|
||||
videoRef.value.onloadedmetadata = () => {
|
||||
duration.value = videoRef.value.duration
|
||||
}
|
||||
videoRef.value.ontimeupdate = () => {
|
||||
currentTime.value = videoRef.value.currentTime
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const fileURL = computed(() => {
|
||||
if (isYoutube) {
|
||||
let url = props.file
|
||||
if (url.includes('watch?v=')) {
|
||||
url = url.replace('watch?v=', 'embed/')
|
||||
}
|
||||
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
|
||||
}
|
||||
return props.file
|
||||
})
|
||||
|
||||
const isYoutube = computed(() => {
|
||||
return props.type == 'video/youtube'
|
||||
})
|
||||
|
||||
const playVideo = () => {
|
||||
videoRef.value.play()
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
const pauseVideo = () => {
|
||||
videoRef.value.pause()
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
const videoEnded = () => {
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
videoRef.value.muted = !videoRef.value.muted
|
||||
muted.value = videoRef.value.muted
|
||||
}
|
||||
|
||||
const changeCurrentTime = () => {
|
||||
videoRef.value.currentTime = currentTime.value
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
videoContainer.value.requestFullscreen()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-block {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.video-block video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.duration-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: theme('colors.gray.400');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duration-slider::-webkit-slider-thumb {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
-webkit-appearance: none;
|
||||
background-color: theme('colors.gray.900');
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
input[type='range'] {
|
||||
overflow: hidden;
|
||||
width: 150px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
box-shadow: -500px 0 0 500px theme('colors.gray.900');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,9 +30,8 @@ app.provide('$dayjs', dayjs)
|
||||
app.provide('$socket', initSocket())
|
||||
app.mount('#app')
|
||||
|
||||
const { userResource, allUsers } = usersStore()
|
||||
const { userResource } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
app.provide('$user', userResource)
|
||||
app.provide('$allUsers', allUsers)
|
||||
app.config.globalProperties.$user = userResource
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<div v-if="badge.doc">
|
||||
<div class="p-5 flex flex-col items-center mt-40">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ badge.doc.title }}
|
||||
</div>
|
||||
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
|
||||
<div class="text-lg">
|
||||
{{
|
||||
__('This badge has been awarded to {0} on {1}.').format(
|
||||
userName,
|
||||
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="text-lg mt-2">
|
||||
{{ badge.doc.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const allUsers = inject('$allUsers')
|
||||
const dayjs = inject('$dayjs')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
badgeName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const badge = createDocumentResource({
|
||||
doctype: 'LMS Badge',
|
||||
name: props.badgeName,
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
const user = Object.values(allUsers.data).find(
|
||||
(user) => user.name === props.email
|
||||
)
|
||||
return user ? user.full_name : props.email
|
||||
})
|
||||
|
||||
const issuedOn = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Badge Assignment',
|
||||
filters: {
|
||||
member: props.email,
|
||||
badge: props.badgeName,
|
||||
},
|
||||
fieldname: 'issued_on',
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
if (!data.issued_on) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Badges',
|
||||
},
|
||||
{
|
||||
label: badge.doc.title,
|
||||
route: {
|
||||
name: 'Badge',
|
||||
params: {
|
||||
badge: badge.doc.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -13,9 +13,9 @@
|
||||
</template>
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
|
||||
<div class="border-r-2">
|
||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-y-hidden">
|
||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||
<div>
|
||||
<button
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ tab }">
|
||||
<div class="pt-5 px-5 pb-10">
|
||||
<div class="pt-5 px-10 pb-10">
|
||||
<div v-if="tab.label == 'Courses'">
|
||||
<BatchCourses :batch="batch.data.name" />
|
||||
</div>
|
||||
@@ -66,10 +66,9 @@
|
||||
<Discussions
|
||||
doctype="LMS Batch"
|
||||
:docname="batch.data.name"
|
||||
:title="__('Discussions')"
|
||||
title="Discussions"
|
||||
:key="batch.data.name"
|
||||
:singleThread="true"
|
||||
:scrollToBottom="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,40 +79,21 @@
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
||||
|
||||
<div class="flex avatar-group overlap mb-5">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
|
||||
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex items-center mb-6">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.data.timezone" class="flex items-center mb-4">
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AnnouncementModal
|
||||
v-model="showAnnouncementModal"
|
||||
@@ -169,9 +149,8 @@
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
@@ -181,9 +160,8 @@ import {
|
||||
Mail,
|
||||
SendIcon,
|
||||
MessageCircle,
|
||||
Globe,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||
import { formatTime } from '@/utils'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import BatchCourses from '@/components/BatchCourses.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
@@ -192,8 +170,8 @@ import Assessments from '@/components/Assessments.vue'
|
||||
import Announcements from '@/components/Annoucements.vue'
|
||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const showAnnouncementModal = ref(false)
|
||||
|
||||
@@ -286,13 +264,4 @@ const redirectToLogin = () => {
|
||||
const openAnnouncementModal = () => {
|
||||
showAnnouncementModal.value = true
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: batch.data?.title,
|
||||
description: batch.data?.description,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -8,86 +8,76 @@
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div class="w-1/2 mx-auto py-5">
|
||||
<div class="">
|
||||
<div class="py-5">
|
||||
<div class="container">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10 mb-4">
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow self enrollment')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
class="mt-4"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||
</Button>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
class="mt-4"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mt-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ batch.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(batch.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ batch.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(batch.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
class="my-4"
|
||||
/>
|
||||
<div class="container border-b mb-5">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">
|
||||
{{ __('Batch Details') }}
|
||||
@@ -101,9 +91,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="container border-b mb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Date and Time') }}
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
@@ -119,8 +109,6 @@
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
@@ -133,20 +121,7 @@
|
||||
type="time"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
@@ -160,8 +135,6 @@
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
@@ -187,7 +160,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<div class="container">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Payment') }}
|
||||
</div>
|
||||
@@ -215,14 +188,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
inject,
|
||||
reactive,
|
||||
onBeforeUnmount,
|
||||
ref,
|
||||
} from 'vue'
|
||||
import { computed, onMounted, inject, reactive } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
@@ -232,7 +198,6 @@ import {
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize, showToast } from '../utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
@@ -256,41 +221,21 @@ const batch = reactive({
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
timezone: '',
|
||||
evaluation_end_date: '',
|
||||
seat_count: '',
|
||||
medium: '',
|
||||
category: '',
|
||||
allow_self_enrollment: false,
|
||||
image: null,
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
amount: 0,
|
||||
})
|
||||
|
||||
const instructors = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
if (props.batchName != 'new') {
|
||||
batchDetail.reload()
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const newBatch = createResource({
|
||||
@@ -299,10 +244,7 @@ const newBatch = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch',
|
||||
meta_image: batch.image?.file_url,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
meta_image: batch.image.file_url,
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
@@ -319,13 +261,9 @@ const batchDetail = createResource({
|
||||
},
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
||||
let checkboxes = ['published', 'paid_batch']
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
@@ -341,10 +279,7 @@ const editBatch = createResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
fieldname: {
|
||||
meta_image: batch.image?.file_url,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
meta_image: batch.image.file_url,
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,23 +11,20 @@
|
||||
<div class="my-3">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
|
||||
>
|
||||
<div class="flex items-center justify-between w-1/2">
|
||||
<div class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<span class="hidden lg:block" v-if="batch.data.courses"
|
||||
>·</span
|
||||
>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
/>
|
||||
<span class="hidden lg:block" v-if="batch.data.start_date"
|
||||
>·</span
|
||||
>
|
||||
<span v-if="batch.data.courses">·</span>
|
||||
<div class="flex items-center">
|
||||
<Calendar class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span>
|
||||
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
||||
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="batch.data.start_date">·</span>
|
||||
<div class="flex items-center">
|
||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span>
|
||||
@@ -36,29 +33,15 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex avatar-group overlap mt-3">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||
<div class="order-2 lg:order-none">
|
||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||
<div class="">
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div class="order-1 lg:order-none">
|
||||
<div>
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +51,7 @@
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="batch.data.courses"
|
||||
v-for="course in courses.data"
|
||||
@@ -97,17 +80,15 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { Breadcrumbs, createResource, Button } from 'frappe-ui'
|
||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '../utils'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||
import DateRange from '../components/Common/DateRange.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
@@ -125,6 +106,16 @@ const batch = createResource({
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.students?.includes(user.data?.name)) {
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: props.batchName,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
@@ -144,15 +135,6 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: batch.data?.title,
|
||||
description: batch.data?.description,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
|
||||
@@ -7,17 +7,9 @@
|
||||
class="h-7"
|
||||
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2">
|
||||
<div class="w-40">
|
||||
<Select
|
||||
v-if="categories.data?.length"
|
||||
v-model="currentCategory"
|
||||
:options="categories.data"
|
||||
:placeholder="__('Filter')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator"
|
||||
v-if="user.data"
|
||||
:to="{
|
||||
name: 'BatchCreation',
|
||||
params: { batchName: 'new' },
|
||||
@@ -41,7 +33,7 @@
|
||||
</div>
|
||||
<Tabs
|
||||
v-model="tabIndex"
|
||||
:tabs="makeTabs"
|
||||
:tabs="tabs"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
>
|
||||
<template #tab="{ tab, selected }">
|
||||
@@ -70,7 +62,7 @@
|
||||
<template #default="{ tab }">
|
||||
<div
|
||||
v-if="tab.batches && tab.batches.value.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mt-5 mx-5"
|
||||
>
|
||||
<router-link
|
||||
v-for="batch in tab.batches.value"
|
||||
@@ -95,29 +87,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Tabs,
|
||||
Badge,
|
||||
Select,
|
||||
} from 'frappe-ui'
|
||||
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const currentCategory = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (queries.has('category')) {
|
||||
currentCategory.value = queries.get('category')
|
||||
}
|
||||
})
|
||||
|
||||
const batches = createListResource({
|
||||
doctype: 'LMS Batch',
|
||||
@@ -126,82 +101,32 @@ const batches = createListResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const categories = createResource({
|
||||
url: 'lms.lms.api.get_categories',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
filters: {
|
||||
published: 1,
|
||||
},
|
||||
}
|
||||
},
|
||||
cache: ['batchCategories'],
|
||||
auto: true,
|
||||
transform(data) {
|
||||
data.unshift({
|
||||
label: '',
|
||||
value: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
let tabs
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Upcoming',
|
||||
batches: computed(() => batches.data?.upcoming || []),
|
||||
count: computed(() => batches.data?.upcoming?.length),
|
||||
},
|
||||
]
|
||||
|
||||
const makeTabs = computed(() => {
|
||||
tabs = []
|
||||
addToTabs('Upcoming')
|
||||
|
||||
if (user.data?.is_moderator) {
|
||||
addToTabs('Archived')
|
||||
addToTabs('Private')
|
||||
}
|
||||
|
||||
if (user.data) {
|
||||
addToTabs('Enrolled')
|
||||
}
|
||||
|
||||
return tabs
|
||||
})
|
||||
|
||||
const getBatches = (type) => {
|
||||
if (currentCategory.value && currentCategory.value != '') {
|
||||
return batches.data[type].filter(
|
||||
(batch) => batch.category == currentCategory.value
|
||||
)
|
||||
}
|
||||
return batches.data[type]
|
||||
}
|
||||
|
||||
const addToTabs = (label) => {
|
||||
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
|
||||
if (user.data?.is_moderator) {
|
||||
tabs.push({
|
||||
label,
|
||||
batches: computed(() => batches),
|
||||
count: computed(() => batches.length),
|
||||
label: 'Archived',
|
||||
batches: computed(() => batches.data?.archived),
|
||||
count: computed(() => batches.data?.archived?.length),
|
||||
})
|
||||
tabs.push({
|
||||
label: 'Private',
|
||||
batches: computed(() => batches.data?.private),
|
||||
count: computed(() => batches.data?.private?.length),
|
||||
})
|
||||
}
|
||||
if (user.data) {
|
||||
tabs.push({
|
||||
label: 'Enrolled',
|
||||
batches: computed(() => batches.data?.enrolled),
|
||||
count: computed(() => batches.data?.enrolled?.length),
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (currentCategory.value) {
|
||||
queries.set('category', currentCategory.value)
|
||||
} else {
|
||||
queries.delete('category')
|
||||
}
|
||||
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
|
||||
}
|
||||
)
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Batches',
|
||||
description: 'All batches divided by categories',
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<div v-else-if="!user.data?.name">
|
||||
<NotPermitted
|
||||
text="Please login to access this page."
|
||||
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
|
||||
:buttonLink="`/login?redirect-to=/billing/${type}/${name}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +340,6 @@ const validateAddress = () => {
|
||||
'Assam',
|
||||
'Bihar',
|
||||
'Chhattisgarh',
|
||||
'Delhi',
|
||||
'Goa',
|
||||
'Gujarat',
|
||||
'Haryana',
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div>
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search Participants"
|
||||
v-model="searchQuery"
|
||||
@input="participants.reload()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
||||
<div
|
||||
v-if="participants.data?.length"
|
||||
v-for="participant in participantsList"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: participant.username },
|
||||
}"
|
||||
>
|
||||
<div class="flex shadow rounded-md h-full p-2">
|
||||
<UserAvatar :user="participant" size="3xl" class="mr-2" />
|
||||
<div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: participant.username },
|
||||
}"
|
||||
>
|
||||
<div class="text-lg font-semibold mb-2">
|
||||
{{ participant.full_name }}
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="leading-5" v-for="course in participant.courses">
|
||||
{{ course }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const participants = createResource({
|
||||
url: 'lms.lms.api.get_certified_participants',
|
||||
method: 'GET',
|
||||
cache: 'certified-participants',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: 'Certified Participants', to: '/certified-participants' }]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Certified Participants',
|
||||
description: 'All participants that have been certified.',
|
||||
}
|
||||
})
|
||||
|
||||
const participantsList = computed(() => {
|
||||
if (searchQuery.value) {
|
||||
return participants.data.filter((participant) => {
|
||||
return participant.full_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase())
|
||||
})
|
||||
}
|
||||
return participants.data
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
@@ -41,7 +41,7 @@
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
class="mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
@@ -51,7 +51,17 @@
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
<span v-if="course.data.instructors.length == 1">
|
||||
{{ course.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length == 2">
|
||||
{{ course.data.instructors[0].first_name }} and
|
||||
{{ course.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="course.data.instructors.length > 2">
|
||||
{{ course.data.instructors[0].first_name }} and
|
||||
{{ course.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt-3 mb-4 w-fit">
|
||||
@@ -70,9 +80,14 @@
|
||||
class="course-description"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline :courseName="course.data.name" :showOutline="true" />
|
||||
<CourseOutline
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
title="Course Outline"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
v-if="course.data.avg_rating"
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.avg_rating"
|
||||
:membership="course.data.membership"
|
||||
@@ -94,7 +109,6 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -145,12 +159,6 @@ updateDocumentTitle(pageMeta)
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.course-description ul {
|
||||
list-style: disc;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,19 +5,9 @@
|
||||
>
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="[{ label: __('All Courses'), route: { name: 'Courses' } }]"
|
||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search Course"
|
||||
v-model="searchQuery"
|
||||
@input="courses.reload()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CreateCourse',
|
||||
@@ -36,10 +26,17 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="">
|
||||
<div
|
||||
v-if="courses.data.length == 0 && courses.list.loading"
|
||||
class="p-5 text-base text-gray-700"
|
||||
>
|
||||
{{ __('Loading Courses...') }}
|
||||
</div>
|
||||
<Tabs
|
||||
v-else
|
||||
v-model="tabIndex"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
:tabs="makeTabs"
|
||||
:tabs="tabs"
|
||||
>
|
||||
<template #tab="{ tab, selected }">
|
||||
<div>
|
||||
@@ -68,8 +65,8 @@
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.current_lesson.split('-')[0],
|
||||
lessonNumber: course.current_lesson.split('-')[1],
|
||||
chapterNumber: course.current_lesson.split('.')[0],
|
||||
lessonNumber: course.current_lesson.split('.')[1],
|
||||
},
|
||||
}
|
||||
: course.membership
|
||||
@@ -107,75 +104,61 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Tabs,
|
||||
Badge,
|
||||
Button,
|
||||
FormControl,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { createListResource, Breadcrumbs, Tabs, Badge, Button } from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { ref, computed, inject } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const courses = createResource({
|
||||
const courses = createListResource({
|
||||
type: 'list',
|
||||
doctype: 'LMS Course',
|
||||
cache: ['courses', user?.data?.email],
|
||||
url: 'lms.lms.utils.get_courses',
|
||||
cache: ['courses', user.data?.email],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
let tabs
|
||||
const tabs = [
|
||||
{
|
||||
label: 'All',
|
||||
courses: computed(() => courses.data?.live || []),
|
||||
count: computed(() => courses.data?.live?.length),
|
||||
},
|
||||
{
|
||||
label: 'Upcoming',
|
||||
courses: computed(() => courses.data?.upcoming),
|
||||
count: computed(() => courses.data?.upcoming?.length),
|
||||
},
|
||||
]
|
||||
|
||||
const makeTabs = computed(() => {
|
||||
tabs = []
|
||||
addToTabs('Live')
|
||||
addToTabs('New')
|
||||
addToTabs('Upcoming')
|
||||
|
||||
if (user.data) {
|
||||
addToTabs('Enrolled')
|
||||
|
||||
if (
|
||||
user.data.is_moderator ||
|
||||
user.data.is_instructor ||
|
||||
courses.data?.created?.length
|
||||
) {
|
||||
addToTabs('Created')
|
||||
}
|
||||
|
||||
if (user.data.is_moderator) {
|
||||
addToTabs('Under Review')
|
||||
}
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
const addToTabs = (label) => {
|
||||
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
|
||||
if (user.data) {
|
||||
tabs.push({
|
||||
label,
|
||||
courses: computed(() => courses),
|
||||
count: computed(() => courses.length),
|
||||
label: 'Enrolled',
|
||||
courses: computed(() => courses.data?.enrolled),
|
||||
count: computed(() => courses.data?.enrolled?.length),
|
||||
})
|
||||
}
|
||||
|
||||
const getCourses = (type) => {
|
||||
if (searchQuery.value) {
|
||||
let query = searchQuery.value.toLowerCase()
|
||||
return courses.data[type].filter(
|
||||
(course) =>
|
||||
course.title.toLowerCase().includes(query) ||
|
||||
course.short_introduction.toLowerCase().includes(query) ||
|
||||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
||||
)
|
||||
if (
|
||||
user.data.is_moderator ||
|
||||
user.data.is_instructor ||
|
||||
courses.data?.created?.length
|
||||
) {
|
||||
tabs.push({
|
||||
label: 'Created',
|
||||
courses: computed(() => courses.data?.created),
|
||||
count: computed(() => courses.data?.created?.length),
|
||||
})
|
||||
}
|
||||
|
||||
if (user.data.is_moderator) {
|
||||
tabs.push({
|
||||
label: 'Under Review',
|
||||
courses: computed(() => courses.data?.under_review),
|
||||
count: computed(() => courses.data?.under_review?.length),
|
||||
})
|
||||
}
|
||||
return courses.data[type]
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center mt-3 md:mt-0">
|
||||
<router-link
|
||||
v-if="courseResource.data"
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseResource.data.name },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<span>
|
||||
{{ __('View Course') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||
<span>
|
||||
{{ __('Save') }}
|
||||
@@ -86,14 +99,13 @@
|
||||
:label="__('Preview Video')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-xs text-gray-600">
|
||||
{{ __('Tags') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
v-for="tag in course.tags.split(', ')"
|
||||
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -102,64 +114,30 @@
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-3"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.published"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="course.published_on"
|
||||
:label="__('Published On')"
|
||||
type="date"
|
||||
class="mb-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.upcoming"
|
||||
:label="__('Upcoming')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.featured"
|
||||
:label="__('Featured')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.disable_self_learning"
|
||||
:label="__('Disable Self Enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.published"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.upcoming"
|
||||
:label="__('Upcoming')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.disable_self_learning"
|
||||
:label="__('Disable Self Enrollment')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
@@ -187,7 +165,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l pt-5">
|
||||
<div class="border-l px-5 pt-5">
|
||||
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
|
||||
<CourseOutline
|
||||
v-if="courseResource.data"
|
||||
:courseName="courseResource.data.name"
|
||||
@@ -204,34 +183,20 @@ import {
|
||||
TextEditor,
|
||||
Button,
|
||||
createResource,
|
||||
createDocumentResource,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
computed,
|
||||
ref,
|
||||
reactive,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import {
|
||||
convertToTitleCase,
|
||||
showToast,
|
||||
getFileSize,
|
||||
updateDocumentTitle,
|
||||
} from '../utils'
|
||||
import { inject, onMounted, computed, ref, reactive, watch } from 'vue'
|
||||
import { convertToTitleCase, showToast, getFileSize } from '../utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const newTag = ref('')
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -247,44 +212,20 @@ const course = reactive({
|
||||
course_image: null,
|
||||
tags: '',
|
||||
published: false,
|
||||
published_on: '',
|
||||
featured: false,
|
||||
upcoming: false,
|
||||
disable_self_learning: false,
|
||||
enable_certification: false,
|
||||
paid_course: false,
|
||||
course_price: '',
|
||||
currency: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
props.courseName == 'new' &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (props.courseName !== 'new') {
|
||||
courseResource.reload()
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
submitCourse()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const courseCreationResource = createResource({
|
||||
@@ -293,10 +234,7 @@ const courseCreationResource = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Course',
|
||||
image: course.course_image?.file_url || '',
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
image: course.course_image.file_url,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
@@ -311,10 +249,7 @@ const courseEditResource = createResource({
|
||||
doctype: 'LMS Course',
|
||||
name: values.course,
|
||||
fieldname: {
|
||||
image: course.course_image?.file_url || '',
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
image: course.course_image.file_url,
|
||||
...course,
|
||||
},
|
||||
}
|
||||
@@ -332,20 +267,13 @@ const courseResource = createResource({
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'upcoming',
|
||||
'disable_self_learning',
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
@@ -353,7 +281,6 @@ const courseResource = createResource({
|
||||
}
|
||||
|
||||
if (data.image) imageResource.reload({ image: data.image })
|
||||
check_permission()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -370,6 +297,12 @@ const imageResource = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const getTags = computed(() => {
|
||||
return courseResource.doc?.tags
|
||||
? courseResource.doc.tags.split(', ')
|
||||
: tags.value?.split(', ')
|
||||
})
|
||||
|
||||
const submitCourse = () => {
|
||||
if (courseResource.data) {
|
||||
courseEditResource.submit(
|
||||
@@ -395,7 +328,7 @@ const submitCourse = () => {
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
showToast(err)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -431,7 +364,7 @@ watch(
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
@@ -459,21 +392,6 @@ const removeImage = () => {
|
||||
course.course_image = null
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
let user_is_instructor = false
|
||||
if (user.data?.is_moderator) return
|
||||
|
||||
instructors.value.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user_is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
@@ -493,13 +411,4 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Create a Course',
|
||||
description: 'Create or edit a course for your learning system.',
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="grid md:grid-cols-[75%,25%] h-screen">
|
||||
<div class="grid md:grid-cols-[75%,25%] h-full">
|
||||
<div class="border-r">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
|
||||
@@ -43,7 +43,7 @@
|
||||
<div
|
||||
v-show="openInstructorEditor"
|
||||
id="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-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
|
||||
class="py-3"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,10 +52,7 @@
|
||||
<label class="block font-medium text-gray-600 mb-1">
|
||||
{{ __('Content') }}
|
||||
</label>
|
||||
<div
|
||||
id="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-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
|
||||
></div>
|
||||
<div id="content" class="py-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,17 +66,26 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
createResource,
|
||||
Button,
|
||||
createDocumentResource,
|
||||
} from 'frappe-ui'
|
||||
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import { createToast } from '../utils'
|
||||
import LessonPlugins from '@/components/LessonPlugins.vue'
|
||||
import { getEditorTools } from '../utils'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const user = inject('$user')
|
||||
const openInstructorEditor = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -97,7 +103,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
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')
|
||||
@@ -108,7 +114,6 @@ const renderEditor = (holder) => {
|
||||
return new EditorJS({
|
||||
holder: holder,
|
||||
tools: getEditorTools(),
|
||||
autofocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -428,117 +433,17 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Lesson Editor',
|
||||
description: 'Create and edit lessons for your course',
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.embed-tool__caption,
|
||||
.cdx-simple-image__caption {
|
||||
.embed-tool__caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ce-toolbar__actions {
|
||||
right: 108%;
|
||||
}
|
||||
|
||||
.ce-block__content {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ce-toolbar__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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="job.data" class="w-3/4 mx-auto">
|
||||
<div class="p-4">
|
||||
<div v-if="job.data">
|
||||
<div class="p-5 sm:p-5">
|
||||
<div class="flex mb-4">
|
||||
<img
|
||||
:src="job.data.company_logo"
|
||||
@@ -59,44 +59,29 @@
|
||||
:alt="job.data.company_name"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-2xl font-semibold mb-4">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
{{ job.data.job_title }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Building2 class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.company_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div>
|
||||
{{ __('posted by') }}
|
||||
<span class="font-medium">{{ job.data.company_name }}</span>
|
||||
{{ __('on') }}
|
||||
<span class="font-medium">{{
|
||||
dayjs(job.data.creation).format('DD MMM YYYY')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center mt-2">
|
||||
<Badge :label="job.data.type" theme="green" size="lg" />
|
||||
<Badge
|
||||
:label="job.data.location"
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="ml-4"
|
||||
>
|
||||
<template #prefix>
|
||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<ClipboardType class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<CalendarDays class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{
|
||||
dayjs(job.data.creation).format('DD MMM YYYY')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 h-fit">
|
||||
<div
|
||||
v-if="applicationCount.data"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
||||
<span
|
||||
>{{ applicationCount.data }}
|
||||
{{ __('applications received') }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,19 +99,10 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { Badge, Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { inject, ref, onMounted } from 'vue'
|
||||
import { MapPin, SendHorizonal, Pencil } from 'lucide-vue-next'
|
||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||
import {
|
||||
MapPin,
|
||||
SendHorizonal,
|
||||
Pencil,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ClipboardType,
|
||||
SquareUserRound,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -150,7 +126,6 @@ const job = createResource({
|
||||
if (user.data?.name) {
|
||||
jobApplication.submit()
|
||||
}
|
||||
applicationCount.submit()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -167,18 +142,6 @@ const jobApplication = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const applicationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Job Application',
|
||||
filters: {
|
||||
job: job.data?.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const openApplicationModal = () => {
|
||||
showApplicationModal.value = true
|
||||
}
|
||||
@@ -186,13 +149,4 @@ const openApplicationModal = () => {
|
||||
const redirectToLogin = (job) => {
|
||||
window.location.href = `/login?redirect-to=/job-openings/${job}`
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: job.data?.job_title,
|
||||
description: job.data?.description,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="jobs.data?.length">
|
||||
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
||||
<div v-for="job in jobs.data">
|
||||
<div v-if="jobs.data">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 p-5">
|
||||
<div v-if="jobs.data.length" v-for="job in jobs.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'JobDetail',
|
||||
@@ -41,17 +41,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
|
||||
{{ __('No jobs posted') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { inject, computed } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import JobCard from '@/components/JobCard.vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -60,13 +56,4 @@ const jobs = createResource({
|
||||
cache: ['jobs'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Jobs',
|
||||
description: 'An open job board for the community',
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||
<div class="grid md:grid-cols-[70%,30%] h-full">
|
||||
<div
|
||||
v-if="lesson.data.no_preview"
|
||||
class="border-r text-center pt-10 px-5 md:px-0 pb-10"
|
||||
class="border-r-2 text-center pt-10 px-5 md:px-0 pb-10"
|
||||
>
|
||||
<p class="mb-4">
|
||||
{{
|
||||
@@ -18,18 +18,14 @@
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||
>
|
||||
<Button variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-else @click="redirectToLogin()">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="border-r container pt-5 pb-10 px-5">
|
||||
<div v-else class="border-r-2 container pt-5 pb-10 px-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ lesson.data.title }}
|
||||
@@ -90,23 +86,12 @@
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: courseName },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Back to Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
class="mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors.length > 1,
|
||||
}"
|
||||
@@ -116,7 +101,17 @@
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="lesson.data.instructors" />
|
||||
<span v-if="lesson.data.instructors.length == 1">
|
||||
{{ lesson.data.instructors[0].full_name }}
|
||||
</span>
|
||||
<span v-if="lesson.data.instructors.length == 2">
|
||||
{{ lesson.data.instructors[0].first_name }} and
|
||||
{{ lesson.data.instructors[1].first_name }}
|
||||
</span>
|
||||
<span v-if="lesson.data.instructors.length > 2">
|
||||
{{ lesson.data.instructors[0].first_name }} and
|
||||
{{ lesson.data.instructors.length - 1 }} others
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
@@ -157,7 +152,7 @@
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<Discussions
|
||||
v-if="allowDiscussions"
|
||||
v-if="allowDiscussions()"
|
||||
:title="'Questions'"
|
||||
:doctype="'Course Lesson'"
|
||||
:docname="lesson.data.name"
|
||||
@@ -166,50 +161,45 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
<div class="bg-gray-50 py-5 px-2 border-b">
|
||||
<div class="bg-gray-50 p-5 border-b-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ lesson.data.course_title }}
|
||||
</div>
|
||||
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
||||
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
|
||||
{{ Math.ceil(lesson.data.membership.progress) }}% completed
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
<div
|
||||
v-if="user && lesson.data.membership"
|
||||
:progress="lessonProgress"
|
||||
/>
|
||||
class="w-full bg-gray-200 rounded-full h-1 my-2"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-900 h-1 rounded-full"
|
||||
:style="{
|
||||
width: Math.ceil(lesson.data.membership.progress) + '%',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseOutline
|
||||
:courseName="courseName"
|
||||
:key="chapterNumber"
|
||||
:getProgress="lesson.data.membership ? true : false"
|
||||
/>
|
||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, watch, ref, inject, createApp } from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||
import { getEditorTools } from '../utils'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const route = useRoute()
|
||||
const allowDiscussions = ref(false)
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const lessonProgress = ref(0)
|
||||
const timer = ref(0)
|
||||
let timerInterval
|
||||
let editor, instructorEditor
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -226,10 +216,6 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
})
|
||||
|
||||
const lesson = createResource({
|
||||
url: 'lms.lms.utils.get_lesson',
|
||||
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
||||
@@ -242,29 +228,25 @@ const lesson = createResource({
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
lessonProgress.value = data.membership?.progress
|
||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||
if (data.membership)
|
||||
current_lesson.submit({
|
||||
name: data.membership.name,
|
||||
lesson_name: data.name,
|
||||
})
|
||||
markProgress(data)
|
||||
|
||||
if (data.content) editor = renderEditor('editor', data.content)
|
||||
if (data.instructor_content?.blocks?.length)
|
||||
instructorEditor.value = renderEditor(
|
||||
instructorEditor = renderEditor(
|
||||
'instructor-content',
|
||||
data.instructor_content
|
||||
)
|
||||
editor.value?.isReady.then(() => {
|
||||
checkIfDiscussionsAllowed()
|
||||
})
|
||||
|
||||
if (!editor.value && data.body) {
|
||||
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
||||
const hasQuiz = quizRegex.test(data.body)
|
||||
if (!hasQuiz) allowDiscussions.value = true
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const renderEditor = (holder, content) => {
|
||||
// empty the holder
|
||||
if (document.getElementById(holder))
|
||||
document.getElementById(holder).innerHTML = ''
|
||||
document.getElementById(holder).innerHTML = ''
|
||||
return new EditorJS({
|
||||
holder: holder,
|
||||
tools: getEditorTools(),
|
||||
@@ -274,12 +256,22 @@ const renderEditor = (holder, content) => {
|
||||
})
|
||||
}
|
||||
|
||||
const markProgress = () => {
|
||||
if (user.data && !lesson.data?.progress) {
|
||||
progress.submit()
|
||||
}
|
||||
const markProgress = (data) => {
|
||||
if (user.data && !data.progress) progress.submit()
|
||||
}
|
||||
|
||||
const current_lesson = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Enrollment',
|
||||
name: values.name,
|
||||
fieldname: 'current_lesson',
|
||||
value: values.lesson_name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const progress = createResource({
|
||||
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
||||
makeParams() {
|
||||
@@ -288,9 +280,6 @@ const progress = createResource({
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
lessonProgress.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
@@ -319,48 +308,21 @@ watch(
|
||||
[newChapterNumber, newLessonNumber],
|
||||
[oldChapterNumber, oldLessonNumber]
|
||||
) => {
|
||||
if (newChapterNumber || newLessonNumber) {
|
||||
editor.value = null
|
||||
instructorEditor.value = null
|
||||
allowDiscussions.value = false
|
||||
if (newChapterNumber && newLessonNumber) {
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
startTimer()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
if (timer.value == 30) {
|
||||
clearInterval(timerInterval)
|
||||
markProgress()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timerInterval)
|
||||
})
|
||||
|
||||
const checkIfDiscussionsAllowed = () => {
|
||||
let quizPresent = false
|
||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||
if (block.type === 'quiz') quizPresent = true
|
||||
})
|
||||
|
||||
if (
|
||||
!quizPresent &&
|
||||
(lesson.data?.membership ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor)
|
||||
const allowDiscussions = () => {
|
||||
return (
|
||||
lesson.data?.membership ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor
|
||||
)
|
||||
allowDiscussions.value = true
|
||||
}
|
||||
|
||||
const allowEdit = () => {
|
||||
@@ -374,19 +336,6 @@ const allowInstructorContent = () => {
|
||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: lesson.data?.title,
|
||||
description: lesson.data?.course,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
@@ -440,101 +389,11 @@ updateDocumentTitle(pageMeta)
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codex-editor__redactor {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="markAllAsRead.submit"
|
||||
:loading="markAllAsRead.loading"
|
||||
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
|
||||
>
|
||||
{{ __('Mark all as read') }}
|
||||
</Button>
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
|
||||
<div
|
||||
v-if="notifications?.length"
|
||||
v-for="log in notifications"
|
||||
class="flex items-center py-2 justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
|
||||
<div class="notification" v-html="log.subject"></div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
v-if="log.link"
|
||||
:to="log.link"
|
||||
@click="markAsRead.submit({ name: log.name })"
|
||||
class="text-gray-600 font-medium text-sm hover:text-gray-700"
|
||||
>
|
||||
{{ __('View') }}
|
||||
</Link>
|
||||
<Tooltip :text="__('Mark as read')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@click="markAsRead.submit({ name: log.name })"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-600">
|
||||
{{ __('Nothing to see here.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
TabButtons,
|
||||
Button,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, ref, onMounted } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const socket = inject('$socket')
|
||||
const allUsers = inject('$allUsers')
|
||||
const activeTab = ref('Unread')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) router.push({ name: 'Courses' })
|
||||
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unReadNotifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
const notifications = computed(() => {
|
||||
return activeTab.value === 'Unread'
|
||||
? unReadNotifications.data
|
||||
: readNotifications.data
|
||||
})
|
||||
|
||||
const unReadNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 0,
|
||||
},
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
cache: 'Unread Notifications',
|
||||
})
|
||||
|
||||
const readNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 1,
|
||||
},
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
cache: 'Read Notifications',
|
||||
})
|
||||
|
||||
const markAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_as_read',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
unReadNotifications.reload()
|
||||
readNotifications.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const markAllAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_all_as_read',
|
||||
onSuccess(data) {
|
||||
unReadNotifications.reload()
|
||||
readNotifications.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Notifications',
|
||||
route: {
|
||||
name: 'Notifications',
|
||||
},
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Notifications',
|
||||
description: 'All your notifications in one place.',
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.notification strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
.notification b {
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -1,220 +1 @@
|
||||
<template>
|
||||
<NoPermission v-if="!$user.data" />
|
||||
<div v-else-if="profile.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="group relative h-[130px] w-full">
|
||||
<img
|
||||
v-if="profile.data.cover_image"
|
||||
:src="profile.data.cover_image"
|
||||
class="h-[130px] w-full object-cover object-center"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="{ 'bg-gray-100': !profile.data.cover_image }"
|
||||
class="h-[130px] w-full"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 left-1/2 mb-4 flex -translate-x-1/2 space-x-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
|
||||
v-if="isSessionUser()"
|
||||
>
|
||||
<EditCoverImage
|
||||
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
|
||||
>
|
||||
<template v-slot="{ togglePopover }">
|
||||
<Button variant="outline" @click="togglePopover()">
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</template>
|
||||
</EditCoverImage>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
|
||||
<div class="flex flex-col md:flex-row items-center">
|
||||
<div>
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
:src="profile.data.user_image"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:user="profile.data"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-6">
|
||||
<h2 class="mt-2 text-3xl font-semibold text-gray-900">
|
||||
{{ profile.data.full_name }}
|
||||
</h2>
|
||||
<div class="mt-2 text-base text-gray-700">
|
||||
{{ profile.data.headline }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isSessionUser()"
|
||||
class="mt-3 sm:mt-0 md:ml-auto"
|
||||
@click="editProfile()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Edit Profile') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-6">
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="getTabButtons()"
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
<router-view :profile="profile" />
|
||||
</div>
|
||||
</div>
|
||||
<EditProfile
|
||||
v-model="showProfileModal"
|
||||
v-model:reloadProfile="profile"
|
||||
:profile="profile"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource, Button, TabButtons } from 'frappe-ui'
|
||||
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import { convertToTitleCase, updateDocumentTitle } from '@/utils'
|
||||
import EditProfile from '@/components/Modals/EditProfile.vue'
|
||||
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
const $user = inject('$user')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('')
|
||||
const showProfileModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if ($user.data) profile.reload()
|
||||
|
||||
setActiveTab()
|
||||
})
|
||||
|
||||
const profile = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'User',
|
||||
filters: {
|
||||
username: props.username,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const coverImage = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'User',
|
||||
name: profile.data?.name,
|
||||
fieldname: 'cover_image',
|
||||
value: values.url,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
profile.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const setActiveTab = () => {
|
||||
let fragments = route.path.split('/')
|
||||
let sections = ['certificates', 'roles', 'evaluations']
|
||||
sections.forEach((section) => {
|
||||
if (fragments.includes(section)) {
|
||||
activeTab.value = convertToTitleCase(section)
|
||||
}
|
||||
})
|
||||
if (!activeTab.value) activeTab.value = 'About'
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (activeTab.value) {
|
||||
let route = {
|
||||
About: { name: 'ProfileAbout' },
|
||||
Certificates: { name: 'ProfileCertificates' },
|
||||
Roles: { name: 'ProfileRoles' },
|
||||
Evaluations: { name: 'ProfileEvaluator' },
|
||||
}[activeTab.value]
|
||||
router.push(route)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.username,
|
||||
() => {
|
||||
profile.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const editProfile = () => {
|
||||
showProfileModal.value = true
|
||||
}
|
||||
|
||||
const isSessionUser = () => {
|
||||
return $user.data?.email === profile.data?.email
|
||||
}
|
||||
|
||||
const getTabButtons = () => {
|
||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||
if (isSessionUser() && $user.data?.is_evaluator)
|
||||
buttons.push({ label: 'Evaluations' })
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'People',
|
||||
},
|
||||
{
|
||||
label: profile.data?.full_name,
|
||||
route: {
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: user.doc?.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: profile.data?.full_name,
|
||||
description: profile.data?.headline,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<template></template>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-7 mb-10">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('About') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="profile.data.bio"
|
||||
v-html="profile.data.bio"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
<div v-else class="text-gray-700 text-sm italic">
|
||||
{{ __('No introduction') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7 mb-10" v-if="badges.data?.length">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Achievements') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-5 gap-4">
|
||||
<div v-for="badge in badges.data">
|
||||
<Popover trigger="hover" :leaveDelay="Number(0.01)">
|
||||
<template #target>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="h-[80px]"
|
||||
/>
|
||||
<div
|
||||
v-if="badge.count > 1"
|
||||
class="flex items-end bg-gray-100 p-2 text-xs font-semibold rounded-full absolute right-0 bottom-0"
|
||||
>
|
||||
<span>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
{{ badge.count }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="w-[250px] text-base">
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="bg-gray-100 rounded-t-md"
|
||||
/>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
{{ badge.badge }}
|
||||
</div>
|
||||
<div class="leading-5 mb-4">
|
||||
{{ badge.badge_description }}
|
||||
</div>
|
||||
<div class="flex flex-col mb-4">
|
||||
<span class="text-xs text-gray-700 font-medium mb-1">
|
||||
{{ __('Issued on') }}:
|
||||
</span>
|
||||
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-gray-700 font-medium mb-1">
|
||||
{{ __('Share on') }}:
|
||||
</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="shareOnSocial(badge, 'LinkedIn')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LinkedinIcon class="h-3 w-3 text-gray-700" />
|
||||
</template>
|
||||
<span class="text-xs">
|
||||
{{ __('LinkedIn') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="shareOnSocial(badge, 'Twitter')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Twitter class="h-3 w-3 text-gray-700" />
|
||||
</template>
|
||||
<span class="text-xs">
|
||||
{{ __('Twitter') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { X, LinkedinIcon, Twitter } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { branding } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const badges = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
params: {
|
||||
doctype: 'LMS Badge Assignment',
|
||||
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
|
||||
filters: {
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
let finalBadges = []
|
||||
let groupedBadges = Object.groupBy(data, ({ badge }) => badge)
|
||||
for (let badge in groupedBadges) {
|
||||
let badgeData = groupedBadges[badge][0]
|
||||
badgeData.count = groupedBadges[badge].length
|
||||
finalBadges.push(badgeData)
|
||||
}
|
||||
return finalBadges
|
||||
},
|
||||
})
|
||||
|
||||
const shareOnSocial = (badge, medium) => {
|
||||
let shareUrl
|
||||
const url = encodeURIComponent(
|
||||
`${window.location.origin}/lms/badges/${badge.badge}/${props.profile.data?.email}`
|
||||
)
|
||||
const summary = `I am happy to announce that I earned the ${
|
||||
badge.badge
|
||||
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
||||
branding.data?.brand_name
|
||||
}.`
|
||||
|
||||
if (medium == 'LinkedIn')
|
||||
shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}`
|
||||
else if (medium == 'Twitter')
|
||||
shareUrl = `https://twitter.com/intent/tweet?text=${summary}&url=${url}`
|
||||
|
||||
window.open(shareUrl, '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-7 mb-10">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Certificates') }}
|
||||
</h2>
|
||||
<div class="grid grod-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="certificate in certificates.data"
|
||||
:key="certificate.name"
|
||||
class="bg-white shadow rounded-lg p-3 cursor-pointer"
|
||||
@click="openCertificate(certificate)"
|
||||
>
|
||||
<div class="font-medium leading-5">
|
||||
{{ certificate.course_title }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
|
||||
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const certificates = createResource({
|
||||
url: 'lms.lms.api.get_certificates',
|
||||
params: {
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openCertificate = (certificate) => {
|
||||
window.open(
|
||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||
certificate.name
|
||||
}&format=${encodeURIComponent(certificate.template)}`
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,323 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-7 mb-20">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ __('My availability') }}
|
||||
</h2>
|
||||
|
||||
<div class="">
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-gray-700 mb-4"
|
||||
>
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Start Time') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('End Time') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="evaluator.data"
|
||||
v-for="slot in evaluator.data.slots.schedule"
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
v-model="slot.day"
|
||||
@focusout.stop="update(slot.name, 'day', slot.day)"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.start_time"
|
||||
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.end_time"
|
||||
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
|
||||
/>
|
||||
<X
|
||||
@click="deleteRow(slot.name)"
|
||||
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-red-100 hidden group-hover:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
|
||||
v-show="showSlotsTemplate"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
v-model="newSlot.day"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.start_time"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.end_time"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="showSlotsTemplate = 1">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Add Slot') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="my-10">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_from',
|
||||
value: from,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('To')"
|
||||
v-model="to"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_to',
|
||||
value: to,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ __('My calendar') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
|
||||
class="flex items-center bg-green-100 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
{{ __('Your calendar is set.') }}
|
||||
</div>
|
||||
<Button @click="() => authorizeCalendar.submit()">
|
||||
{{ __('Authorize Google Calendar Access') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, FormControl, Button } from 'frappe-ui'
|
||||
import { computed, reactive, ref, onMounted, inject } from 'vue'
|
||||
import { showToast, convertToTitleCase } from '@/utils'
|
||||
import { Plus, X, Check } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (user.data?.name !== props.profile.data?.name) {
|
||||
window.location.href = `/user/${props.profile.data?.username}`
|
||||
}
|
||||
})
|
||||
|
||||
const showSlotsTemplate = ref(0)
|
||||
const from = ref(null)
|
||||
const to = ref(null)
|
||||
|
||||
const newSlot = reactive({
|
||||
day: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
})
|
||||
|
||||
const evaluator = createResource({
|
||||
url: 'lms.lms.api.get_evaluator_details',
|
||||
params: {
|
||||
evaluator: props.profile.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
|
||||
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
|
||||
},
|
||||
})
|
||||
|
||||
const createSlot = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Evaluator Schedule',
|
||||
parent: evaluator.data?.slots.name,
|
||||
parentfield: 'schedule',
|
||||
parenttype: 'Course Evaluator',
|
||||
...newSlot,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Slot added successfully', 'check')
|
||||
evaluator.reload()
|
||||
showSlotsTemplate.value = 0
|
||||
newSlot.day = ''
|
||||
newSlot.start_time = ''
|
||||
newSlot.end_time = ''
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
})
|
||||
|
||||
const updateSlot = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Evaluator Schedule',
|
||||
name: values.name,
|
||||
fieldname: values.field,
|
||||
value: values.value,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Availability updated successfully', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteSlot = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Evaluator Schedule',
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Slot deleted successfully', 'check')
|
||||
evaluator.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
})
|
||||
|
||||
const updateUnavailability = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Course Evaluator',
|
||||
name: evaluator.data?.slots.name,
|
||||
fieldname: values.field,
|
||||
value: values.value,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Unavailability updated successfully', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
})
|
||||
|
||||
const update = (name, field, value) => {
|
||||
updateSlot.submit(
|
||||
{
|
||||
name,
|
||||
field,
|
||||
value,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
if (!value) {
|
||||
return `Please enter a value for ${convertToTitleCase(field)}`
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const add = () => {
|
||||
if (!newSlot.day || !newSlot.start_time || !newSlot.end_time) {
|
||||
return
|
||||
}
|
||||
createSlot.submit()
|
||||
}
|
||||
|
||||
const deleteRow = (name) => {
|
||||
deleteSlot.submit({ name })
|
||||
}
|
||||
|
||||
const authorizeCalendar = createResource({
|
||||
url: 'frappe.integrations.doctype.google_calendar.google_calendar.authorize_access',
|
||||
makeParams() {
|
||||
return {
|
||||
g_calendar: evaluator.data?.calendar,
|
||||
reauthorize: 1,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
window.open(data.url)
|
||||
},
|
||||
})
|
||||
|
||||
const days = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Monday',
|
||||
value: 'Monday',
|
||||
},
|
||||
{
|
||||
label: 'Tuesday',
|
||||
value: 'Tuesday',
|
||||
},
|
||||
{
|
||||
label: 'Wednesday',
|
||||
value: 'Wednesday',
|
||||
},
|
||||
{
|
||||
label: 'Thursday',
|
||||
value: 'Thursday',
|
||||
},
|
||||
{
|
||||
label: 'Friday',
|
||||
value: 'Friday',
|
||||
},
|
||||
{
|
||||
label: 'Saturday',
|
||||
value: 'Saturday',
|
||||
},
|
||||
{
|
||||
label: 'Sunday',
|
||||
value: 'Sunday',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,98 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-7">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Settings') }}
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
|
||||
>
|
||||
<FormControl
|
||||
:label="__('Moderator')"
|
||||
v-model="moderator"
|
||||
type="checkbox"
|
||||
@change.stop="changeRole('moderator')"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Course Creator')"
|
||||
v-model="course_creator"
|
||||
type="checkbox"
|
||||
@change.stop="changeRole('course_creator')"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Evaluator')"
|
||||
v-model="batch_evaluator"
|
||||
type="checkbox"
|
||||
@change.stop="changeRole('batch_evaluator')"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Student')"
|
||||
v-model="lms_student"
|
||||
type="checkbox"
|
||||
@change.stop="changeRole('lms_student')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { showToast, convertToTitleCase } from '@/utils'
|
||||
|
||||
const moderator = ref(false)
|
||||
const course_creator = ref(false)
|
||||
const batch_evaluator = ref(false)
|
||||
const lms_student = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const roles = createResource({
|
||||
url: 'lms.lms.utils.get_roles',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: props.profile.data?.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
let roles = [
|
||||
'moderator',
|
||||
'course_creator',
|
||||
'batch_evaluator',
|
||||
'lms_student',
|
||||
]
|
||||
for (let role of roles) {
|
||||
if (data[role]) eval(role).value = true
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const updateRole = createResource({
|
||||
url: 'lms.overrides.user.save_role',
|
||||
makeParams(values) {
|
||||
return {
|
||||
user: props.profile.data?.name,
|
||||
role: values.role,
|
||||
value: values.value,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const changeRole = (role) => {
|
||||
updateRole.submit(
|
||||
{
|
||||
role: convertToTitleCase(role.split('_').join(' ')),
|
||||
value: eval(role).value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast('Success', 'Role updated successfully', 'check')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<!-- Details -->
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-2">
|
||||
<FormControl v-model="quiz.title" :label="__('Title')" />
|
||||
<FormControl
|
||||
v-model="quiz.max_attempts"
|
||||
:label="__('Maximun Attempts')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<FormControl v-model="quiz.total_marks" :label="__('Total Marks')" />
|
||||
<FormControl
|
||||
v-model="quiz.passing_percentage"
|
||||
:label="__('Passing Percentage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 my-4">
|
||||
<FormControl
|
||||
v-model="quiz.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm font-semibold">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
:columns="questionColumns"
|
||||
:rows="quiz.questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in quiz.questions"
|
||||
@click="openQuestionModal(row.question)"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<Question v-model="showQuestionModal" :questionName="currentQuestion" />
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createDocumentResource,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
|
||||
const showQuestionModal = ref(false)
|
||||
const currentQuestion = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const quiz = reactive({
|
||||
title: '',
|
||||
total_marks: '',
|
||||
passing_percentage: '',
|
||||
max_attempts: 0,
|
||||
limit_questions_to: 0,
|
||||
show_answers: true,
|
||||
show_submission_history: false,
|
||||
shuffle_questions: false,
|
||||
questions: [],
|
||||
})
|
||||
|
||||
const quizDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz',
|
||||
name: props.quizID,
|
||||
auto: true,
|
||||
cache: ['quiz', props.quizID],
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
|
||||
})
|
||||
|
||||
let checkboxes = [
|
||||
'show_answers',
|
||||
'show_submission_history',
|
||||
'shuffle_questions',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
quiz[key] = quiz[key] ? true : false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const questionColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('ID'),
|
||||
key: 'question',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
label: __('Question'),
|
||||
key: __('question_detail'),
|
||||
width: '60%',
|
||||
},
|
||||
{
|
||||
label: __('Marks'),
|
||||
key: 'marks',
|
||||
width: '10%',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const openQuestionModal = (question = null) => {
|
||||
console.log('called')
|
||||
console.log(question)
|
||||
currentQuestion.value = question
|
||||
showQuestionModal.value = true
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: __('Quizzes'),
|
||||
route: {
|
||||
name: 'Quizzes',
|
||||
},
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4"/>
|
||||
</template>
|
||||
{{ __('New Quiz') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
|
||||
<ListView
|
||||
:columns="quizColumns"
|
||||
:rows="quizzes.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: false }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<router-link
|
||||
v-for="row in quizzes.data"
|
||||
:to="{
|
||||
name: 'QuizCreation',
|
||||
params: {
|
||||
quizID: row.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row" />
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createListResource,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const quizFilter = computed(() => {
|
||||
if (user.data?.is_moderator) return {}
|
||||
return {
|
||||
owner: user.data?.name,
|
||||
}
|
||||
})
|
||||
|
||||
const quizzes = createListResource({
|
||||
doctype: 'LMS Quiz',
|
||||
filters: quizFilter,
|
||||
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
|
||||
auto: true,
|
||||
cache: ['quizzes', user.data?.name],
|
||||
onSuccess(data) {
|
||||
data.forEach((row) => {})
|
||||
},
|
||||
})
|
||||
|
||||
const quizColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Title'),
|
||||
key: 'title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: __('Total Marks'),
|
||||
key: 'total_marks',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: __('Passing Percentage'),
|
||||
key: 'passing_percentage',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Quizzes'),
|
||||
route: {
|
||||
name: 'Quizzes',
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -6,17 +6,17 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="chartDetails.data" class="p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.courses) }}
|
||||
{{ chartDetails.data.courses }}
|
||||
</div>
|
||||
<div class="text-gray-700">
|
||||
{{ __('Courses') }}
|
||||
{{ __('Published Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,10 +26,10 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.users) }}
|
||||
{{ chartDetails.data.users }}
|
||||
</div>
|
||||
<div class="text-gray-700">
|
||||
{{ __('Signups') }}
|
||||
{{ __('Total Signups') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,10 +39,10 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.enrollments) }}
|
||||
{{ chartDetails.data.enrollments }}
|
||||
</div>
|
||||
<div class="text-gray-700">
|
||||
{{ __('Enrollments') }}
|
||||
{{ __('Enrolled Users') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,10 +52,10 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.completions) }}
|
||||
{{ chartDetails.data.completions }}
|
||||
</div>
|
||||
<div class="text-gray-700">
|
||||
{{ __('Completions') }}
|
||||
{{ __('Courses Completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,10 +65,10 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.lesson_completions) }}
|
||||
{{ chartDetails.data.lesson_completions }}
|
||||
</div>
|
||||
<div class="text-gray-700">
|
||||
{{ __('Milestones') }}
|
||||
{{ __('Lessons Completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,8 +109,6 @@
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { formatNumber } from '@/utils'
|
||||
import { Line, Pie } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -198,7 +196,7 @@ const courseCompletion = createResource({
|
||||
|
||||
const signupChartOptions = () => {
|
||||
let options = chartOptions(false)
|
||||
options.plugins.title.text = 'Signups'
|
||||
options.plugins.title.text = 'New Signups'
|
||||
options.borderColor = '#4563f0'
|
||||
options.backgroundColor = (ctx) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
@@ -214,7 +212,7 @@ const signupChartOptions = () => {
|
||||
|
||||
const enrollmentChartOptions = () => {
|
||||
let options = chartOptions(false)
|
||||
options.plugins.title.text = 'Enrollments'
|
||||
options.plugins.title.text = 'Course Enrollments'
|
||||
options.borderColor = '#4563f0'
|
||||
options.backgroundColor = (ctx) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
@@ -230,7 +228,7 @@ const enrollmentChartOptions = () => {
|
||||
|
||||
const lessonChartOptions = () => {
|
||||
let options = chartOptions(false)
|
||||
options.plugins.title.text = 'Milestones'
|
||||
options.plugins.title.text = 'Lesson Completion'
|
||||
options.borderColor = '#4563f0'
|
||||
options.backgroundColor = (ctx) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
@@ -246,7 +244,7 @@ const lessonChartOptions = () => {
|
||||
|
||||
const courseChartOptions = () => {
|
||||
let options = chartOptions(true)
|
||||
options.plugins.title.text = 'Completions'
|
||||
options.plugins.title.text = 'Course Completion'
|
||||
options.backgroundColor = ['#4563f0', '#f683ae']
|
||||
return options
|
||||
}
|
||||
@@ -306,13 +304,4 @@ const chartOptions = (isPie) => {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Statistics',
|
||||
description: 'Statistics of the platform',
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -56,33 +56,10 @@ const routes = [
|
||||
component: () => import('@/pages/Statistics.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user/:username',
|
||||
path: '/user/:userName',
|
||||
name: 'Profile',
|
||||
component: () => import('@/pages/Profile.vue'),
|
||||
props: true,
|
||||
redirect: { name: 'ProfileAbout' },
|
||||
children: [
|
||||
{
|
||||
name: 'ProfileAbout',
|
||||
path: '',
|
||||
component: () => import('@/pages/ProfileAbout.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ProfileCertificates',
|
||||
path: 'certificates',
|
||||
component: () => import('@/pages/ProfileCertificates.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ProfileRoles',
|
||||
path: 'roles',
|
||||
component: () => import('@/pages/ProfileRoles.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ProfileEvaluator',
|
||||
path: 'evaluations',
|
||||
component: () => import('@/pages/ProfileEvaluator.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/job-openings',
|
||||
@@ -125,33 +102,6 @@ const routes = [
|
||||
component: () => import('@/pages/AssignmentSubmission.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/certified-participants',
|
||||
name: 'CertifiedParticipants',
|
||||
component: () => import('@/pages/CertifiedParticipants.vue'),
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('@/pages/Notifications.vue'),
|
||||
},
|
||||
{
|
||||
path: '/badges/:badgeName/:email',
|
||||
name: 'Badge',
|
||||
component: () => import('@/pages/Badge.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/quizzes',
|
||||
name: 'Quizzes',
|
||||
component: () => import('@/pages/Quizzes.vue'),
|
||||
},
|
||||
{
|
||||
path: '/quizzes/:quizID',
|
||||
name: 'QuizCreation',
|
||||
component: () => import('@/pages/QuizCreation.vue'),
|
||||
props: true,
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
@@ -160,21 +110,12 @@ let router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { userResource, allUsers } = usersStore()
|
||||
const { userResource } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
await userResource.promise
|
||||
}
|
||||
if (
|
||||
isLoggedIn &&
|
||||
(to.name == 'Lesson' ||
|
||||
to.name == 'Batch' ||
|
||||
to.name == 'Notifications' ||
|
||||
to.name == 'Badge')
|
||||
) {
|
||||
await allUsers.promise
|
||||
await userResource.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
isLoggedIn = false
|
||||
|
||||
@@ -5,7 +5,7 @@ import router from '@/router'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const sessionStore = defineStore('lms-session', () => {
|
||||
let { userResource, allUsers } = usersStore()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
function sessionUser() {
|
||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||
@@ -17,9 +17,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
}
|
||||
|
||||
let user = ref(sessionUser())
|
||||
if (user) {
|
||||
allUsers.reload()
|
||||
}
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
const login = createResource({
|
||||
@@ -44,20 +41,10 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
cache: 'brand',
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
document.querySelector("link[rel='icon']").href = data.favicon
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
branding,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,16 +9,9 @@ export const usersStore = defineStore('lms-users', () => {
|
||||
router.push('/login')
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const allUsers = createResource({
|
||||
url: 'lms.lms.api.get_all_users',
|
||||
cache: ['allUsers'],
|
||||
})
|
||||
|
||||
return {
|
||||
userResource,
|
||||
allUsers,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createResource } from 'frappe-ui'
|
||||
|
||||
export default function translationPlugin(app) {
|
||||
app.config.globalProperties.__ = translate
|
||||
window.__ = translate
|
||||
if (!window.translatedMessages) fetchTranslations()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
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;
|
||||
@@ -1,32 +0,0 @@
|
||||
import Embed from '@editorjs/embed'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
export class CustomEmbed extends Embed {
|
||||
render() {
|
||||
const container = super.render()
|
||||
const { service, source, embed } = this.data
|
||||
|
||||
if (service === 'youtube' || service === 'vimeo') {
|
||||
// Remove the iframe or existing embed content
|
||||
container.innerHTML = ''
|
||||
|
||||
// Create a placeholder element for Vue component
|
||||
const vueContainer = document.createElement('div')
|
||||
vueContainer.setAttribute('data-service', service)
|
||||
vueContainer.setAttribute('data-video-id', this.data.source)
|
||||
|
||||
// Append the Vue placeholder
|
||||
container.appendChild(vueContainer)
|
||||
console.log(source)
|
||||
// Mount the Vue component (using a global Vue instance)
|
||||
const app = createApp(VideoBlock, {
|
||||
file: source,
|
||||
type: 'video/youtube',
|
||||
})
|
||||
app.mount(vueContainer)
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import { toast } from 'frappe-ui'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next'
|
||||
import { Quiz } from '@/utils/quiz'
|
||||
import { Upload } from '@/utils/upload'
|
||||
import Header from '@editorjs/header'
|
||||
import Paragraph from '@editorjs/paragraph'
|
||||
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'
|
||||
import Embed from '@editorjs/embed'
|
||||
import SimpleImage from '@editorjs/simple-image'
|
||||
import NestedList from '@editorjs/nested-list'
|
||||
import { watch } from 'vue'
|
||||
|
||||
export function createToast(options) {
|
||||
toast({
|
||||
@@ -40,12 +37,6 @@ export function formatTime(timeString) {
|
||||
return formattedTime
|
||||
}
|
||||
|
||||
export function formatNumber(number) {
|
||||
return number.toLocaleString('en-IN', {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatNumberIntoCurrency(number, currency) {
|
||||
if (number) {
|
||||
return number.toLocaleString('en-IN', {
|
||||
@@ -80,31 +71,17 @@ export function getFileSize(file_size) {
|
||||
return value
|
||||
}
|
||||
|
||||
export function showToast(title, text, icon, iconClasses = null) {
|
||||
if (!iconClasses) {
|
||||
iconClasses =
|
||||
icon == 'check'
|
||||
? 'bg-green-600 text-white rounded-md p-px'
|
||||
: 'bg-red-600 text-white rounded-md p-px'
|
||||
}
|
||||
export function showToast(title, text, icon) {
|
||||
createToast({
|
||||
title: title,
|
||||
text: htmlToText(text),
|
||||
icon: icon,
|
||||
iconClasses: iconClasses,
|
||||
iconClasses:
|
||||
icon == 'check'
|
||||
? 'bg-green-600 text-white rounded-md p-px'
|
||||
: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: icon == 'check' ? 'bottom-right' : 'top-center',
|
||||
timeout: 5,
|
||||
})
|
||||
}
|
||||
|
||||
export function getImgDimensions(imgSrc) {
|
||||
return new Promise((resolve) => {
|
||||
let img = new Image()
|
||||
img.onload = function () {
|
||||
let { width, height } = img
|
||||
resolve({ width, height, ratio: width / height })
|
||||
}
|
||||
img.src = imgSrc
|
||||
timeout: icon == 'check' ? 5 : 10,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,22 +114,9 @@ export function getEditorTools() {
|
||||
header: Header,
|
||||
quiz: Quiz,
|
||||
upload: Upload,
|
||||
image: SimpleImage,
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
preserveBlank: 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,
|
||||
@@ -160,113 +124,19 @@ export function getEditorTools() {
|
||||
defaultStyle: 'ordered',
|
||||
},
|
||||
},
|
||||
inlineCode: {
|
||||
class: InlineCode,
|
||||
shortcut: 'CMD+SHIFT+M',
|
||||
},
|
||||
embed: {
|
||||
class: Embed,
|
||||
inlineToolbar: false,
|
||||
config: {
|
||||
services: {
|
||||
youtube: {
|
||||
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
|
||||
embedUrl:
|
||||
'https://www.youtube.com/embed/<%= remote_id %>',
|
||||
html: '<iframe style="width:100%; height: 30rem;" frameborder="0" allowfullscreen></iframe>',
|
||||
height: 320,
|
||||
width: 580,
|
||||
id: ([id, params]) => {
|
||||
if (!params && id) {
|
||||
return id
|
||||
}
|
||||
|
||||
const paramsMap = {
|
||||
start: 'start',
|
||||
end: 'end',
|
||||
t: 'start',
|
||||
// eslint-disable-next-line camelcase
|
||||
time_continue: 'start',
|
||||
list: 'list',
|
||||
}
|
||||
|
||||
let newParams = params
|
||||
.slice(1)
|
||||
.split('&')
|
||||
.map((param) => {
|
||||
const [name, value] = param.split('=')
|
||||
|
||||
if (!id && name === 'v') {
|
||||
id = value
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!paramsMap[name]) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
value === 'LL' ||
|
||||
value.startsWith('RDMM') ||
|
||||
value.startsWith('FL')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${paramsMap[name]}=${value}`
|
||||
})
|
||||
.filter((param) => !!param)
|
||||
|
||||
return id + '?' + newParams.join('&')
|
||||
},
|
||||
},
|
||||
youtube: true,
|
||||
vimeo: true,
|
||||
codepen: true,
|
||||
aparat: {
|
||||
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
|
||||
embedUrl:
|
||||
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
|
||||
html: '<iframe style="margin: 0 auto; width: 100%; height: 25rem;" frameborder="0" scrolling="no" allowtransparency="true"></iframe>',
|
||||
height: 300,
|
||||
width: 600,
|
||||
},
|
||||
github: true,
|
||||
slides: {
|
||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
|
||||
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
drive: {
|
||||
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
|
||||
embedUrl:
|
||||
'https://drive.google.com/file/d/<%= remote_id %>/preview',
|
||||
html: "<iframe style='width: 100%; height: 25rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
docsPublic: {
|
||||
regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/document/d/<%= remote_id %>/preview',
|
||||
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
sheetsPublic: {
|
||||
regex: /https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/spreadsheets/d/<%= remote_id %>/preview',
|
||||
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
slidesPublic: {
|
||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||
embedUrl:
|
||||
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
|
||||
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
codesandbox: {
|
||||
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
|
||||
embedUrl:
|
||||
'https://codesandbox.io/embed/<%= remote_id %>?view=editor+%2B+preview&module=%2Findex.html',
|
||||
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-mods allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
html: "<iframe width='100%' height='300' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -418,68 +288,26 @@ export function getSidebarLinks() {
|
||||
return [
|
||||
{
|
||||
label: 'Courses',
|
||||
icon: 'BookOpen',
|
||||
icon: BookOpen,
|
||||
to: 'Courses',
|
||||
activeFor: [
|
||||
'Courses',
|
||||
'CourseDetail',
|
||||
'Lesson',
|
||||
'CreateCourse',
|
||||
'CreateLesson',
|
||||
],
|
||||
activeFor: ['Courses', 'CourseDetail', 'Lesson'],
|
||||
},
|
||||
{
|
||||
label: 'Batches',
|
||||
icon: 'Users',
|
||||
icon: Users,
|
||||
to: 'Batches',
|
||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchCreation'],
|
||||
},
|
||||
{
|
||||
label: 'Certified Participants',
|
||||
icon: 'GraduationCap',
|
||||
to: 'CertifiedParticipants',
|
||||
activeFor: ['CertifiedParticipants'],
|
||||
activeFor: ['Batches', 'BatchDetail', 'Batch'],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
icon: 'Briefcase',
|
||||
icon: Briefcase,
|
||||
to: 'Jobs',
|
||||
activeFor: ['Jobs', 'JobDetail'],
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
icon: 'TrendingUp',
|
||||
icon: TrendingUp,
|
||||
to: 'Statistics',
|
||||
activeFor: ['Statistics'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getFormattedDateRange(
|
||||
startDate,
|
||||
endDate,
|
||||
format = 'DD MMM YYYY'
|
||||
) {
|
||||
if (startDate === endDate) {
|
||||
return dayjs(startDate).format(format)
|
||||
}
|
||||
return `${dayjs(startDate).format(format)} - ${dayjs(endDate).format(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class Quiz {
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
console.log(blockContent)
|
||||
return {
|
||||
quiz: this.data.quiz,
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export function scrollTo(...options) {
|
||||
if (!options || options.length === 0) return
|
||||
const container = getScrollContainer()
|
||||
if (!container) return
|
||||
container.scrollTo(...options)
|
||||
}
|
||||
|
||||
export function getScrollContainer() {
|
||||
// window.scrollContainer is reference to the scroll container in DesktopLayout.vue and MobileLayout.vue
|
||||
return window.scrollContainer
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import AudioBlock from '@/components/AudioBlock.vue'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
export class Upload {
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.data = data
|
||||
@@ -14,33 +10,27 @@ export class Upload {
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div')
|
||||
this.renderUpload(this.data)
|
||||
this.wrapper.innerHTML = this.renderUpload(this.data)
|
||||
return this.wrapper
|
||||
}
|
||||
|
||||
renderUpload(file) {
|
||||
if (this.isVideo(file.file_type)) {
|
||||
const app = createApp(VideoBlock, {
|
||||
file: file.file_url,
|
||||
})
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
return `<video controls width='100%' controls controlsList='nodownload' class="mb-4">
|
||||
<source src=${encodeURI(file.file_url)} type='video/mp4'>
|
||||
</video>`
|
||||
} else if (this.isAudio(file.file_type)) {
|
||||
const app = createApp(AudioBlock, {
|
||||
file: file.file_url,
|
||||
})
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
} else if (file.file_type == 'PDF') {
|
||||
this.wrapper.innerHTML = `<iframe src="${encodeURI(
|
||||
return `<audio controls width='100%' controls controlsList='nodownload' class="mb-4">
|
||||
<source src=${encodeURI(file.file_url)} type='audio/mp3'>
|
||||
</audio>`
|
||||
} else if (file.file_type == 'pdf') {
|
||||
return `<iframe src="${encodeURI(
|
||||
file.file_url
|
||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
||||
return
|
||||
} else {
|
||||
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
||||
return `<img class="mb-4" src=${encodeURI(
|
||||
file.file_url
|
||||
)} width='100%'>`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1054
frontend/yarn.lock
1054
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
82
lms/hooks.py
82
lms/hooks.py
@@ -97,13 +97,7 @@ override_doctype_class = {
|
||||
# Hook on document methods and events
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"on_change": [
|
||||
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
|
||||
]
|
||||
},
|
||||
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
||||
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
|
||||
}
|
||||
|
||||
# Scheduled Tasks
|
||||
@@ -111,8 +105,7 @@ doc_events = {
|
||||
scheduler_events = {
|
||||
"hourly": [
|
||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
|
||||
],
|
||||
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||
]
|
||||
}
|
||||
|
||||
fixtures = ["Custom Field", "Function", "Industry"]
|
||||
@@ -153,8 +146,9 @@ website_redirects = [
|
||||
{"source": "/update-profile", "target": "/edit-profile"},
|
||||
{"source": "/courses", "target": "/lms/courses"},
|
||||
{
|
||||
"source": r"^/courses/.*$",
|
||||
"source": r"/courses/([^/]*)",
|
||||
"target": "/lms/courses",
|
||||
"match_with_query_string": True,
|
||||
},
|
||||
{"source": "/batches", "target": "/lms/batches"},
|
||||
{
|
||||
@@ -176,7 +170,52 @@ update_website_context = [
|
||||
]
|
||||
|
||||
jinja = {
|
||||
"methods": ["lms.lms.utils.get_signup_optin_checks"],
|
||||
"methods": [
|
||||
"lms.page_renderers.get_profile_url",
|
||||
"lms.overrides.user.get_enrolled_courses",
|
||||
"lms.overrides.user.get_course_membership",
|
||||
"lms.overrides.user.get_authored_courses",
|
||||
"lms.overrides.user.get_palette",
|
||||
"lms.lms.utils.get_membership",
|
||||
"lms.lms.utils.get_lessons",
|
||||
"lms.lms.utils.get_tags",
|
||||
"lms.lms.utils.get_instructors",
|
||||
"lms.lms.utils.get_students",
|
||||
"lms.lms.utils.get_average_rating",
|
||||
"lms.lms.utils.is_certified",
|
||||
"lms.lms.utils.get_lesson_index",
|
||||
"lms.lms.utils.get_lesson_url",
|
||||
"lms.lms.utils.get_chapters",
|
||||
"lms.lms.utils.get_slugified_chapter_title",
|
||||
"lms.lms.utils.get_progress",
|
||||
"lms.lms.utils.render_html",
|
||||
"lms.lms.utils.is_mentor",
|
||||
"lms.lms.utils.is_cohort_staff",
|
||||
"lms.lms.utils.get_mentors",
|
||||
"lms.lms.utils.get_reviews",
|
||||
"lms.lms.utils.is_eligible_to_review",
|
||||
"lms.lms.utils.get_initial_members",
|
||||
"lms.lms.utils.get_sorted_reviews",
|
||||
"lms.lms.utils.is_instructor",
|
||||
"lms.lms.utils.convert_number_to_character",
|
||||
"lms.lms.utils.get_signup_optin_checks",
|
||||
"lms.lms.utils.get_popular_courses",
|
||||
"lms.lms.utils.format_amount",
|
||||
"lms.lms.utils.first_lesson_exists",
|
||||
"lms.lms.utils.get_courses_under_review",
|
||||
"lms.lms.utils.has_course_instructor_role",
|
||||
"lms.lms.utils.has_course_moderator_role",
|
||||
"lms.lms.utils.get_certificates",
|
||||
"lms.lms.utils.format_number",
|
||||
"lms.lms.utils.get_lesson_count",
|
||||
"lms.lms.utils.get_all_memberships",
|
||||
"lms.lms.utils.get_filtered_membership",
|
||||
"lms.lms.utils.show_start_learing_cta",
|
||||
"lms.lms.utils.can_create_courses",
|
||||
"lms.lms.utils.get_telemetry_boot_info",
|
||||
"lms.lms.utils.is_onboarding_complete",
|
||||
"lms.www.utils.is_student",
|
||||
],
|
||||
"filters": [],
|
||||
}
|
||||
## Specify the additional tabs to be included in the user profile page.
|
||||
@@ -193,10 +232,28 @@ jinja = {
|
||||
# ]
|
||||
|
||||
has_website_permission = {
|
||||
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission",
|
||||
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_website_permission",
|
||||
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission"
|
||||
}
|
||||
|
||||
profile_mandatory_fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"user_image",
|
||||
"bio",
|
||||
"linkedin",
|
||||
"education",
|
||||
"skill",
|
||||
"preferred_functions",
|
||||
"preferred_industries",
|
||||
"dream_companies",
|
||||
"attire",
|
||||
"collaboration",
|
||||
"role",
|
||||
"location_preference",
|
||||
"time",
|
||||
"company_type",
|
||||
]
|
||||
|
||||
## Markdown Macros for Lessons
|
||||
lms_markdown_macro_renderers = {
|
||||
"Exercise": "lms.plugins.exercise_renderer",
|
||||
@@ -213,7 +270,6 @@ lms_markdown_macro_renderers = {
|
||||
page_renderer = [
|
||||
"lms.page_renderers.ProfileRedirectPage",
|
||||
"lms.page_renderers.ProfilePage",
|
||||
"lms.page_renderers.CoursePage",
|
||||
]
|
||||
|
||||
# set this to "/" to have profiles on the top-level
|
||||
|
||||
@@ -90,11 +90,11 @@ def create_moderator_role():
|
||||
|
||||
|
||||
def create_evaluator_role():
|
||||
if not frappe.db.exists("Role", "Batch Evaluator"):
|
||||
if not frappe.db.exists("Role", "Class Evaluator"):
|
||||
role = frappe.new_doc("Role")
|
||||
role.update(
|
||||
{
|
||||
"role_name": "Batch Evaluator",
|
||||
"role_name": "Class Evaluator",
|
||||
"home_page": "",
|
||||
"desk_access": 0,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import get_link_to_form, add_months, getdate
|
||||
from frappe.utils import get_link_to_form
|
||||
from frappe.utils.user import get_system_managers
|
||||
|
||||
from lms.lms.utils import validate_image
|
||||
@@ -19,17 +19,6 @@ class JobOpportunity(Document):
|
||||
frappe.utils.validate_url(self.company_website, True)
|
||||
|
||||
|
||||
def update_job_openings():
|
||||
old_jobs = frappe.get_all(
|
||||
"Job Opportunity",
|
||||
filters={"status": "Open", "creation": ["<=", add_months(getdate(), -3)]},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for job in old_jobs:
|
||||
frappe.db.set_value("Job Opportunity", job, "status", "Closed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def report(job, reason):
|
||||
system_managers = get_system_managers(only_name=True)
|
||||
|
||||
@@ -30,23 +30,13 @@ class LMSJobApplication(Document):
|
||||
"full_name": frappe.db.get_value("User", self.user, "full_name"),
|
||||
"job_title": self.job_title,
|
||||
}
|
||||
resume = frappe.get_doc(
|
||||
"File",
|
||||
{
|
||||
"file_name": self.resume,
|
||||
},
|
||||
)
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=company_email,
|
||||
subject=subject,
|
||||
template="job_application",
|
||||
args=args,
|
||||
attachments=[
|
||||
{
|
||||
"fname": resume.file_name,
|
||||
"fcontent": resume.get_content(),
|
||||
}
|
||||
],
|
||||
attachments=[self.resume],
|
||||
header=[subject, "green"],
|
||||
retry=3,
|
||||
)
|
||||
|
||||
273
lms/lms/api.py
273
lms/lms/api.py
@@ -4,8 +4,6 @@
|
||||
import frappe
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Count
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -150,7 +148,7 @@ def get_user_info():
|
||||
user = frappe.db.get_value(
|
||||
"User",
|
||||
frappe.session.user,
|
||||
["name", "email", "enabled", "user_image", "full_name", "user_type", "username"],
|
||||
["name", "email", "enabled", "user_image", "full_name", "user_type"],
|
||||
as_dict=1,
|
||||
)
|
||||
user["roles"] = frappe.get_roles(user.name)
|
||||
@@ -290,272 +288,3 @@ def get_branding():
|
||||
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
|
||||
"favicon": frappe.db.get_single_value("Website Settings", "favicon"),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_unsplash_photos(keyword=None):
|
||||
from lms.unsplash import get_list, get_by_keyword
|
||||
|
||||
if keyword:
|
||||
return get_by_keyword(keyword)
|
||||
|
||||
return frappe.cache().get_value("unsplash_photos", generator=get_list)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_evaluator_details(evaluator):
|
||||
frappe.only_for("Batch Evaluator")
|
||||
|
||||
if not frappe.db.exists("Google Calendar", {"user": evaluator}):
|
||||
calendar = frappe.new_doc("Google Calendar")
|
||||
calendar.update({"user": evaluator, "calendar_name": evaluator})
|
||||
calendar.insert()
|
||||
else:
|
||||
calendar = frappe.db.get_value(
|
||||
"Google Calendar", {"user": evaluator}, ["name", "authorization_code"], as_dict=1
|
||||
)
|
||||
|
||||
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
|
||||
doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
|
||||
else:
|
||||
doc = frappe.new_doc("Course Evaluator")
|
||||
doc.evaluator = evaluator
|
||||
doc.insert()
|
||||
|
||||
return {
|
||||
"slots": doc.as_dict(),
|
||||
"calendar": calendar.name,
|
||||
"is_authorised": calendar.authorization_code,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_certified_participants():
|
||||
LMSCertificate = DocType("LMS Certificate")
|
||||
participants = (
|
||||
frappe.qb.from_(LMSCertificate)
|
||||
.select(LMSCertificate.member)
|
||||
.distinct()
|
||||
.where(LMSCertificate.published == 1)
|
||||
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
participant_details = []
|
||||
for participant in participants:
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
participant.member,
|
||||
["name", "full_name", "username", "user_image"],
|
||||
as_dict=True,
|
||||
)
|
||||
course_names = frappe.get_all(
|
||||
"LMS Certificate", {"member": participant.member}, pluck="course"
|
||||
)
|
||||
courses = []
|
||||
for course in course_names:
|
||||
courses.append(frappe.db.get_value("LMS Course", course, "title"))
|
||||
details["courses"] = courses
|
||||
participant_details.append(details)
|
||||
return participant_details
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_assigned_badges(member):
|
||||
assigned_badges = frappe.get_all(
|
||||
"LMS Badge Assignment",
|
||||
{"member": member},
|
||||
["badge"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
for badge in assigned_badges:
|
||||
badge.update(
|
||||
frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"])
|
||||
)
|
||||
return assigned_badges
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_certificates(member):
|
||||
"""Get certificates for a member."""
|
||||
return frappe.get_all(
|
||||
"LMS Certificate",
|
||||
filters={"member": member},
|
||||
fields=["name", "course", "course_title", "issue_date", "template"],
|
||||
order_by="creation desc",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_all_users():
|
||||
users = frappe.get_all(
|
||||
"User",
|
||||
{
|
||||
"enabled": 1,
|
||||
},
|
||||
["name", "full_name", "user_image"],
|
||||
)
|
||||
|
||||
return {user.name: user for user in users}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_as_read(name):
|
||||
doc = frappe.get_doc("Notification Log", name)
|
||||
doc.read = 1
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_all_as_read():
|
||||
notifications = frappe.get_all(
|
||||
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
|
||||
)
|
||||
|
||||
for notification in notifications:
|
||||
mark_as_read(notification)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_sidebar_settings():
|
||||
lms_settings = frappe.get_single("LMS Settings")
|
||||
sidebar_items = frappe._dict()
|
||||
|
||||
items = [
|
||||
"courses",
|
||||
"batches",
|
||||
"certified_participants",
|
||||
"jobs",
|
||||
"statistics",
|
||||
"notifications",
|
||||
]
|
||||
for item in items:
|
||||
sidebar_items[item] = lms_settings.get(item)
|
||||
|
||||
if len(lms_settings.sidebar_items):
|
||||
web_pages = frappe.get_all(
|
||||
"LMS Sidebar Item",
|
||||
{"parenttype": "LMS Settings", "parentfield": "sidebar_items"},
|
||||
["web_page", "route", "title as label", "icon"],
|
||||
)
|
||||
for page in web_pages:
|
||||
page.to = page.route
|
||||
|
||||
sidebar_items.web_pages = web_pages
|
||||
|
||||
return sidebar_items
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_sidebar_item(webpage, icon):
|
||||
filters = {
|
||||
"web_page": webpage,
|
||||
"parenttype": "LMS Settings",
|
||||
"parentfield": "sidebar_items",
|
||||
"parent": "LMS Settings",
|
||||
}
|
||||
|
||||
if frappe.db.exists("LMS Sidebar Item", filters):
|
||||
frappe.db.set_value("LMS Sidebar Item", filters, "icon", icon)
|
||||
else:
|
||||
doc = frappe.new_doc("LMS Sidebar Item")
|
||||
doc.update(filters)
|
||||
doc.icon = icon
|
||||
doc.insert()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_sidebar_item(webpage):
|
||||
return frappe.db.delete(
|
||||
"LMS Sidebar Item",
|
||||
{
|
||||
"web_page": webpage,
|
||||
"parenttype": "LMS Settings",
|
||||
"parentfield": "sidebar_items",
|
||||
"parent": "LMS Settings",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_lesson(lesson, chapter):
|
||||
frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
|
||||
frappe.db.delete("Course Lesson", lesson)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
|
||||
hasMoved = sourceChapter == targetChapter
|
||||
|
||||
update_source_chapter(lesson, sourceChapter, idx, hasMoved)
|
||||
if not hasMoved:
|
||||
update_target_chapter(lesson, targetChapter, idx)
|
||||
|
||||
|
||||
def update_source_chapter(lesson, chapter, idx, hasMoved=False):
|
||||
lessons = frappe.get_all(
|
||||
"Lesson Reference",
|
||||
{
|
||||
"parent": chapter,
|
||||
},
|
||||
pluck="lesson",
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
lessons.remove(lesson)
|
||||
if not hasMoved:
|
||||
frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
|
||||
else:
|
||||
lessons.insert(idx, lesson)
|
||||
|
||||
update_index(lessons, chapter)
|
||||
|
||||
|
||||
def update_target_chapter(lesson, chapter, idx):
|
||||
lessons = frappe.get_all(
|
||||
"Lesson Reference",
|
||||
{
|
||||
"parent": chapter,
|
||||
},
|
||||
pluck="lesson",
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
lessons.insert(idx, lesson)
|
||||
new_lesson_reference = frappe.new_doc("Lesson Reference")
|
||||
new_lesson_reference.update(
|
||||
{
|
||||
"lesson": lesson,
|
||||
"parent": chapter,
|
||||
"parenttype": "Course Chapter",
|
||||
"parentfield": "lessons",
|
||||
}
|
||||
)
|
||||
new_lesson_reference.insert()
|
||||
update_index(lessons, chapter)
|
||||
|
||||
|
||||
def update_index(lessons, chapter):
|
||||
for row in lessons:
|
||||
frappe.db.set_value(
|
||||
"Lesson Reference", {"lesson": row, "parent": chapter}, "idx", lessons.index(row) + 1
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_categories(doctype, filters):
|
||||
categoryOptions = []
|
||||
|
||||
categories = frappe.get_all(
|
||||
doctype,
|
||||
filters,
|
||||
pluck="category",
|
||||
)
|
||||
categories = list(set(categories))
|
||||
|
||||
for category in categories:
|
||||
if category:
|
||||
categoryOptions.append({"label": category, "value": category})
|
||||
|
||||
return categoryOptions
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"evaluator",
|
||||
"schedule",
|
||||
"unavailability_section",
|
||||
"unavailable_from",
|
||||
"column_break_ahzi",
|
||||
"unavailable_to"
|
||||
"schedule"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -27,30 +23,11 @@
|
||||
"fieldtype": "Table",
|
||||
"label": "Schedule",
|
||||
"options": "Evaluator Schedule"
|
||||
},
|
||||
{
|
||||
"fieldname": "unavailability_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Unavailability"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ahzi",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "unavailable_from",
|
||||
"fieldtype": "Date",
|
||||
"label": "From"
|
||||
},
|
||||
{
|
||||
"fieldname": "unavailable_to",
|
||||
"fieldtype": "Date",
|
||||
"label": "To"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-15 18:45:08.614466",
|
||||
"modified": "2023-07-13 11:30:22.641076",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Evaluator",
|
||||
@@ -89,7 +66,7 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Batch Evaluator",
|
||||
"role": "Class Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
|
||||
@@ -6,25 +6,15 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from lms.lms.utils import get_evaluator
|
||||
from datetime import datetime
|
||||
from frappe.utils import get_time, getdate
|
||||
|
||||
|
||||
class CourseEvaluator(Document):
|
||||
def validate(self):
|
||||
self.validate_time_slots()
|
||||
self.validate_unavailability()
|
||||
|
||||
def validate_unavailability(self):
|
||||
if (
|
||||
self.unavailable_from
|
||||
and self.unavailable_to
|
||||
and getdate(self.unavailable_from) >= getdate(self.unavailable_to)
|
||||
):
|
||||
frappe.throw(_("Unavailable From Date cannot be greater than Unavailable To Date"))
|
||||
|
||||
def validate_time_slots(self):
|
||||
for schedule in self.schedule:
|
||||
if get_time(schedule.start_time) >= get_time(schedule.end_time):
|
||||
if schedule.start_time >= schedule.end_time:
|
||||
frappe.throw(_("Start Time cannot be greater than End Time"))
|
||||
|
||||
self.validate_overlaps(schedule)
|
||||
@@ -36,21 +26,11 @@ class CourseEvaluator(Document):
|
||||
overlap = False
|
||||
|
||||
for slot in same_day_slots:
|
||||
if (
|
||||
get_time(schedule.start_time)
|
||||
<= get_time(slot.start_time)
|
||||
< get_time(schedule.end_time)
|
||||
):
|
||||
if schedule.start_time <= slot.start_time < schedule.end_time:
|
||||
overlap = True
|
||||
if (
|
||||
get_time(schedule.start_time)
|
||||
< get_time(slot.end_time)
|
||||
<= get_time(schedule.end_time)
|
||||
):
|
||||
if schedule.start_time < slot.end_time <= schedule.end_time:
|
||||
overlap = True
|
||||
if get_time(slot.start_time) < get_time(schedule.start_time) and get_time(
|
||||
schedule.end_time
|
||||
) < get_time(slot.end_time):
|
||||
if slot.start_time < schedule.start_time and schedule.end_time < slot.end_time:
|
||||
overlap = True
|
||||
|
||||
if overlap:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user