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
|
run: pip install semgrep
|
||||||
|
|
||||||
- name: Run Semgrep rules
|
- 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
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
lms/public/frontend
|
lms/public/frontend
|
||||||
lms/www/lms.html
|
lms/www/lms.html
|
||||||
frappe-ui
|
|
||||||
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
|
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.
|
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.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frappe Bench
|
### 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,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://test_site_ui:8000",
|
baseUrl: "http://pyp:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,156 +1,133 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.wait(1000);
|
cy.visit("/courses");
|
||||||
cy.visit("/lms/courses");
|
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("a").contains("New Course").click();
|
cy.get("a.btn").contains("Create a Course").click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new-course/edit");
|
||||||
|
cy.get("#title").type("Test Course");
|
||||||
cy.get("label").contains("Title").type("Test Course");
|
cy.get("#intro").type("Test Course Short Introduction");
|
||||||
cy.get("label")
|
cy.get("#description").type("Test Course Description");
|
||||||
.contains("Short Introduction")
|
cy.get("#video-link").type("-LPmw2Znl2c");
|
||||||
.type("Test Course Short Introduction to test the UI");
|
cy.get("#tags-input").type("Test");
|
||||||
cy.get("div[contenteditable=true").invoke(
|
cy.get("#published").check();
|
||||||
"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.wait(1000);
|
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();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// Add Chapter
|
// Add Chapter
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.button("Add Chapter").click();
|
cy.link("Course Outline").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get("[id^=headlessui-dialog-panel-")
|
cy.get(".edit-header .btn-add-chapter").click();
|
||||||
.should("be.visible")
|
cy.wait(500);
|
||||||
.within(() => {
|
cy.get("#chapter-title").type("Test Chapter");
|
||||||
cy.get("label").contains("Title").type("Test Chapter");
|
cy.get("#chapter-description").type("Test Chapter Description");
|
||||||
cy.button("Add Chapter").click();
|
cy.button("Save").click();
|
||||||
});
|
|
||||||
|
|
||||||
// Add Lesson
|
// Add Lesson
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.button("Add Lesson").click();
|
cy.link("Add Lesson").click();
|
||||||
cy.wait(1000);
|
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.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();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/lms");
|
cy.visit("/courses");
|
||||||
cy.wait(500);
|
cy.get(".course-card-title:first").contains("Test Course");
|
||||||
cy.url().should("include", "/lms/courses");
|
cy.get(".course-card:first").click();
|
||||||
cy.get(".grid a:first").within(() => {
|
cy.url().should("include", "/courses/test-course");
|
||||||
cy.get("div").contains("Test Course");
|
cy.get("#title").contains("Test Course");
|
||||||
cy.get("div").contains(
|
cy.get(".preview-video").should(
|
||||||
"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(
|
|
||||||
"have.attr",
|
"have.attr",
|
||||||
"src",
|
"src",
|
||||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||||
);
|
);
|
||||||
|
cy.get("#intro").contains("Test Course Short Introduction");
|
||||||
|
|
||||||
// View Chapter
|
// View Chapter
|
||||||
cy.get("div").contains("Test Chapter");
|
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
||||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
cy.get(".chapter-description:first").contains(
|
||||||
cy.get("div").contains("Test Lesson").click();
|
"Test Chapter Description"
|
||||||
});
|
);
|
||||||
cy.wait(3000);
|
cy.get(".lesson-info:first").contains("Test Lesson");
|
||||||
|
cy.get(".lesson-info:first").click();
|
||||||
|
|
||||||
// View Lesson
|
// View Lesson
|
||||||
cy.url().should("include", "/learn/1-1");
|
cy.wait(1000);
|
||||||
cy.get("div").contains("Test Lesson");
|
cy.url().should("include", "learn/1.1");
|
||||||
|
cy.get("#title").contains("Test Lesson");
|
||||||
cy.get("video")
|
cy.get(".lesson-video iframe").should(
|
||||||
.should("be.visible")
|
"have.attr",
|
||||||
.children("source")
|
"src",
|
||||||
.invoke("attr", "src")
|
"https://www.youtube.com/embed/GoDtyItReto"
|
||||||
.should("include", "/files/Youtube");
|
);
|
||||||
|
cy.get(".lesson-content-card").contains(
|
||||||
cy.get("div").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."
|
"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
|
// Add Discussion
|
||||||
cy.button("New Question").click();
|
cy.get(".reply").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
cy.get(".discussion-modal").should("be.visible");
|
||||||
cy.get("label").contains("Title").type("Test Discussion");
|
|
||||||
cy.get("div[contenteditable=true]").invoke(
|
// Enter title
|
||||||
"text",
|
cy.get(".modal .topic-title")
|
||||||
"This is a test discussion. This will check if the UI is working properly."
|
.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 --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
import "cypress-file-upload";
|
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
email = Cypress.config("testUser") || "Administrator";
|
email = Cypress.config("testUser") || "Administrator";
|
||||||
@@ -55,13 +53,3 @@ Cypress.Commands.add("iconButton", (text) => {
|
|||||||
Cypress.Commands.add("dialog", (selector) => {
|
Cypress.Commands.add("dialog", (selector) => {
|
||||||
return cy.get(`[role=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": {
|
"dependencies": {
|
||||||
"@editorjs/checklist": "^1.6.0",
|
"@editorjs/checklist": "^1.6.0",
|
||||||
"@editorjs/code": "^2.9.0",
|
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
"@editorjs/embed": "^2.7.0",
|
"@editorjs/embed": "^2.7.0",
|
||||||
"@editorjs/header": "^2.8.1",
|
"@editorjs/header": "^2.8.1",
|
||||||
"@editorjs/inline-code": "^1.5.0",
|
"@editorjs/image": "^2.9.0",
|
||||||
"@editorjs/nested-list": "^1.4.2",
|
"@editorjs/nested-list": "^1.4.2",
|
||||||
"@editorjs/paragraph": "^2.11.3",
|
"@editorjs/paragraph": "^2.11.3",
|
||||||
"@editorjs/simple-image": "^1.6.0",
|
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.56",
|
"frappe-ui": "^0.1.50",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.309.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"vue": "^3.4.23",
|
"vue": "^3.2.25",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-router": "^4.0.12"
|
||||||
"vue-router": "^4.0.12",
|
|
||||||
"vuedraggable": "4.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -8,59 +8,14 @@
|
|||||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||||
>
|
>
|
||||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
<div class="flex flex-col overflow-y-auto">
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
v-for="link in sidebarLinks"
|
v-for="link in links"
|
||||||
:link="link"
|
:link="link"
|
||||||
:isCollapsed="isSidebarCollapsed"
|
:isCollapsed="isSidebarCollapsed"
|
||||||
class="mx-2 my-0.5"
|
class="mx-2 my-0.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
:link="{
|
:link="{
|
||||||
@@ -80,11 +35,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
</div>
|
</div>
|
||||||
<PageModal
|
|
||||||
v-model="showPageModal"
|
|
||||||
v-model:reloadSidebar="sidebarSettings"
|
|
||||||
:page="pageToEdit"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -92,114 +42,14 @@ import UserDropdown from '@/components/UserDropdown.vue'
|
|||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { ref, onMounted, inject, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
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 links = getSidebarLinks()
|
||||||
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 getSidebarFromStorage = () => {
|
const getSidebarFromStorage = () => {
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
return useStorage('sidebar_is_collapsed', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(userResource, () => {
|
|
||||||
if (userResource.data) {
|
|
||||||
isModerator.value = userResource.data.is_moderator
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<div
|
<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"
|
style="min-height: 150px"
|
||||||
>
|
>
|
||||||
<div class="text-xl font-semibold mb-2">
|
|
||||||
{{ batch.title }}
|
|
||||||
</div>
|
|
||||||
<Badge
|
<Badge
|
||||||
v-if="batch.seat_count && batch.seats_left > 0"
|
v-if="batch.seat_count && batch.seats_left > 0"
|
||||||
theme="green"
|
theme="green"
|
||||||
class="self-start mb-2"
|
class="self-start mb-2"
|
||||||
>
|
>
|
||||||
{{ batch.seats_left }}
|
{{ batch.seats_left }} {{ __('Seat Left') }}
|
||||||
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
|
|
||||||
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||||
@@ -22,57 +17,43 @@
|
|||||||
>
|
>
|
||||||
{{ __('Sold Out') }}
|
{{ __('Sold Out') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<div class="text-xl font-semibold mb-1">
|
||||||
|
{{ batch.title }}
|
||||||
|
</div>
|
||||||
<div class="short-introduction">
|
<div class="short-introduction">
|
||||||
{{ batch.description }}
|
{{ batch.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2 mt-auto">
|
<div class="mt-auto">
|
||||||
<div v-if="batch.amount" class="font-semibold text-lg">
|
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
|
||||||
{{ batch.price }}
|
{{ batch.price }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
<DateRange
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
:startDate="batch.start_date"
|
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
||||||
:endDate="batch.end_date"
|
</div>
|
||||||
class="text-sm text-gray-700 mb-3"
|
<div class="flex items-center mb-3">
|
||||||
/>
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<div class="flex items-center text-sm 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" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
|
||||||
|
import { inject } from 'vue'
|
||||||
import { Badge } from 'frappe-ui'
|
import { Badge } from 'frappe-ui'
|
||||||
import { formatTime } from '../utils'
|
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({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -91,17 +72,4 @@ const props = defineProps({
|
|||||||
margin: 0.25rem 0 1.25rem;
|
margin: 0.25rem 0 1.25rem;
|
||||||
line-height: 1.5;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -19,14 +19,8 @@
|
|||||||
<ListView
|
<ListView
|
||||||
:columns="getCoursesColumns()"
|
:columns="getCoursesColumns()"
|
||||||
:rows="courses.data"
|
:rows="courses.data"
|
||||||
row-key="batch_course"
|
row-key="name"
|
||||||
:options="{
|
:options="{ showTooltip: false }"
|
||||||
showTooltip: false,
|
|
||||||
getRowRoute: (row) => ({
|
|
||||||
name: 'CourseDetail',
|
|
||||||
params: { courseName: row.name },
|
|
||||||
}),
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
@@ -55,10 +49,7 @@
|
|||||||
<ListSelectBanner>
|
<ListSelectBanner>
|
||||||
<template #actions="{ unselectAll, selections }">
|
<template #actions="{ unselectAll, selections }">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button variant="ghost" @click="removeCourses(selections)">
|
||||||
variant="ghost"
|
|
||||||
@click="removeCourses(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,16 +108,13 @@ const getCoursesColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Title',
|
label: 'Title',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
width: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Lessons',
|
label: 'Lessons',
|
||||||
key: 'lesson_count',
|
key: 'lesson_count',
|
||||||
align: 'right',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Enrollments',
|
label: 'Enrollments',
|
||||||
align: 'right',
|
|
||||||
key: 'enrollment_count',
|
key: 'enrollment_count',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -142,13 +130,12 @@ const removeCourse = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeCourses = (selections, unselectAll) => {
|
const removeCourses = (selections) => {
|
||||||
|
console.log(selections)
|
||||||
selections.forEach(async (course) => {
|
selections.forEach(async (course) => {
|
||||||
removeCourse.submit({ course })
|
removeCourse.submit({ course })
|
||||||
|
await setTimeout(1000)
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
courses.reload()
|
||||||
courses.reload()
|
|
||||||
unselectAll()
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<template>
|
<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
|
<Badge
|
||||||
v-if="batch.data.seat_count && seats_left > 0"
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
theme="green"
|
theme="green"
|
||||||
class="self-start mb-2 float-right"
|
class="self-start mb-2 float-right"
|
||||||
>
|
>
|
||||||
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
|
{{ seats_left }} {{ __('Seat Left') }}
|
||||||
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
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" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
<DateRange
|
|
||||||
:startDate="batch.data.start_date"
|
|
||||||
:endDate="batch.data.end_date"
|
|
||||||
class="mb-3"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center 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" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
{{ formatTime(batch.data.end_time) }}
|
{{ formatTime(batch.data.end_time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<router-link
|
||||||
v-if="isModerator || isStudent"
|
v-if="user?.data?.is_moderator"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Batch',
|
name: 'Batch',
|
||||||
params: {
|
params: {
|
||||||
@@ -51,7 +46,7 @@
|
|||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<Button variant="solid" class="w-full mt-4">
|
||||||
<span>
|
<span>
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
{{ __('Manage Batch') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -63,9 +58,9 @@
|
|||||||
name: batch.data.name,
|
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>
|
<span>
|
||||||
{{ __('Register Now') }}
|
{{ __('Register Now') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,12 +69,12 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full mt-2"
|
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') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isModerator"
|
v-if="user?.data?.is_moderator"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchCreation',
|
name: 'BatchCreation',
|
||||||
params: {
|
params: {
|
||||||
@@ -96,12 +91,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
|
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button } from 'frappe-ui'
|
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 user = inject('$user')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -117,12 +112,4 @@ const seats_left = computed(() => {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const isStudent = computed(() => {
|
|
||||||
return props.batch.data?.students?.includes(user.data?.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isModerator = computed(() => {
|
|
||||||
return user.data?.is_moderator
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -52,10 +52,7 @@
|
|||||||
<ListSelectBanner>
|
<ListSelectBanner>
|
||||||
<template #actions="{ unselectAll, selections }">
|
<template #actions="{ unselectAll, selections }">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button variant="ghost" @click="removeStudents(selections)">
|
||||||
variant="ghost"
|
|
||||||
@click="removeStudents(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +109,6 @@ const getStudentColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Full Name',
|
label: 'Full Name',
|
||||||
key: 'full_name',
|
key: 'full_name',
|
||||||
width: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Courses Done',
|
label: 'Courses Done',
|
||||||
@@ -145,13 +141,11 @@ const removeStudent = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeStudents = (selections, unselectAll) => {
|
const removeStudents = (selections) => {
|
||||||
selections.forEach(async (student) => {
|
selections.forEach(async (student) => {
|
||||||
removeStudent.submit({ student })
|
removeStudent.submit({ student })
|
||||||
|
await setTimeout(1000)
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
students.reload()
|
||||||
students.reload()
|
|
||||||
unselectAll()
|
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
</script>
|
</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
|
<div
|
||||||
v-if="course.title"
|
v-if="course.title"
|
||||||
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
||||||
style="min-height: 350px"
|
style="min-height: 320px"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="course-image"
|
class="course-image"
|
||||||
:class="{ 'default-image': !course.image }"
|
:class="{ 'default-image': !course.image }"
|
||||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
||||||
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
<Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,12 +65,15 @@
|
|||||||
<div class="short-introduction">
|
<div class="short-introduction">
|
||||||
{{ course.short_introduction }}
|
{{ course.short_introduction }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<ProgressBar
|
|
||||||
v-if="user && course.membership"
|
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">
|
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||||
{{ Math.ceil(course.membership.progress) }}% completed
|
{{ Math.ceil(course.membership.progress) }}% completed
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +81,7 @@
|
|||||||
<div class="flex items-center justify-between mt-auto">
|
<div class="flex items-center justify-between mt-auto">
|
||||||
<div class="flex avatar-group overlap">
|
<div class="flex avatar-group overlap">
|
||||||
<div
|
<div
|
||||||
class="h-6 mr-1"
|
class="mr-1"
|
||||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -96,7 +89,17 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
@@ -111,8 +114,6 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
|
|||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Badge, Tooltip } from 'frappe-ui'
|
import { Badge, Tooltip } from 'frappe-ui'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
|
||||||
|
|
||||||
const { user } = sessionStore()
|
const { user } = sessionStore()
|
||||||
|
|
||||||
@@ -149,8 +150,8 @@ const props = defineProps({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: theme('colors.green.100');
|
background-color: theme('colors.orange.100');
|
||||||
color: theme('colors.green.600');
|
color: theme('colors.orange.600');
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.data.current_lesson
|
chapterNumber: course.data.current_lesson
|
||||||
? course.data.current_lesson.split('-')[0]
|
? course.data.current_lesson.split('.')[0]
|
||||||
: 1,
|
: 1,
|
||||||
lessonNumber: course.data.current_lesson
|
lessonNumber: course.data.current_lesson
|
||||||
? course.data.current_lesson.split('-')[1]
|
? course.data.current_lesson.split('.')[1]
|
||||||
: 1,
|
: 1,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
@@ -46,12 +46,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</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
|
<Button
|
||||||
v-else
|
v-else
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
@@ -63,15 +57,6 @@
|
|||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
v-if="canGetCertificate"
|
|
||||||
@click="fetchCertificate()"
|
|
||||||
variant="subtle"
|
|
||||||
class="w-full mt-2"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{{ __('Get Certificate') }}
|
|
||||||
</Button>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user?.data?.is_moderator || is_instructor()"
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -145,11 +130,12 @@ function enrollStudent() {
|
|||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 2000)
|
}, 3000)
|
||||||
} else {
|
} else {
|
||||||
const enrollStudentResource = createResource({
|
const enrollStudentResource = createResource({
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||||
})
|
})
|
||||||
|
console.log(props.course)
|
||||||
enrollStudentResource
|
enrollStudentResource
|
||||||
.submit({
|
.submit({
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
@@ -174,48 +160,5 @@ function enrollStudent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const is_instructor = () => {
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</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 class="text-base">
|
||||||
<div
|
<div
|
||||||
v-if="title && (outline.data?.length || allowEdit)"
|
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) }}
|
{{ __(title) }}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex w-full p-2">
|
<DisclosureButton ref="" class="flex w-full px-2 py-3">
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
:class="{
|
:class="{
|
||||||
'rotate-90 transform duration-200': open,
|
'rotate-90 transform duration-200': open,
|
||||||
@@ -38,55 +38,42 @@
|
|||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</div>
|
</div>
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel>
|
<DisclosurePanel class="pb-2">
|
||||||
<Draggable
|
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
||||||
:list="chapter.lessons"
|
<div class="outline-lesson pl-8 py-2">
|
||||||
item-key="name"
|
<router-link
|
||||||
group="items"
|
:to="{
|
||||||
@end="updateOutline"
|
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
||||||
:data-chapter="chapter.name"
|
params: {
|
||||||
>
|
courseName: courseName,
|
||||||
<template #item="{ element: lesson }">
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
<div class="outline-lesson pl-8 py-2 pr-4">
|
lessonNumber: lesson.number.split('.')[1],
|
||||||
<router-link
|
},
|
||||||
:to="{
|
}"
|
||||||
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
>
|
||||||
params: {
|
<div class="flex items-center text-sm leading-5">
|
||||||
courseName: courseName,
|
<MonitorPlay
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
v-if="lesson.icon === 'icon-youtube'"
|
||||||
lessonNumber: lesson.number.split('.')[1],
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
},
|
/>
|
||||||
}"
|
<HelpCircle
|
||||||
>
|
v-else-if="lesson.icon === 'icon-quiz'"
|
||||||
<div class="flex items-center text-sm leading-5 group">
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
<MonitorPlay
|
/>
|
||||||
v-if="lesson.icon === 'icon-youtube'"
|
<FileText
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
v-else-if="lesson.icon === 'icon-list'"
|
||||||
/>
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
<HelpCircle
|
/>
|
||||||
v-else-if="lesson.icon === 'icon-quiz'"
|
{{ lesson.title }}
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
<Check
|
||||||
/>
|
v-if="lesson.is_complete"
|
||||||
<FileText
|
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
||||||
v-else-if="lesson.icon === 'icon-list'"
|
/>
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
</div>
|
||||||
/>
|
</router-link>
|
||||||
{{ lesson.title }}
|
</div>
|
||||||
<Trash2
|
</div>
|
||||||
v-if="allowEdit"
|
<div v-if="allowEdit" class="flex mt-2 pl-8">
|
||||||
@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">
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateLesson',
|
name: 'CreateLesson',
|
||||||
@@ -119,7 +106,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -127,11 +113,9 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
FileText,
|
FileText,
|
||||||
Check,
|
Check,
|
||||||
Trash2,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const expandAll = ref(true)
|
const expandAll = ref(true)
|
||||||
@@ -155,10 +139,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
getProgress: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const outline = createResource({
|
const outline = createResource({
|
||||||
@@ -166,47 +146,10 @@ const outline = createResource({
|
|||||||
cache: ['course_outline', props.courseName],
|
cache: ['course_outline', props.courseName],
|
||||||
params: {
|
params: {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
progress: props.getProgress,
|
|
||||||
},
|
},
|
||||||
auto: true,
|
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) => {
|
const openChapterDetail = (index) => {
|
||||||
return index == route.params.chapterNumber || index == 1
|
return index == route.params.chapterNumber || index == 1
|
||||||
}
|
}
|
||||||
@@ -219,15 +162,6 @@ const openChapterModal = (chapter = null) => {
|
|||||||
const getCurrentChapter = () => {
|
const getCurrentChapter = () => {
|
||||||
return currentChapter.value
|
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>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.outline-lesson:has(.router-link-active) {
|
.outline-lesson:has(.router-link-active) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
|
<div v-if="reviews.data" class="mt-20 mb-10">
|
||||||
<Button
|
<Button
|
||||||
v-if="membership && !hasReviewed.data"
|
v-if="membership && !hasReviewed.data"
|
||||||
@click="openReviewModal()"
|
@click="openReviewModal()"
|
||||||
@@ -8,30 +8,18 @@
|
|||||||
{{ __('Write a Review') }}
|
{{ __('Write a Review') }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center font-semibold text-2xl">
|
<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>
|
||||||
<div class="grid gap-8 mt-10">
|
<div class="grid gap-8 mt-10">
|
||||||
<div v-for="(review, index) in reviews.data">
|
<div v-for="(review, index) in reviews.data">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<router-link
|
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: review.owner_details.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
|
||||||
</router-link>
|
|
||||||
<div class="mx-4">
|
<div class="mx-4">
|
||||||
<router-link
|
<span class="text-lg font-medium mr-4">
|
||||||
:to="{
|
{{ review.owner_details.full_name }}
|
||||||
name: 'Profile',
|
</span>
|
||||||
params: { username: review.owner_details.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="text-lg font-medium mr-4">
|
|
||||||
{{ review.owner_details.full_name }}
|
|
||||||
</span>
|
|
||||||
</router-link>
|
|
||||||
<span>
|
<span>
|
||||||
{{ review.creation }}
|
{{ review.creation }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<div
|
<div
|
||||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||||
>
|
>
|
||||||
|
<slot name="sidebar" />
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-auto" id="scrollContainer">
|
<div class="w-full overflow-auto" id="scrollContainer">
|
||||||
|
|||||||
@@ -69,11 +69,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
:content="newReply"
|
:content="newReply"
|
||||||
:mentions="mentionUsers"
|
|
||||||
@change="(val) => (newReply = val)"
|
@change="(val) => (newReply = val)"
|
||||||
placeholder="Type your reply here..."
|
placeholder="Type your reply here..."
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
@@ -94,14 +92,13 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted, computed } from 'vue'
|
import { ref, inject, onMounted } from 'vue'
|
||||||
import { createToast } from '../utils'
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
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 = () => {
|
const postReply = () => {
|
||||||
newReplyResource.submit(
|
newReplyResource.submit(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -40,16 +40,13 @@
|
|||||||
<div v-else-if="singleThread && topics.data">
|
<div v-else-if="singleThread && topics.data">
|
||||||
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="flex justify-center border mt-5 p-5 rounded-md">
|
||||||
v-else
|
<MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
|
||||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
<div>
|
||||||
>
|
<div class="text-xl font-semibold mb-2">
|
||||||
<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">
|
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">
|
<div>
|
||||||
{{ __(emptyStateText) }}
|
{{ __(emptyStateText) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,14 +60,13 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button, TextEditor } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareText } from 'lucide-vue-next'
|
import { MessageSquareIcon } from 'lucide-vue-next'
|
||||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
|
||||||
|
|
||||||
const showTopics = ref(true)
|
const showTopics = ref(true)
|
||||||
const currentTopic = ref(null)
|
const currentTopic = ref(null)
|
||||||
@@ -93,20 +89,16 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateTitle: {
|
emptyStateTitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: 'No topics yet',
|
||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Start a discussion',
|
default: 'Be the first to start a discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
scrollToBottom: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -115,19 +107,8 @@ onMounted(() => {
|
|||||||
socket.on('new_discussion_topic', (data) => {
|
socket.on('new_discussion_topic', (data) => {
|
||||||
topics.refresh()
|
topics.refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (props.scrollToBottom) {
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollToEnd()
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollToEnd = () => {
|
|
||||||
let scrollContainer = getScrollContainer()
|
|
||||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
const topics = createResource({
|
const topics = createResource({
|
||||||
url: 'lms.lms.utils.get_discussion_topics',
|
url: 'lms.lms.utils.get_discussion_topics',
|
||||||
cache: ['topics', props.doctype, props.docname],
|
cache: ['topics', props.doctype, props.docname],
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
|
<div class="flex shadow rounded-md p-4 h-full">
|
||||||
<div class="flex w-3/5 md:w-2/5">
|
<img
|
||||||
<img
|
:src="job.company_logo"
|
||||||
:src="job.company_logo"
|
class="w-12 h-12 rounded-lg object-contain mr-4"
|
||||||
class="w-12 h-12 rounded-lg object-contain mr-4"
|
:alt="job.company_name"
|
||||||
:alt="job.company_name"
|
/>
|
||||||
/>
|
<div>
|
||||||
<div>
|
<div class="text-xl font-semibold mb-2">
|
||||||
<div class="font-medium mb-1">
|
{{ job.job_title }}
|
||||||
{{ job.job_title }}
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="text-gray-700">
|
{{ __('posted by') }}
|
||||||
{{ job.company_name }}
|
<span class="font-medium">
|
||||||
</div>
|
{{ 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>
|
|
||||||
<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>
|
</div>
|
||||||
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
||||||
|
|||||||
@@ -24,12 +24,7 @@
|
|||||||
<Quiz :quiz="getId(block)" />
|
<Quiz :quiz="getId(block)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="block.includes('{{ Video')">
|
<div v-else-if="block.includes('{{ Video')">
|
||||||
<video
|
<video controls width="100%" controlsList="nodownload">
|
||||||
controls
|
|
||||||
width="100%"
|
|
||||||
controlsList="nodownload"
|
|
||||||
oncontextmenu="return false;"
|
|
||||||
>
|
|
||||||
<source :src="getId(block)" type="video/mp4" />
|
<source :src="getId(block)" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
{{
|
{{
|
||||||
uploading
|
uploading
|
||||||
? __('Uploading {0}%').format(progress)
|
? __('Uploading {0}%').format(progress)
|
||||||
: __('Upload a File')
|
: __('Upload an File')
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,34 +68,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
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 { ref, watch } from 'vue'
|
||||||
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
|
|
||||||
|
|
||||||
const quiz = ref(null)
|
const quiz = ref(null)
|
||||||
const file = ref(null)
|
const file = ref(null)
|
||||||
@@ -129,7 +108,7 @@ const addFile = (data) => {
|
|||||||
|
|
||||||
const validateFile = (file) => {
|
const validateFile = (file) => {
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
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.'
|
return 'Only image and video files are allowed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Button
|
<Button
|
||||||
v-if="user.data.is_moderator"
|
v-if="user.data.is_moderator"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="float-right mb-5"
|
class="float-right mb-3"
|
||||||
@click="openLiveClassModal"
|
@click="openLiveClassModal"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -12,49 +12,48 @@
|
|||||||
{{ __('Add Live Class') }}
|
{{ __('Add Live Class') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-lg font-semibold mb-5">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Live Class') }}
|
{{ __('Live Class') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||||
<div
|
<div v-for="cls in liveClasses.data">
|
||||||
v-for="cls in liveClasses.data"
|
<div class="border rounded-md p-3">
|
||||||
class="flex flex-col border rounded-md h-full p-3"
|
<div class="font-semibold text-lg mb-4">
|
||||||
>
|
{{ cls.title }}
|
||||||
<div class="font-semibold text-lg mb-4">
|
</div>
|
||||||
{{ cls.title }}
|
<div class="flex items-center mb-2">
|
||||||
</div>
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<div class="mb-4">
|
<span class="ml-2">
|
||||||
{{ cls.description }}
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center mb-2">
|
</div>
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
<div class="flex items-center mb-5">
|
||||||
<span class="ml-2">
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
<span class="ml-2">
|
||||||
</span>
|
{{ formatTime(cls.time) }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center mb-5">
|
</div>
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<div class="mb-5">
|
||||||
<span class="ml-2">
|
{{ cls.description }}
|
||||||
{{ formatTime(cls.time) }}
|
</div>
|
||||||
</span>
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<a
|
||||||
<div class="flex items-center space-x-2 mt-auto">
|
:href="cls.start_url"
|
||||||
<a
|
target="_blank"
|
||||||
:href="cls.start_url"
|
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"
|
||||||
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') }}
|
||||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
</a>
|
||||||
{{ __('Start') }}
|
<a
|
||||||
</a>
|
:href="cls.join_url"
|
||||||
<a
|
target="_blank"
|
||||||
:href="cls.join_url"
|
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"
|
||||||
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') }}
|
||||||
<Video class="h-4 w-4 stroke-1.5" />
|
</a>
|
||||||
{{ __('Join') }}
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
@click="handleClick(tab)"
|
@click="handleClick(tab)"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="icons[tab.icon]"
|
:is="tab.icon"
|
||||||
class="h-6 w-6 stroke-1.5"
|
class="h-6 w-6 stroke-1.5"
|
||||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||||
/>
|
/>
|
||||||
@@ -29,42 +29,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { usersStore } from '@/stores/user'
|
|
||||||
import * as icons from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const { logout, user } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
let links = getSidebarLinks()
|
return 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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
@@ -77,13 +50,6 @@ const handleClick = (tab) => {
|
|||||||
logout.submit().then(() => {
|
logout.submit().then(() => {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
})
|
})
|
||||||
else if (tab.label == 'Profile')
|
|
||||||
router.push({
|
|
||||||
name: 'Profile',
|
|
||||||
params: {
|
|
||||||
username: userResource.data?.username,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
else router.push({ name: tab.to })
|
else router.push({ name: tab.to })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
|
<Link doctype="LMS Course" v-model="course" />
|
||||||
<Link
|
|
||||||
doctype="Course Evaluator"
|
|
||||||
v-model="evaluator"
|
|
||||||
:label="__('Evaluator')"
|
|
||||||
class="mt-4"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,7 +26,6 @@ import { showToast } from '@/utils'
|
|||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const course = ref(null)
|
const course = ref(null)
|
||||||
const evaluator = ref(null)
|
|
||||||
const courses = defineModel('courses')
|
const courses = defineModel('courses')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -52,7 +45,6 @@ const createBatchCourse = createResource({
|
|||||||
parenttype: 'LMS Batch',
|
parenttype: 'LMS Batch',
|
||||||
parentfield: 'courses',
|
parentfield: 'courses',
|
||||||
course: course.value,
|
course: course.value,
|
||||||
evaluator: evaluator.value,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,7 +58,6 @@ const addCourse = (close) => {
|
|||||||
courses.value.reload()
|
courses.value.reload()
|
||||||
close()
|
close()
|
||||||
course.value = null
|
course.value = null
|
||||||
evaluator.value = null
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message[0] || err, 'x')
|
showToast('Error', err.message[0] || err, 'x')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
size: '2xl',
|
size: '2xl',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: 'Post',
|
label: 'Submit',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitTopic(close),
|
onClick: (close) => submitTopic(close),
|
||||||
},
|
},
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<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>
|
<div>
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
@@ -34,9 +37,8 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||||
import { reactive, defineModel, computed } from 'vue'
|
import { reactive, defineModel } from 'vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const topics = defineModel('reloadTopics')
|
const topics = defineModel('reloadTopics')
|
||||||
|
|
||||||
@@ -91,14 +93,6 @@ const submitTopic = (close) => {
|
|||||||
topicResource.submit(
|
topicResource.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
validate() {
|
|
||||||
if (!topic.title) {
|
|
||||||
return 'Title cannot be empty.'
|
|
||||||
}
|
|
||||||
if (!topic.reply) {
|
|
||||||
return 'Reply cannot be empty.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
replyResource.submit(
|
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">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
{{ __('Date') }}
|
{{ __('Date') }}
|
||||||
</div>
|
</div>
|
||||||
<FormControl type="date" v-model="evaluation.date" />
|
<DatePicker v-model="evaluation.date" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="slots.data?.length">
|
<div v-if="slots.data?.length">
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
@@ -46,10 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="text-sm italic text-red-600">
|
||||||
v-else-if="evaluation.course && evaluation.date"
|
|
||||||
class="text-sm italic text-red-600"
|
|
||||||
>
|
|
||||||
{{ __('No slots available for this date.') }}
|
{{ __('No slots available for this date.') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +54,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 { defineModel, reactive, watch, inject } from 'vue'
|
||||||
import { createToast, formatTime } from '@/utils/'
|
import { createToast, formatTime } from '@/utils/'
|
||||||
|
|
||||||
@@ -116,7 +113,7 @@ function submitEvaluation(close) {
|
|||||||
if (!evaluation.start_time) {
|
if (!evaluation.start_time) {
|
||||||
return 'Please select a slot.'
|
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.'
|
return 'Please select a future date.'
|
||||||
}
|
}
|
||||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||||
@@ -130,14 +127,11 @@ function submitEvaluation(close) {
|
|||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
let message = err.messages?.[0] || err
|
|
||||||
let unavailabilityMessage = message.includes('unavailable')
|
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
|
title: 'Error',
|
||||||
text: message,
|
text: err.messages?.[0] || err,
|
||||||
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
icon: 'x',
|
||||||
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
position: 'top-center',
|
position: 'top-center',
|
||||||
timeout: 10,
|
timeout: 10,
|
||||||
})
|
})
|
||||||
@@ -171,7 +165,7 @@ watch(
|
|||||||
() => evaluation.date,
|
() => evaluation.date,
|
||||||
(date) => {
|
(date) => {
|
||||||
evaluation.start_time = ''
|
evaluation.start_time = ''
|
||||||
if (date && evaluation.course) {
|
if (date) {
|
||||||
slots.submit(evaluation)
|
slots.submit(evaluation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,61 +17,78 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<div class="mb-4">
|
||||||
type="text"
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
v-model="liveClass.title"
|
{{ __('Title') }}
|
||||||
:label="__('Title')"
|
</div>
|
||||||
class="mb-4"
|
<Input type="text" v-model="liveClass.title" />
|
||||||
/>
|
</div>
|
||||||
<Tooltip
|
<div class="mb-4">
|
||||||
:text="
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
__(
|
<Tooltip
|
||||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
class="flex items-center"
|
||||||
)
|
: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')"
|
<span>
|
||||||
class="mb-4"
|
{{ __('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>
|
</div>
|
||||||
<FormControl
|
|
||||||
v-model="liveClass.timezone"
|
|
||||||
type="select"
|
|
||||||
:options="getTimezoneOptions()"
|
|
||||||
:label="__('Timezone')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<div class="mb-4">
|
||||||
v-model="liveClass.date"
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
type="date"
|
{{ __('Date') }}
|
||||||
class="mb-4"
|
</div>
|
||||||
:label="__('Date')"
|
<DatePicker v-model="liveClass.date" inputClass="w-full" />
|
||||||
/>
|
</div>
|
||||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
<div class="mb-4">
|
||||||
<FormControl
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
type="number"
|
<Tooltip
|
||||||
v-model="liveClass.duration"
|
class="flex items-center"
|
||||||
:label="__('Duration')"
|
:text="__('Duration of the live class in minutes')"
|
||||||
class="mb-4"
|
>
|
||||||
|
<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>
|
</div>
|
||||||
<FormControl
|
|
||||||
v-model="liveClass.auto_recording"
|
|
||||||
type="select"
|
|
||||||
:options="getRecordingOptions()"
|
|
||||||
:label="__('Auto Recording')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<div>
|
||||||
v-model="liveClass.description"
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
type="textarea"
|
{{ __('Description') }}
|
||||||
:label="__('Description')"
|
</div>
|
||||||
/>
|
<Textarea v-model="liveClass.description" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -85,7 +102,6 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
createResource,
|
createResource,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
FormControl,
|
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, inject } from 'vue'
|
import { reactive, inject } from 'vue'
|
||||||
import { getTimezones, createToast } from '@/utils/'
|
import { getTimezones, createToast } from '@/utils/'
|
||||||
@@ -153,7 +169,7 @@ const createLiveClass = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const submitLiveClass = (close) => {
|
const submitLiveClass = (close) => {
|
||||||
return createLiveClass.submit(liveClass, {
|
createLiveClass.submit(liveClass, {
|
||||||
validate() {
|
validate() {
|
||||||
if (!liveClass.title) {
|
if (!liveClass.title) {
|
||||||
return 'Please enter a 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">
|
<span class="mr-2">
|
||||||
{{ __('Question {0}').format(activeQuestion) }}:
|
{{ __('Question {0}').format(activeQuestion) }}:
|
||||||
</span>
|
</span>
|
||||||
<span v-if="questionDetails.data.type == 'User Input'">
|
<span>
|
||||||
{{ __('Type your answer') }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{
|
{{
|
||||||
questionDetails.data.multiple
|
questionDetails.data.multiple
|
||||||
? __('Choose all answers that apply')
|
? __('Choose all answers that apply')
|
||||||
@@ -85,10 +82,9 @@
|
|||||||
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
|
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-gray-900 font-semibold mt-2">
|
||||||
class="text-gray-900 font-semibold mt-2"
|
{{ questionDetails.data.question }}
|
||||||
v-html="questionDetails.data.question"
|
</div>
|
||||||
></div>
|
|
||||||
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
|
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
|
||||||
<label
|
<label
|
||||||
v-if="questionDetails.data[`option_${index}`]"
|
v-if="questionDetails.data[`option_${index}`]"
|
||||||
@@ -127,41 +123,18 @@
|
|||||||
<MinusCircle v-else class="w-4 h-4" />
|
<MinusCircle v-else class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span class="ml-2">
|
||||||
class="ml-2"
|
{{ questionDetails.data[`option_${index}`] }}
|
||||||
v-html="questionDetails.data[`option_${index}`]"
|
|
||||||
>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
v-if="questionDetails.data[`explanation_${index}`]"
|
v-if="questionDetails.data[`explanation_${index}`]"
|
||||||
class="mt-2 text-xs"
|
class="mt-2 text-sm hidden"
|
||||||
v-show="showAnswers.length"
|
|
||||||
>
|
>
|
||||||
{{ questionDetails.data[`explanation_${index}`] }}
|
{{ questionDetails.data[`explanation_${index}`] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div class="flex items-center justify-between mt-8">
|
||||||
<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>
|
<div>
|
||||||
{{
|
{{
|
||||||
__('Question {0} of {1}').format(
|
__('Question {0} of {1}').format(
|
||||||
@@ -238,19 +211,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 { ref, watch, reactive, inject } from 'vue'
|
||||||
import { createToast } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
import FormControl from 'frappe-ui/src/components/FormControl.vue'
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
const activeQuestion = ref(0)
|
const activeQuestion = ref(0)
|
||||||
const currentQuestion = ref('')
|
const currentQuestion = ref('')
|
||||||
const selectedOptions = reactive([0, 0, 0, 0])
|
const selectedOptions = reactive([0, 0, 0, 0])
|
||||||
const showAnswers = reactive([])
|
const showAnswers = reactive([])
|
||||||
const possibleAnswer = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -270,12 +246,8 @@ const quiz = createResource({
|
|||||||
cache: ['quiz', props.quizName],
|
cache: ['quiz', props.quizName],
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
if (data.shuffle_questions) {
|
attempts.reload()
|
||||||
data.questions = data.questions.sort(() => Math.random() - 0.5)
|
resetQuiz()
|
||||||
}
|
|
||||||
if (data.limit_questions_to) {
|
|
||||||
data.questions = data.questions.slice(0, data.limit_questions_to)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -307,16 +279,6 @@ const attempts = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => quiz.data,
|
|
||||||
() => {
|
|
||||||
if (quiz.data) {
|
|
||||||
attempts.reload()
|
|
||||||
resetQuiz()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const quizSubmission = createResource({
|
const quizSubmission = createResource({
|
||||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -346,6 +308,7 @@ watch(activeQuestion, (value) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.quizName,
|
() => props.quizName,
|
||||||
(newName) => {
|
(newName) => {
|
||||||
|
console.log(newName)
|
||||||
if (newName) {
|
if (newName) {
|
||||||
quiz.reload()
|
quiz.reload()
|
||||||
}
|
}
|
||||||
@@ -365,17 +328,10 @@ const markAnswer = (index) => {
|
|||||||
|
|
||||||
const getAnswers = () => {
|
const getAnswers = () => {
|
||||||
let answers = []
|
let answers = []
|
||||||
const type = questionDetails.data.type
|
selectedOptions.forEach((value, index) => {
|
||||||
|
if (selectedOptions[index])
|
||||||
if (type == 'Choices') {
|
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||||
selectedOptions.forEach((value, index) => {
|
})
|
||||||
if (selectedOptions[index])
|
|
||||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
answers.push(possibleAnswer.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return answers
|
return answers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,8 +341,7 @@ const checkAnswer = () => {
|
|||||||
createToast({
|
createToast({
|
||||||
title: 'Please select an option',
|
title: 'Please select an option',
|
||||||
icon: 'alert-circle',
|
icon: 'alert-circle',
|
||||||
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
|
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||||
position: 'top-center',
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -400,20 +355,15 @@ const checkAnswer = () => {
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
let type = questionDetails.data.type
|
selectedOptions.forEach((option, index) => {
|
||||||
if (type == 'Choices') {
|
if (option) {
|
||||||
selectedOptions.forEach((option, index) => {
|
showAnswers[index] = option && data[index]
|
||||||
if (option) {
|
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
||||||
showAnswers[index] = option && data[index]
|
showAnswers[index] = 0
|
||||||
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
} else {
|
||||||
showAnswers[index] = 0
|
showAnswers[index] = undefined
|
||||||
} else {
|
}
|
||||||
showAnswers[index] = undefined
|
})
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showAnswers.push(data)
|
|
||||||
}
|
|
||||||
addToLocalStorage()
|
addToLocalStorage()
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers) {
|
||||||
resetQuestion()
|
resetQuestion()
|
||||||
@@ -426,7 +376,7 @@ const addToLocalStorage = () => {
|
|||||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||||
let questionData = {
|
let questionData = {
|
||||||
question_index: activeQuestion.value,
|
question_index: activeQuestion.value,
|
||||||
answer: getAnswers().join(),
|
answers: getAnswers().join(),
|
||||||
is_correct: showAnswers.filter((answer) => {
|
is_correct: showAnswers.filter((answer) => {
|
||||||
return answer != undefined
|
return answer != undefined
|
||||||
}),
|
}),
|
||||||
@@ -448,7 +398,6 @@ const resetQuestion = () => {
|
|||||||
activeQuestion.value = activeQuestion.value + 1
|
activeQuestion.value = activeQuestion.value + 1
|
||||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||||
showAnswers.length = 0
|
showAnswers.length = 0
|
||||||
possibleAnswer.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitQuiz = () => {
|
const submitQuiz = () => {
|
||||||
|
|||||||
@@ -23,8 +23,4 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const redirectToLogin = () => {
|
|
||||||
window.location.href = `/login`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,21 +6,21 @@
|
|||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<div
|
<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'"
|
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||||
>
|
>
|
||||||
<Tooltip :text="link.label" placement="right">
|
<Tooltip :text="link.label" placement="right">
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
<component
|
<component
|
||||||
:is="icons[link.icon]"
|
:is="link.icon"
|
||||||
class="h-4 w-4 stroke-1.5 text-gray-800"
|
class="h-5 w-5 stroke-1.5 text-gray-800"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</slot>
|
</slot>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span
|
<span
|
||||||
class="flex-shrink-0 text-sm duration-300 ease-in-out"
|
class="flex-shrink-0 text-base duration-300 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||||
@@ -29,35 +29,16 @@
|
|||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Tooltip, Button } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import * as icons from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const emit = defineEmits(['openModal', 'deletePage'])
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
link: {
|
link: {
|
||||||
@@ -68,29 +49,13 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
showControls: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (router.hasRoute(props.link.to)) {
|
router.push({ name: props.link.to })
|
||||||
router.push({ name: props.link.to })
|
|
||||||
} else if (props.link.to) {
|
|
||||||
window.location.href = `/${props.link.to}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = computed(() => {
|
let isActive = computed(() => {
|
||||||
return props.link?.activeFor?.includes(router.currentRoute.value.name)
|
return props.link?.activeFor?.includes(router.currentRoute.value.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const openModal = (link) => {
|
|
||||||
emit('openModal', link)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletePage = (link) => {
|
|
||||||
emit('deletePage', link)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ const props = defineProps({
|
|||||||
default: 'Tags',
|
default: 'Tags',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
console.log(props.modelValue)
|
||||||
let tags = ref(props.modelValue)
|
let tags = ref(props.modelValue)
|
||||||
|
console.log(tags.value)
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
let newTag = ref('')
|
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>
|
<template>
|
||||||
<Tooltip :text="user.full_name">
|
<Avatar
|
||||||
<Avatar
|
class="avatar border border-gray-300"
|
||||||
class="avatar border border-gray-300 cursor-auto"
|
v-if="user"
|
||||||
v-if="user"
|
:label="user.full_name"
|
||||||
:label="user.full_name"
|
:image="user.user_image"
|
||||||
:image="user.user_image"
|
:size="size"
|
||||||
:size="size"
|
v-bind="$attrs"
|
||||||
v-bind="$attrs"
|
/>
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Avatar, Tooltip } from 'frappe-ui'
|
import { Avatar } from 'frappe-ui'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
user: {
|
user: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -26,21 +26,13 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="text-base font-medium text-gray-900 leading-none">
|
<div class="text-base font-medium text-gray-900 leading-none">
|
||||||
<span
|
<span v-if="branding.data?.brand_name">
|
||||||
v-if="
|
|
||||||
branding.data?.brand_name &&
|
|
||||||
branding.data?.brand_name != 'Frappe'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ branding.data?.brand_name }}
|
{{ branding.data?.brand_name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else> Learning </span>
|
<span v-else> Learning </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
|
||||||
v-if="userResource"
|
{{ convertToTitleCase(user.split('@')[0]) }}
|
||||||
class="mt-1 text-sm text-gray-700 leading-none"
|
|
||||||
>
|
|
||||||
{{ convertToTitleCase(userResource.data?.full_name) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -61,23 +53,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Dropdown } from 'frappe-ui'
|
import { Dropdown, createResource } from 'frappe-ui'
|
||||||
import {
|
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
||||||
ChevronDown,
|
|
||||||
LogIn,
|
|
||||||
LogOut,
|
|
||||||
User,
|
|
||||||
ArrowRightLeft,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { convertToTitleCase } from '../utils'
|
import { convertToTitleCase } from '../utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { logout, branding } = sessionStore()
|
|
||||||
let { userResource } = usersStore()
|
|
||||||
let { isLoggedIn } = sessionStore()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isCollapsed: {
|
isCollapsed: {
|
||||||
type: Boolean,
|
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 = [
|
const userDropdownOptions = [
|
||||||
{
|
/* {
|
||||||
icon: User,
|
icon: User,
|
||||||
label: 'My Profile',
|
label: 'My Profile',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
router.push(`/user/${userResource.data?.username}`)
|
router.push(`/user/${user.data?.username}`)
|
||||||
},
|
},
|
||||||
condition: () => {
|
condition: () => {
|
||||||
return isLoggedIn
|
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,
|
icon: LogOut,
|
||||||
label: 'Log out',
|
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.provide('$socket', initSocket())
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
const { userResource, allUsers } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
app.provide('$allUsers', allUsers)
|
|
||||||
app.config.globalProperties.$user = userResource
|
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>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</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">
|
<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">
|
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ tab }">
|
<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'">
|
<div v-if="tab.label == 'Courses'">
|
||||||
<BatchCourses :batch="batch.data.name" />
|
<BatchCourses :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,10 +66,9 @@
|
|||||||
<Discussions
|
<Discussions
|
||||||
doctype="LMS Batch"
|
doctype="LMS Batch"
|
||||||
:docname="batch.data.name"
|
:docname="batch.data.name"
|
||||||
:title="__('Discussions')"
|
title="Discussions"
|
||||||
:key="batch.data.name"
|
:key="batch.data.name"
|
||||||
:singleThread="true"
|
:singleThread="true"
|
||||||
:scrollToBottom="true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,40 +79,21 @@
|
|||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-2xl font-semibold mb-2">
|
||||||
{{ batch.data.title }}
|
{{ batch.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
<div class="flex avatar-group overlap mb-5">
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<div
|
<span>
|
||||||
class="h-6 mr-1"
|
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
|
||||||
:class="{
|
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
|
||||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
</span>
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-for="instructor in batch.data.instructors"
|
|
||||||
:user="instructor"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CourseInstructors :instructors="batch.data.instructors" />
|
|
||||||
</div>
|
</div>
|
||||||
<DateRange
|
<div class="flex items-center mb-6">
|
||||||
:startDate="batch.data.start_date"
|
|
||||||
:endDate="batch.data.end_date"
|
|
||||||
class="mb-3"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
{{ formatTime(batch.data.end_time) }}
|
{{ formatTime(batch.data.end_time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<AnnouncementModal
|
<AnnouncementModal
|
||||||
v-model="showAnnouncementModal"
|
v-model="showAnnouncementModal"
|
||||||
@@ -169,9 +149,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import {
|
import {
|
||||||
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -181,9 +160,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Globe,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
import { formatTime } from '@/utils'
|
||||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||||
import BatchCourses from '@/components/BatchCourses.vue'
|
import BatchCourses from '@/components/BatchCourses.vue'
|
||||||
import LiveClass from '@/components/LiveClass.vue'
|
import LiveClass from '@/components/LiveClass.vue'
|
||||||
@@ -192,8 +170,8 @@ import Assessments from '@/components/Assessments.vue'
|
|||||||
import Announcements from '@/components/Annoucements.vue'
|
import Announcements from '@/components/Annoucements.vue'
|
||||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showAnnouncementModal = ref(false)
|
const showAnnouncementModal = ref(false)
|
||||||
|
|
||||||
@@ -286,13 +264,4 @@ const redirectToLogin = () => {
|
|||||||
const openAnnouncementModal = () => {
|
const openAnnouncementModal = () => {
|
||||||
showAnnouncementModal.value = true
|
showAnnouncementModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: batch.data?.title,
|
|
||||||
description: batch.data?.description,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,86 +8,76 @@
|
|||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div class="w-1/2 mx-auto py-5">
|
<div class="py-5">
|
||||||
<div class="">
|
<div class="container">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Details') }}
|
{{ __('Details') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-10 mb-4">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.title"
|
v-model="batch.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
type="textarea"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2">
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.published"
|
v-model="batch.published"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Published')"
|
:label="__('Published')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FileUploader
|
||||||
v-model="batch.allow_self_enrollment"
|
v-if="!batch.image"
|
||||||
type="checkbox"
|
class="mt-4"
|
||||||
:label="__('Allow self enrollment')"
|
:fileTypes="['image/*']"
|
||||||
/>
|
:validateFile="validateFile"
|
||||||
</div>
|
@success="(file) => saveImage(file)"
|
||||||
</div>
|
>
|
||||||
</div>
|
<template
|
||||||
<div class="mb-4">
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
<div>
|
>
|
||||||
<FileUploader
|
<div class="mb-4">
|
||||||
v-if="!batch.image"
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
class="mt-4"
|
{{
|
||||||
:fileTypes="['image/*']"
|
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||||
:validateFile="validateFile"
|
}}
|
||||||
@success="(file) => saveImage(file)"
|
</Button>
|
||||||
>
|
</div>
|
||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
</template>
|
||||||
<div class="mb-4">
|
</FileUploader>
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<div v-else class="mt-4">
|
||||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
</Button>
|
{{ __('Meta Image') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="flex items-center">
|
||||||
</FileUploader>
|
<div class="border rounded-md p-2 mr-2">
|
||||||
<div v-else class="mb-4">
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
</div>
|
||||||
{{ __('Meta Image') }}
|
<div class="flex flex-col">
|
||||||
</div>
|
<span>
|
||||||
<div class="flex items-center">
|
{{ batch.image.file_name }}
|
||||||
<div class="border rounded-md p-2 mr-2">
|
</span>
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MultiSelect
|
|
||||||
v-model="instructors"
|
|
||||||
doctype="User"
|
|
||||||
:label="__('Instructors')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="container border-b mb-5">
|
||||||
<FormControl
|
|
||||||
v-model="batch.description"
|
|
||||||
:label="__('Description')"
|
|
||||||
type="textarea"
|
|
||||||
class="my-4"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 mb-1">
|
<label class="block text-sm text-gray-600 mb-1">
|
||||||
{{ __('Batch Details') }}
|
{{ __('Batch Details') }}
|
||||||
@@ -101,9 +91,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="container border-b mb-5">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Date and Time') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-10">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -119,8 +109,6 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.start_time"
|
v-model="batch.start_time"
|
||||||
:label="__('Start Time')"
|
:label="__('Start Time')"
|
||||||
@@ -133,20 +121,7 @@
|
|||||||
type="time"
|
type="time"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-model="batch.timezone"
|
|
||||||
:label="__('Timezone')"
|
|
||||||
type="text"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.seat_count"
|
v-model="batch.seat_count"
|
||||||
@@ -160,8 +135,6 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.medium"
|
v-model="batch.medium"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -187,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="">
|
<div class="container">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Payment') }}
|
{{ __('Payment') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -215,14 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { computed, onMounted, inject, reactive } from 'vue'
|
||||||
computed,
|
|
||||||
onMounted,
|
|
||||||
inject,
|
|
||||||
reactive,
|
|
||||||
onBeforeUnmount,
|
|
||||||
ref,
|
|
||||||
} from 'vue'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -232,7 +198,6 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize, showToast } from '../utils'
|
import { getFileSize, showToast } from '../utils'
|
||||||
import { X, FileText } from 'lucide-vue-next'
|
import { X, FileText } from 'lucide-vue-next'
|
||||||
@@ -256,41 +221,21 @@ const batch = reactive({
|
|||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
timezone: '',
|
|
||||||
evaluation_end_date: '',
|
evaluation_end_date: '',
|
||||||
seat_count: '',
|
seat_count: '',
|
||||||
medium: '',
|
medium: '',
|
||||||
category: '',
|
category: '',
|
||||||
allow_self_enrollment: false,
|
|
||||||
image: null,
|
image: null,
|
||||||
paid_batch: false,
|
paid_batch: false,
|
||||||
currency: '',
|
currency: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const instructors = ref([])
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
batchDetail.reload()
|
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({
|
const newBatch = createResource({
|
||||||
@@ -299,10 +244,7 @@ const newBatch = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
meta_image: batch.image?.file_url,
|
meta_image: batch.image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -319,13 +261,9 @@ const batchDetail = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (key == 'instructors') {
|
if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||||
data.instructors.forEach((instructor) => {
|
|
||||||
instructors.value.push(instructor.instructor)
|
|
||||||
})
|
|
||||||
} else 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) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
batch[key] = batch[key] ? true : false
|
batch[key] = batch[key] ? true : false
|
||||||
@@ -341,10 +279,7 @@ const editBatch = createResource({
|
|||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
name: props.batchName,
|
name: props.batchName,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
meta_image: batch.image?.file_url,
|
meta_image: batch.image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,23 +11,20 @@
|
|||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
{{ batch.data.description }}
|
{{ batch.data.description }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex items-center justify-between w-1/2">
|
||||||
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">
|
<div class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden lg:block" v-if="batch.data.courses"
|
<span v-if="batch.data.courses">·</span>
|
||||||
>·</span
|
<div class="flex items-center">
|
||||||
>
|
<Calendar class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<DateRange
|
<span>
|
||||||
:startDate="batch.data.start_date"
|
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
||||||
:endDate="batch.data.end_date"
|
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
||||||
/>
|
</span>
|
||||||
<span class="hidden lg:block" v-if="batch.data.start_date"
|
</div>
|
||||||
>·</span
|
<span v-if="batch.data.start_date">·</span>
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
@@ -36,29 +33,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||||
<div class="order-2 lg:order-none">
|
<div class="">
|
||||||
<div
|
<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"
|
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"
|
v-html="batch.data.batch_details"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="order-1 lg:order-none">
|
<div>
|
||||||
<BatchOverlay :batch="batch" />
|
<BatchOverlay :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +51,7 @@
|
|||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-if="batch.data.courses"
|
v-if="batch.data.courses"
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
@@ -97,17 +80,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject } from 'vue'
|
import { Breadcrumbs, createResource, Button } from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
import { formatTime } from '../utils'
|
||||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
import { computed, inject, ref } from 'vue'
|
||||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
|
||||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||||
import DateRange from '../components/Common/DateRange.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import { useRouter } from 'vue-router'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -125,6 +106,16 @@ const batch = createResource({
|
|||||||
batch: props.batchName,
|
batch: props.batchName,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
if (data.students?.includes(user.data?.name)) {
|
||||||
|
router.push({
|
||||||
|
name: 'Batch',
|
||||||
|
params: {
|
||||||
|
batchName: props.batchName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const courses = createResource({
|
const courses = createResource({
|
||||||
@@ -144,15 +135,6 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: batch.data?.title,
|
|
||||||
description: batch.data?.description,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.batch-description p {
|
.batch-description p {
|
||||||
|
|||||||
@@ -7,17 +7,9 @@
|
|||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
|
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex space-x-2">
|
<div class="flex">
|
||||||
<div class="w-40">
|
|
||||||
<Select
|
|
||||||
v-if="categories.data?.length"
|
|
||||||
v-model="currentCategory"
|
|
||||||
:options="categories.data"
|
|
||||||
:placeholder="__('Filter')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data?.is_moderator"
|
v-if="user.data"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchCreation',
|
name: 'BatchCreation',
|
||||||
params: { batchName: 'new' },
|
params: { batchName: 'new' },
|
||||||
@@ -41,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
:tabs="makeTabs"
|
:tabs="tabs"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
>
|
>
|
||||||
<template #tab="{ tab, selected }">
|
<template #tab="{ tab, selected }">
|
||||||
@@ -70,7 +62,7 @@
|
|||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.batches && tab.batches.value.length"
|
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
|
<router-link
|
||||||
v-for="batch in tab.batches.value"
|
v-for="batch in tab.batches.value"
|
||||||
@@ -95,29 +87,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
Breadcrumbs,
|
|
||||||
Button,
|
|
||||||
Tabs,
|
|
||||||
Badge,
|
|
||||||
Select,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import BatchCard from '@/components/BatchCard.vue'
|
import BatchCard from '@/components/BatchCard.vue'
|
||||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
import { inject, ref, computed } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
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({
|
const batches = createListResource({
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
@@ -126,82 +101,32 @@ const batches = createListResource({
|
|||||||
auto: true,
|
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)
|
const tabIndex = ref(0)
|
||||||
let tabs
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: 'Upcoming',
|
||||||
|
batches: computed(() => batches.data?.upcoming || []),
|
||||||
|
count: computed(() => batches.data?.upcoming?.length),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const makeTabs = computed(() => {
|
if (user.data?.is_moderator) {
|
||||||
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('_'))
|
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label,
|
label: 'Archived',
|
||||||
batches: computed(() => batches),
|
batches: computed(() => batches.data?.archived),
|
||||||
count: computed(() => batches.length),
|
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>
|
</script>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<div v-else-if="!user.data?.name">
|
<div v-else-if="!user.data?.name">
|
||||||
<NotPermitted
|
<NotPermitted
|
||||||
text="Please login to access this page."
|
text="Please login to access this page."
|
||||||
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
|
:buttonLink="`/login?redirect-to=/billing/${type}/${name}`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +340,6 @@ const validateAddress = () => {
|
|||||||
'Assam',
|
'Assam',
|
||||||
'Bihar',
|
'Bihar',
|
||||||
'Chhattisgarh',
|
'Chhattisgarh',
|
||||||
'Delhi',
|
|
||||||
'Goa',
|
'Goa',
|
||||||
'Gujarat',
|
'Gujarat',
|
||||||
'Haryana',
|
'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">
|
<div class="flex items-center">
|
||||||
<span
|
<span
|
||||||
class="h-6 mr-1"
|
class="mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
'avatar-group overlap': course.data.instructors.length > 1,
|
'avatar-group overlap': course.data.instructors.length > 1,
|
||||||
}"
|
}"
|
||||||
@@ -51,7 +51,17 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<div class="flex mt-3 mb-4 w-fit">
|
<div class="flex mt-3 mb-4 w-fit">
|
||||||
@@ -70,9 +80,14 @@
|
|||||||
class="course-description"
|
class="course-description"
|
||||||
></div>
|
></div>
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<CourseOutline :courseName="course.data.name" :showOutline="true" />
|
<CourseOutline
|
||||||
|
:courseName="course.data.name"
|
||||||
|
:showOutline="true"
|
||||||
|
title="Course Outline"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
|
v-if="course.data.avg_rating"
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:avg_rating="course.data.avg_rating"
|
:avg_rating="course.data.avg_rating"
|
||||||
:membership="course.data.membership"
|
:membership="course.data.membership"
|
||||||
@@ -94,7 +109,6 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
|||||||
import CourseReviews from '@/components/CourseReviews.vue'
|
import CourseReviews from '@/components/CourseReviews.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -145,12 +159,6 @@ updateDocumentTitle(pageMeta)
|
|||||||
padding: revert;
|
padding: revert;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-description ul {
|
|
||||||
list-style: disc;
|
|
||||||
margin: revert;
|
|
||||||
padding: revert;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -5,19 +5,9 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('All Courses'), route: { name: 'Courses' } }]"
|
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex space-x-2">
|
<div class="flex">
|
||||||
<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>
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateCourse',
|
name: 'CreateCourse',
|
||||||
@@ -36,10 +26,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="">
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-if="courses.data.length == 0 && courses.list.loading"
|
||||||
|
class="p-5 text-base text-gray-700"
|
||||||
|
>
|
||||||
|
{{ __('Loading Courses...') }}
|
||||||
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
v-else
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
:tabs="makeTabs"
|
:tabs="tabs"
|
||||||
>
|
>
|
||||||
<template #tab="{ tab, selected }">
|
<template #tab="{ tab, selected }">
|
||||||
<div>
|
<div>
|
||||||
@@ -68,8 +65,8 @@
|
|||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.current_lesson.split('-')[0],
|
chapterNumber: course.current_lesson.split('.')[0],
|
||||||
lessonNumber: course.current_lesson.split('-')[1],
|
lessonNumber: course.current_lesson.split('.')[1],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: course.membership
|
: course.membership
|
||||||
@@ -107,75 +104,61 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { createListResource, Breadcrumbs, Tabs, Badge, Button } from 'frappe-ui'
|
||||||
Breadcrumbs,
|
|
||||||
Tabs,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
createResource,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
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 { ref, computed, inject } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const searchQuery = ref('')
|
const courses = createListResource({
|
||||||
|
type: 'list',
|
||||||
const courses = createResource({
|
doctype: 'LMS Course',
|
||||||
|
cache: ['courses', user?.data?.email],
|
||||||
url: 'lms.lms.utils.get_courses',
|
url: 'lms.lms.utils.get_courses',
|
||||||
cache: ['courses', user.data?.email],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
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(() => {
|
if (user.data) {
|
||||||
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('_'))
|
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label,
|
label: 'Enrolled',
|
||||||
courses: computed(() => courses),
|
courses: computed(() => courses.data?.enrolled),
|
||||||
count: computed(() => courses.length),
|
count: computed(() => courses.data?.enrolled?.length),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const getCourses = (type) => {
|
if (
|
||||||
if (searchQuery.value) {
|
user.data.is_moderator ||
|
||||||
let query = searchQuery.value.toLowerCase()
|
user.data.is_instructor ||
|
||||||
return courses.data[type].filter(
|
courses.data?.created?.length
|
||||||
(course) =>
|
) {
|
||||||
course.title.toLowerCase().includes(query) ||
|
tabs.push({
|
||||||
course.short_introduction.toLowerCase().includes(query) ||
|
label: 'Created',
|
||||||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
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(() => {
|
const pageMeta = computed(() => {
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
<div class="flex items-center mt-3 md:mt-0">
|
<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">
|
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
@@ -86,14 +99,13 @@
|
|||||||
:label="__('Preview Video')"
|
:label="__('Preview Video')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-gray-600">
|
<div class="mb-1.5 text-xs text-gray-600">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<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"
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@@ -102,64 +114,30 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
||||||
v-model="newTag"
|
|
||||||
@keyup.enter="updateTags()"
|
|
||||||
id="tags"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MultiSelect
|
|
||||||
v-model="instructors"
|
|
||||||
doctype="User"
|
|
||||||
:label="__('Instructors')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div
|
<FormControl
|
||||||
v-if="user.data?.is_moderator"
|
type="checkbox"
|
||||||
class="flex flex-col space-y-3"
|
v-model="course.published"
|
||||||
>
|
:label="__('Published')"
|
||||||
<FormControl
|
/>
|
||||||
type="checkbox"
|
<FormControl
|
||||||
v-model="course.published"
|
type="checkbox"
|
||||||
:label="__('Published')"
|
v-model="course.upcoming"
|
||||||
/>
|
:label="__('Upcoming')"
|
||||||
<FormControl
|
/>
|
||||||
v-model="course.published_on"
|
<FormControl
|
||||||
:label="__('Published On')"
|
type="checkbox"
|
||||||
type="date"
|
v-model="course.disable_self_learning"
|
||||||
class="mb-5"
|
:label="__('Disable Self Enrollment')"
|
||||||
/>
|
/>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
@@ -187,7 +165,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<CourseOutline
|
||||||
v-if="courseResource.data"
|
v-if="courseResource.data"
|
||||||
:courseName="courseResource.data.name"
|
:courseName="courseResource.data.name"
|
||||||
@@ -204,34 +183,20 @@ import {
|
|||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
|
createDocumentResource,
|
||||||
FormControl,
|
FormControl,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import { inject, onMounted, computed, ref, reactive, watch } from 'vue'
|
||||||
inject,
|
import { convertToTitleCase, showToast, getFileSize } from '../utils'
|
||||||
onMounted,
|
|
||||||
onBeforeUnmount,
|
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
reactive,
|
|
||||||
watch,
|
|
||||||
} from 'vue'
|
|
||||||
import {
|
|
||||||
convertToTitleCase,
|
|
||||||
showToast,
|
|
||||||
getFileSize,
|
|
||||||
updateDocumentTitle,
|
|
||||||
} from '../utils'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const instructors = ref([])
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -247,44 +212,20 @@ const course = reactive({
|
|||||||
course_image: null,
|
course_image: null,
|
||||||
tags: '',
|
tags: '',
|
||||||
published: false,
|
published: false,
|
||||||
published_on: '',
|
|
||||||
featured: false,
|
|
||||||
upcoming: false,
|
upcoming: false,
|
||||||
disable_self_learning: false,
|
disable_self_learning: false,
|
||||||
enable_certification: false,
|
|
||||||
paid_course: false,
|
paid_course: false,
|
||||||
course_price: '',
|
course_price: '',
|
||||||
currency: '',
|
currency: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (
|
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||||
props.courseName == 'new' &&
|
|
||||||
!user.data?.is_moderator &&
|
|
||||||
!user.data?.is_instructor
|
|
||||||
) {
|
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
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({
|
const courseCreationResource = createResource({
|
||||||
@@ -293,10 +234,7 @@ const courseCreationResource = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
image: course.course_image?.file_url || '',
|
image: course.course_image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -311,10 +249,7 @@ const courseEditResource = createResource({
|
|||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
name: values.course,
|
name: values.course,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
image: course.course_image?.file_url || '',
|
image: course.course_image.file_url,
|
||||||
instructors: instructors.value.map((instructor) => ({
|
|
||||||
instructor: instructor,
|
|
||||||
})),
|
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -332,20 +267,13 @@ const courseResource = createResource({
|
|||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (key == 'instructors') {
|
if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||||
instructors.value = []
|
|
||||||
data.instructors.forEach((instructor) => {
|
|
||||||
instructors.value.push(instructor.instructor)
|
|
||||||
})
|
|
||||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
|
||||||
})
|
})
|
||||||
let checkboxes = [
|
let checkboxes = [
|
||||||
'published',
|
'published',
|
||||||
'upcoming',
|
'upcoming',
|
||||||
'disable_self_learning',
|
'disable_self_learning',
|
||||||
'paid_course',
|
'paid_course',
|
||||||
'featured',
|
|
||||||
'enable_certification',
|
|
||||||
]
|
]
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
@@ -353,7 +281,6 @@ const courseResource = createResource({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.image) imageResource.reload({ image: data.image })
|
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 = () => {
|
const submitCourse = () => {
|
||||||
if (courseResource.data) {
|
if (courseResource.data) {
|
||||||
courseEditResource.submit(
|
courseEditResource.submit(
|
||||||
@@ -395,7 +328,7 @@ const submitCourse = () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
showToast(err)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -431,7 +364,7 @@ watch(
|
|||||||
|
|
||||||
const validateFile = (file) => {
|
const validateFile = (file) => {
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
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.'
|
return 'Only image file is allowed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,21 +392,6 @@ const removeImage = () => {
|
|||||||
course.course_image = null
|
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(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
@@ -493,13 +411,4 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Create a Course',
|
|
||||||
description: 'Create or edit a course for your learning system.',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<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">
|
<div class="border-r">
|
||||||
<header
|
<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"
|
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
|
<div
|
||||||
v-show="openInstructorEditor"
|
v-show="openInstructorEditor"
|
||||||
id="instructor-notes"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,10 +52,7 @@
|
|||||||
<label class="block font-medium text-gray-600 mb-1">
|
<label class="block font-medium text-gray-600 mb-1">
|
||||||
{{ __('Content') }}
|
{{ __('Content') }}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div id="content" class="py-3"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,17 +66,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 { computed, reactive, onMounted, inject, ref, watch } from 'vue'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
|
import { createToast } from '../utils'
|
||||||
import LessonPlugins from '@/components/LessonPlugins.vue'
|
import LessonPlugins from '@/components/LessonPlugins.vue'
|
||||||
|
import { getEditorTools } from '../utils'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const editor = ref(null)
|
const editor = ref(null)
|
||||||
const instructorEditor = ref(null)
|
const instructorEditor = ref(null)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const openInstructorEditor = ref(false)
|
const openInstructorEditor = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -97,7 +103,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
@@ -108,7 +114,6 @@ const renderEditor = (holder) => {
|
|||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(),
|
tools: getEditorTools(),
|
||||||
autofocus: true,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,117 +433,17 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Lesson Editor',
|
|
||||||
description: 'Create and edit lessons for your course',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.embed-tool__caption,
|
.embed-tool__caption {
|
||||||
.cdx-simple-image__caption {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-toolbar__actions {
|
||||||
|
right: 108%;
|
||||||
|
}
|
||||||
|
|
||||||
.ce-block__content {
|
.ce-block__content {
|
||||||
max-width: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="job.data" class="w-3/4 mx-auto">
|
<div v-if="job.data">
|
||||||
<div class="p-4">
|
<div class="p-5 sm:p-5">
|
||||||
<div class="flex mb-4">
|
<div class="flex mb-4">
|
||||||
<img
|
<img
|
||||||
:src="job.data.company_logo"
|
:src="job.data.company_logo"
|
||||||
@@ -59,44 +59,29 @@
|
|||||||
:alt="job.data.company_name"
|
:alt="job.data.company_name"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-2xl font-semibold mb-4">
|
<div class="text-2xl font-semibold mb-2">
|
||||||
{{ job.data.job_title }}
|
{{ job.data.job_title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-8">
|
<div>
|
||||||
<div class="grid grid-cols-1 gap-2">
|
{{ __('posted by') }}
|
||||||
<div class="flex items-center space-x-2">
|
<span class="font-medium">{{ job.data.company_name }}</span>
|
||||||
<Building2 class="h-4 w-4 stroke-1.5" />
|
{{ __('on') }}
|
||||||
<span>{{ job.data.company_name }}</span>
|
<span class="font-medium">{{
|
||||||
</div>
|
dayjs(job.data.creation).format('DD MMM YYYY')
|
||||||
<div class="flex items-center space-x-2">
|
}}</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" />
|
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||||
<span>{{ job.data.location }}</span>
|
</template>
|
||||||
</div>
|
</Badge>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,19 +99,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
import { Badge, Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import { inject, ref, computed } from 'vue'
|
import { inject, ref, onMounted } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { MapPin, SendHorizonal, Pencil } from 'lucide-vue-next'
|
||||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||||
import {
|
|
||||||
MapPin,
|
|
||||||
SendHorizonal,
|
|
||||||
Pencil,
|
|
||||||
Building2,
|
|
||||||
CalendarDays,
|
|
||||||
ClipboardType,
|
|
||||||
SquareUserRound,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -150,7 +126,6 @@ const job = createResource({
|
|||||||
if (user.data?.name) {
|
if (user.data?.name) {
|
||||||
jobApplication.submit()
|
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 = () => {
|
const openApplicationModal = () => {
|
||||||
showApplicationModal.value = true
|
showApplicationModal.value = true
|
||||||
}
|
}
|
||||||
@@ -186,13 +149,4 @@ const openApplicationModal = () => {
|
|||||||
const redirectToLogin = (job) => {
|
const redirectToLogin = (job) => {
|
||||||
window.location.href = `/login?redirect-to=/job-openings/${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>
|
</script>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="jobs.data?.length">
|
<div v-if="jobs.data">
|
||||||
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 p-5">
|
||||||
<div v-for="job in jobs.data">
|
<div v-if="jobs.data.length" v-for="job in jobs.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobDetail',
|
name: 'JobDetail',
|
||||||
@@ -41,17 +41,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
|
|
||||||
{{ __('No jobs posted') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { inject, computed } from 'vue'
|
import { inject } from 'vue'
|
||||||
import JobCard from '@/components/JobCard.vue'
|
import JobCard from '@/components/JobCard.vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
@@ -60,13 +56,4 @@ const jobs = createResource({
|
|||||||
cache: ['jobs'],
|
cache: ['jobs'],
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Jobs',
|
|
||||||
description: 'An open job board for the community',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
<div class="grid md:grid-cols-[70%,30%] h-full">
|
||||||
<div
|
<div
|
||||||
v-if="lesson.data.no_preview"
|
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">
|
<p class="mb-4">
|
||||||
{{
|
{{
|
||||||
@@ -18,18 +18,14 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data"
|
|
||||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||||
>
|
>
|
||||||
<Button variant="solid">
|
<Button variant="solid">
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<Button v-else @click="redirectToLogin()">
|
|
||||||
{{ __('Login') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
<div class="text-3xl font-semibold">
|
<div class="text-3xl font-semibold">
|
||||||
{{ lesson.data.title }}
|
{{ lesson.data.title }}
|
||||||
@@ -90,23 +86,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-else
|
|
||||||
:to="{
|
|
||||||
name: 'CourseDetail',
|
|
||||||
params: { courseName: courseName },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
{{ __('Back to Course') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mt-2">
|
<div class="flex items-center mt-2">
|
||||||
<span
|
<span
|
||||||
class="h-6 mr-1"
|
class="mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
'avatar-group overlap': lesson.data.instructors.length > 1,
|
'avatar-group overlap': lesson.data.instructors.length > 1,
|
||||||
}"
|
}"
|
||||||
@@ -116,7 +101,17 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</span>
|
</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>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -157,7 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-20">
|
<div class="mt-20">
|
||||||
<Discussions
|
<Discussions
|
||||||
v-if="allowDiscussions"
|
v-if="allowDiscussions()"
|
||||||
:title="'Questions'"
|
:title="'Questions'"
|
||||||
:doctype="'Course Lesson'"
|
:doctype="'Course Lesson'"
|
||||||
:docname="lesson.data.name"
|
:docname="lesson.data.name"
|
||||||
@@ -166,50 +161,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sticky top-10">
|
<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">
|
<div class="text-lg font-semibold">
|
||||||
{{ lesson.data.course_title }}
|
{{ lesson.data.course_title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
||||||
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
|
{{ Math.ceil(lesson.data.membership.progress) }}% completed
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<ProgressBar
|
|
||||||
v-if="user && lesson.data.membership"
|
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>
|
</div>
|
||||||
<CourseOutline
|
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
||||||
:courseName="courseName"
|
|
||||||
:key="chapterNumber"
|
|
||||||
:getProgress="lesson.data.membership ? true : false"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
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 CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
import { getEditorTools } from '../utils'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
let editor, instructorEditor
|
||||||
const editor = ref(null)
|
|
||||||
const instructorEditor = ref(null)
|
|
||||||
const lessonProgress = ref(0)
|
|
||||||
const timer = ref(0)
|
|
||||||
let timerInterval
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -226,10 +216,6 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
startTimer()
|
|
||||||
})
|
|
||||||
|
|
||||||
const lesson = createResource({
|
const lesson = createResource({
|
||||||
url: 'lms.lms.utils.get_lesson',
|
url: 'lms.lms.utils.get_lesson',
|
||||||
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
|
||||||
@@ -242,29 +228,25 @@ const lesson = createResource({
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
lessonProgress.value = data.membership?.progress
|
if (data.membership)
|
||||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
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)
|
if (data.instructor_content?.blocks?.length)
|
||||||
instructorEditor.value = renderEditor(
|
instructorEditor = renderEditor(
|
||||||
'instructor-content',
|
'instructor-content',
|
||||||
data.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) => {
|
const renderEditor = (holder, content) => {
|
||||||
// empty the holder
|
// empty the holder
|
||||||
if (document.getElementById(holder))
|
document.getElementById(holder).innerHTML = ''
|
||||||
document.getElementById(holder).innerHTML = ''
|
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(),
|
tools: getEditorTools(),
|
||||||
@@ -274,12 +256,22 @@ const renderEditor = (holder, content) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const markProgress = () => {
|
const markProgress = (data) => {
|
||||||
if (user.data && !lesson.data?.progress) {
|
if (user.data && !data.progress) progress.submit()
|
||||||
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({
|
const progress = createResource({
|
||||||
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
||||||
makeParams() {
|
makeParams() {
|
||||||
@@ -288,9 +280,6 @@ const progress = createResource({
|
|||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
|
||||||
lessonProgress.value = data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
@@ -319,48 +308,21 @@ watch(
|
|||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber && newLessonNumber) {
|
||||||
editor.value = null
|
|
||||||
instructorEditor.value = null
|
|
||||||
allowDiscussions.value = false
|
|
||||||
lesson.submit({
|
lesson.submit({
|
||||||
chapter: newChapterNumber,
|
chapter: newChapterNumber,
|
||||||
lesson: newLessonNumber,
|
lesson: newLessonNumber,
|
||||||
})
|
})
|
||||||
clearInterval(timerInterval)
|
|
||||||
timer.value = 0
|
|
||||||
startTimer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const startTimer = () => {
|
const allowDiscussions = () => {
|
||||||
timerInterval = setInterval(() => {
|
return (
|
||||||
timer.value++
|
lesson.data?.membership ||
|
||||||
if (timer.value == 30) {
|
user.data?.is_moderator ||
|
||||||
clearInterval(timerInterval)
|
user.data?.is_instructor
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
allowDiscussions.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowEdit = () => {
|
const allowEdit = () => {
|
||||||
@@ -374,19 +336,6 @@ const allowInstructorContent = () => {
|
|||||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||||
return false
|
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>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
@@ -440,101 +389,11 @@ updateDocumentTitle(pageMeta)
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codex-editor__redactor {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.embed-tool__caption {
|
.embed-tool__caption {
|
||||||
display: none;
|
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>
|
</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>
|
<template></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>
|
|
||||||
|
|||||||
@@ -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" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div v-if="chartDetails.data" class="p-5">
|
<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="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ formatNumber(chartDetails.data.courses) }}
|
{{ chartDetails.data.courses }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Courses') }}
|
{{ __('Published Courses') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ formatNumber(chartDetails.data.users) }}
|
{{ chartDetails.data.users }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Signups') }}
|
{{ __('Total Signups') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,10 +39,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ formatNumber(chartDetails.data.enrollments) }}
|
{{ chartDetails.data.enrollments }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Enrollments') }}
|
{{ __('Enrolled Users') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ formatNumber(chartDetails.data.completions) }}
|
{{ chartDetails.data.completions }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Completions') }}
|
{{ __('Courses Completed') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ formatNumber(chartDetails.data.lesson_completions) }}
|
{{ chartDetails.data.lesson_completions }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Milestones') }}
|
{{ __('Lessons Completed') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,8 +109,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
import { formatNumber } from '@/utils'
|
|
||||||
import { Line, Pie } from 'vue-chartjs'
|
import { Line, Pie } from 'vue-chartjs'
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -198,7 +196,7 @@ const courseCompletion = createResource({
|
|||||||
|
|
||||||
const signupChartOptions = () => {
|
const signupChartOptions = () => {
|
||||||
let options = chartOptions(false)
|
let options = chartOptions(false)
|
||||||
options.plugins.title.text = 'Signups'
|
options.plugins.title.text = 'New Signups'
|
||||||
options.borderColor = '#4563f0'
|
options.borderColor = '#4563f0'
|
||||||
options.backgroundColor = (ctx) => {
|
options.backgroundColor = (ctx) => {
|
||||||
const canvas = ctx.chart.ctx
|
const canvas = ctx.chart.ctx
|
||||||
@@ -214,7 +212,7 @@ const signupChartOptions = () => {
|
|||||||
|
|
||||||
const enrollmentChartOptions = () => {
|
const enrollmentChartOptions = () => {
|
||||||
let options = chartOptions(false)
|
let options = chartOptions(false)
|
||||||
options.plugins.title.text = 'Enrollments'
|
options.plugins.title.text = 'Course Enrollments'
|
||||||
options.borderColor = '#4563f0'
|
options.borderColor = '#4563f0'
|
||||||
options.backgroundColor = (ctx) => {
|
options.backgroundColor = (ctx) => {
|
||||||
const canvas = ctx.chart.ctx
|
const canvas = ctx.chart.ctx
|
||||||
@@ -230,7 +228,7 @@ const enrollmentChartOptions = () => {
|
|||||||
|
|
||||||
const lessonChartOptions = () => {
|
const lessonChartOptions = () => {
|
||||||
let options = chartOptions(false)
|
let options = chartOptions(false)
|
||||||
options.plugins.title.text = 'Milestones'
|
options.plugins.title.text = 'Lesson Completion'
|
||||||
options.borderColor = '#4563f0'
|
options.borderColor = '#4563f0'
|
||||||
options.backgroundColor = (ctx) => {
|
options.backgroundColor = (ctx) => {
|
||||||
const canvas = ctx.chart.ctx
|
const canvas = ctx.chart.ctx
|
||||||
@@ -246,7 +244,7 @@ const lessonChartOptions = () => {
|
|||||||
|
|
||||||
const courseChartOptions = () => {
|
const courseChartOptions = () => {
|
||||||
let options = chartOptions(true)
|
let options = chartOptions(true)
|
||||||
options.plugins.title.text = 'Completions'
|
options.plugins.title.text = 'Course Completion'
|
||||||
options.backgroundColor = ['#4563f0', '#f683ae']
|
options.backgroundColor = ['#4563f0', '#f683ae']
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
@@ -306,13 +304,4 @@ const chartOptions = (isPie) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
|
||||||
return {
|
|
||||||
title: 'Statistics',
|
|
||||||
description: 'Statistics of the platform',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -56,33 +56,10 @@ const routes = [
|
|||||||
component: () => import('@/pages/Statistics.vue'),
|
component: () => import('@/pages/Statistics.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user/:username',
|
path: '/user/:userName',
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
component: () => import('@/pages/Profile.vue'),
|
component: () => import('@/pages/Profile.vue'),
|
||||||
props: true,
|
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',
|
path: '/job-openings',
|
||||||
@@ -125,33 +102,6 @@ const routes = [
|
|||||||
component: () => import('@/pages/AssignmentSubmission.vue'),
|
component: () => import('@/pages/AssignmentSubmission.vue'),
|
||||||
props: true,
|
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({
|
let router = createRouter({
|
||||||
@@ -160,21 +110,12 @@ let router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const { userResource, allUsers } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
await userResource.promise
|
await userResource.reload()
|
||||||
}
|
|
||||||
if (
|
|
||||||
isLoggedIn &&
|
|
||||||
(to.name == 'Lesson' ||
|
|
||||||
to.name == 'Batch' ||
|
|
||||||
to.name == 'Notifications' ||
|
|
||||||
to.name == 'Badge')
|
|
||||||
) {
|
|
||||||
await allUsers.promise
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import router from '@/router'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const sessionStore = defineStore('lms-session', () => {
|
export const sessionStore = defineStore('lms-session', () => {
|
||||||
let { userResource, allUsers } = usersStore()
|
let { userResource } = usersStore()
|
||||||
|
|
||||||
function sessionUser() {
|
function sessionUser() {
|
||||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||||
@@ -17,9 +17,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user = ref(sessionUser())
|
let user = ref(sessionUser())
|
||||||
if (user) {
|
|
||||||
allUsers.reload()
|
|
||||||
}
|
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|
||||||
const login = createResource({
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
branding,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,16 +9,9 @@ export const usersStore = defineStore('lms-users', () => {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const allUsers = createResource({
|
|
||||||
url: 'lms.lms.api.get_all_users',
|
|
||||||
cache: ['allUsers'],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userResource,
|
userResource,
|
||||||
allUsers,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createResource } from 'frappe-ui'
|
|||||||
|
|
||||||
export default function translationPlugin(app) {
|
export default function translationPlugin(app) {
|
||||||
app.config.globalProperties.__ = translate
|
app.config.globalProperties.__ = translate
|
||||||
window.__ = translate
|
|
||||||
if (!window.translatedMessages) fetchTranslations()
|
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 { toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
|
import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
import Header from '@editorjs/header'
|
import Header from '@editorjs/header'
|
||||||
import Paragraph from '@editorjs/paragraph'
|
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 Embed from '@editorjs/embed'
|
||||||
import SimpleImage from '@editorjs/simple-image'
|
import NestedList from '@editorjs/nested-list'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
export function createToast(options) {
|
export function createToast(options) {
|
||||||
toast({
|
toast({
|
||||||
@@ -40,12 +37,6 @@ export function formatTime(timeString) {
|
|||||||
return formattedTime
|
return formattedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNumber(number) {
|
|
||||||
return number.toLocaleString('en-IN', {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNumberIntoCurrency(number, currency) {
|
export function formatNumberIntoCurrency(number, currency) {
|
||||||
if (number) {
|
if (number) {
|
||||||
return number.toLocaleString('en-IN', {
|
return number.toLocaleString('en-IN', {
|
||||||
@@ -80,31 +71,17 @@ export function getFileSize(file_size) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(title, text, icon, iconClasses = null) {
|
export function showToast(title, text, icon) {
|
||||||
if (!iconClasses) {
|
|
||||||
iconClasses =
|
|
||||||
icon == 'check'
|
|
||||||
? 'bg-green-600 text-white rounded-md p-px'
|
|
||||||
: 'bg-red-600 text-white rounded-md p-px'
|
|
||||||
}
|
|
||||||
createToast({
|
createToast({
|
||||||
title: title,
|
title: title,
|
||||||
text: htmlToText(text),
|
text: htmlToText(text),
|
||||||
icon: icon,
|
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',
|
position: icon == 'check' ? 'bottom-right' : 'top-center',
|
||||||
timeout: 5,
|
timeout: icon == 'check' ? 5 : 10,
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,22 +114,9 @@ export function getEditorTools() {
|
|||||||
header: Header,
|
header: Header,
|
||||||
quiz: Quiz,
|
quiz: Quiz,
|
||||||
upload: Upload,
|
upload: Upload,
|
||||||
image: SimpleImage,
|
|
||||||
paragraph: {
|
paragraph: {
|
||||||
class: Paragraph,
|
class: Paragraph,
|
||||||
inlineToolbar: true,
|
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: {
|
list: {
|
||||||
class: NestedList,
|
class: NestedList,
|
||||||
@@ -160,113 +124,19 @@ export function getEditorTools() {
|
|||||||
defaultStyle: 'ordered',
|
defaultStyle: 'ordered',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
inlineCode: {
|
|
||||||
class: InlineCode,
|
|
||||||
shortcut: 'CMD+SHIFT+M',
|
|
||||||
},
|
|
||||||
embed: {
|
embed: {
|
||||||
class: Embed,
|
class: Embed,
|
||||||
inlineToolbar: false,
|
inlineToolbar: false,
|
||||||
config: {
|
config: {
|
||||||
services: {
|
services: {
|
||||||
youtube: {
|
youtube: true,
|
||||||
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('&')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vimeo: true,
|
vimeo: true,
|
||||||
codepen: 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: {
|
slides: {
|
||||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
|
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
|
||||||
embedUrl:
|
embedUrl:
|
||||||
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
|
'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>",
|
html: "<iframe width='100%' height='300' 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>",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -418,68 +288,26 @@ export function getSidebarLinks() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Courses',
|
label: 'Courses',
|
||||||
icon: 'BookOpen',
|
icon: BookOpen,
|
||||||
to: 'Courses',
|
to: 'Courses',
|
||||||
activeFor: [
|
activeFor: ['Courses', 'CourseDetail', 'Lesson'],
|
||||||
'Courses',
|
|
||||||
'CourseDetail',
|
|
||||||
'Lesson',
|
|
||||||
'CreateCourse',
|
|
||||||
'CreateLesson',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Batches',
|
label: 'Batches',
|
||||||
icon: 'Users',
|
icon: Users,
|
||||||
to: 'Batches',
|
to: 'Batches',
|
||||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchCreation'],
|
activeFor: ['Batches', 'BatchDetail', 'Batch'],
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Certified Participants',
|
|
||||||
icon: 'GraduationCap',
|
|
||||||
to: 'CertifiedParticipants',
|
|
||||||
activeFor: ['CertifiedParticipants'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
icon: 'Briefcase',
|
icon: Briefcase,
|
||||||
to: 'Jobs',
|
to: 'Jobs',
|
||||||
activeFor: ['Jobs', 'JobDetail'],
|
activeFor: ['Jobs', 'JobDetail'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Statistics',
|
label: 'Statistics',
|
||||||
icon: 'TrendingUp',
|
icon: TrendingUp,
|
||||||
to: 'Statistics',
|
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) {
|
save(blockContent) {
|
||||||
|
console.log(blockContent)
|
||||||
return {
|
return {
|
||||||
quiz: this.data.quiz,
|
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 {
|
export class Upload {
|
||||||
constructor({ data, api, readOnly }) {
|
constructor({ data, api, readOnly }) {
|
||||||
this.data = data
|
this.data = data
|
||||||
@@ -14,33 +10,27 @@ export class Upload {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.wrapper = document.createElement('div')
|
this.wrapper = document.createElement('div')
|
||||||
this.renderUpload(this.data)
|
this.wrapper.innerHTML = this.renderUpload(this.data)
|
||||||
return this.wrapper
|
return this.wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUpload(file) {
|
renderUpload(file) {
|
||||||
if (this.isVideo(file.file_type)) {
|
if (this.isVideo(file.file_type)) {
|
||||||
const app = createApp(VideoBlock, {
|
return `<video controls width='100%' controls controlsList='nodownload' class="mb-4">
|
||||||
file: file.file_url,
|
<source src=${encodeURI(file.file_url)} type='video/mp4'>
|
||||||
})
|
</video>`
|
||||||
app.mount(this.wrapper)
|
|
||||||
return
|
|
||||||
} else if (this.isAudio(file.file_type)) {
|
} else if (this.isAudio(file.file_type)) {
|
||||||
const app = createApp(AudioBlock, {
|
return `<audio controls width='100%' controls controlsList='nodownload' class="mb-4">
|
||||||
file: file.file_url,
|
<source src=${encodeURI(file.file_url)} type='audio/mp3'>
|
||||||
})
|
</audio>`
|
||||||
app.mount(this.wrapper)
|
} else if (file.file_type == 'pdf') {
|
||||||
return
|
return `<iframe src="${encodeURI(
|
||||||
} else if (file.file_type == 'PDF') {
|
|
||||||
this.wrapper.innerHTML = `<iframe src="${encodeURI(
|
|
||||||
file.file_url
|
file.file_url
|
||||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
||||||
return
|
|
||||||
} else {
|
} else {
|
||||||
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
return `<img class="mb-4" src=${encodeURI(
|
||||||
file.file_url
|
file.file_url
|
||||||
)} width='100%'>`
|
)} 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
|
# Hook on document methods and events
|
||||||
|
|
||||||
doc_events = {
|
doc_events = {
|
||||||
"*": {
|
|
||||||
"on_change": [
|
|
||||||
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
||||||
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
@@ -111,8 +105,7 @@ doc_events = {
|
|||||||
scheduler_events = {
|
scheduler_events = {
|
||||||
"hourly": [
|
"hourly": [
|
||||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
|
"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"]
|
fixtures = ["Custom Field", "Function", "Industry"]
|
||||||
@@ -153,8 +146,9 @@ website_redirects = [
|
|||||||
{"source": "/update-profile", "target": "/edit-profile"},
|
{"source": "/update-profile", "target": "/edit-profile"},
|
||||||
{"source": "/courses", "target": "/lms/courses"},
|
{"source": "/courses", "target": "/lms/courses"},
|
||||||
{
|
{
|
||||||
"source": r"^/courses/.*$",
|
"source": r"/courses/([^/]*)",
|
||||||
"target": "/lms/courses",
|
"target": "/lms/courses",
|
||||||
|
"match_with_query_string": True,
|
||||||
},
|
},
|
||||||
{"source": "/batches", "target": "/lms/batches"},
|
{"source": "/batches", "target": "/lms/batches"},
|
||||||
{
|
{
|
||||||
@@ -176,7 +170,52 @@ update_website_context = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
jinja = {
|
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": [],
|
"filters": [],
|
||||||
}
|
}
|
||||||
## Specify the additional tabs to be included in the user profile page.
|
## Specify the additional tabs to be included in the user profile page.
|
||||||
@@ -193,10 +232,28 @@ jinja = {
|
|||||||
# ]
|
# ]
|
||||||
|
|
||||||
has_website_permission = {
|
has_website_permission = {
|
||||||
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
## Markdown Macros for Lessons
|
||||||
lms_markdown_macro_renderers = {
|
lms_markdown_macro_renderers = {
|
||||||
"Exercise": "lms.plugins.exercise_renderer",
|
"Exercise": "lms.plugins.exercise_renderer",
|
||||||
@@ -213,7 +270,6 @@ lms_markdown_macro_renderers = {
|
|||||||
page_renderer = [
|
page_renderer = [
|
||||||
"lms.page_renderers.ProfileRedirectPage",
|
"lms.page_renderers.ProfileRedirectPage",
|
||||||
"lms.page_renderers.ProfilePage",
|
"lms.page_renderers.ProfilePage",
|
||||||
"lms.page_renderers.CoursePage",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# set this to "/" to have profiles on the top-level
|
# set this to "/" to have profiles on the top-level
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ def create_moderator_role():
|
|||||||
|
|
||||||
|
|
||||||
def create_evaluator_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 = frappe.new_doc("Role")
|
||||||
role.update(
|
role.update(
|
||||||
{
|
{
|
||||||
"role_name": "Batch Evaluator",
|
"role_name": "Class Evaluator",
|
||||||
"home_page": "",
|
"home_page": "",
|
||||||
"desk_access": 0,
|
"desk_access": 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
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 frappe.utils.user import get_system_managers
|
||||||
|
|
||||||
from lms.lms.utils import validate_image
|
from lms.lms.utils import validate_image
|
||||||
@@ -19,17 +19,6 @@ class JobOpportunity(Document):
|
|||||||
frappe.utils.validate_url(self.company_website, True)
|
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()
|
@frappe.whitelist()
|
||||||
def report(job, reason):
|
def report(job, reason):
|
||||||
system_managers = get_system_managers(only_name=True)
|
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"),
|
"full_name": frappe.db.get_value("User", self.user, "full_name"),
|
||||||
"job_title": self.job_title,
|
"job_title": self.job_title,
|
||||||
}
|
}
|
||||||
resume = frappe.get_doc(
|
|
||||||
"File",
|
|
||||||
{
|
|
||||||
"file_name": self.resume,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
frappe.sendmail(
|
frappe.sendmail(
|
||||||
recipients=company_email,
|
recipients=company_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
template="job_application",
|
template="job_application",
|
||||||
args=args,
|
args=args,
|
||||||
attachments=[
|
attachments=[self.resume],
|
||||||
{
|
|
||||||
"fname": resume.file_name,
|
|
||||||
"fcontent": resume.get_content(),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
header=[subject, "green"],
|
header=[subject, "green"],
|
||||||
retry=3,
|
retry=3,
|
||||||
)
|
)
|
||||||
|
|||||||
273
lms/lms/api.py
273
lms/lms/api.py
@@ -4,8 +4,6 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.translate import get_all_translations
|
from frappe.translate import get_all_translations
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.query_builder import DocType
|
|
||||||
from frappe.query_builder.functions import Count
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -150,7 +148,7 @@ def get_user_info():
|
|||||||
user = frappe.db.get_value(
|
user = frappe.db.get_value(
|
||||||
"User",
|
"User",
|
||||||
frappe.session.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,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
user["roles"] = frappe.get_roles(user.name)
|
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"),
|
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
|
||||||
"favicon": frappe.db.get_single_value("Website Settings", "favicon"),
|
"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",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"evaluator",
|
"evaluator",
|
||||||
"schedule",
|
"schedule"
|
||||||
"unavailability_section",
|
|
||||||
"unavailable_from",
|
|
||||||
"column_break_ahzi",
|
|
||||||
"unavailable_to"
|
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -27,30 +23,11 @@
|
|||||||
"fieldtype": "Table",
|
"fieldtype": "Table",
|
||||||
"label": "Schedule",
|
"label": "Schedule",
|
||||||
"options": "Evaluator 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,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-04-15 18:45:08.614466",
|
"modified": "2023-07-13 11:30:22.641076",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "Course Evaluator",
|
"name": "Course Evaluator",
|
||||||
@@ -89,7 +66,7 @@
|
|||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "Batch Evaluator",
|
"role": "Class Evaluator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,15 @@ from frappe import _
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from lms.lms.utils import get_evaluator
|
from lms.lms.utils import get_evaluator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from frappe.utils import get_time, getdate
|
|
||||||
|
|
||||||
|
|
||||||
class CourseEvaluator(Document):
|
class CourseEvaluator(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_time_slots()
|
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):
|
def validate_time_slots(self):
|
||||||
for schedule in self.schedule:
|
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"))
|
frappe.throw(_("Start Time cannot be greater than End Time"))
|
||||||
|
|
||||||
self.validate_overlaps(schedule)
|
self.validate_overlaps(schedule)
|
||||||
@@ -36,21 +26,11 @@ class CourseEvaluator(Document):
|
|||||||
overlap = False
|
overlap = False
|
||||||
|
|
||||||
for slot in same_day_slots:
|
for slot in same_day_slots:
|
||||||
if (
|
if schedule.start_time <= slot.start_time < schedule.end_time:
|
||||||
get_time(schedule.start_time)
|
|
||||||
<= get_time(slot.start_time)
|
|
||||||
< get_time(schedule.end_time)
|
|
||||||
):
|
|
||||||
overlap = True
|
overlap = True
|
||||||
if (
|
if schedule.start_time < slot.end_time <= schedule.end_time:
|
||||||
get_time(schedule.start_time)
|
|
||||||
< get_time(slot.end_time)
|
|
||||||
<= get_time(schedule.end_time)
|
|
||||||
):
|
|
||||||
overlap = True
|
overlap = True
|
||||||
if get_time(slot.start_time) < get_time(schedule.start_time) and get_time(
|
if slot.start_time < schedule.start_time and schedule.end_time < slot.end_time:
|
||||||
schedule.end_time
|
|
||||||
) < get_time(slot.end_time):
|
|
||||||
overlap = True
|
overlap = True
|
||||||
|
|
||||||
if overlap:
|
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