Compare commits
232 Commits
v2.0.0
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fac29e3e4 | ||
|
|
224bb18d3e | ||
|
|
aab7bdcc20 | ||
|
|
c5ca428d98 | ||
|
|
af0cc7126b | ||
|
|
a085050d27 | ||
|
|
2442f35f56 | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
53285a0d19 | ||
|
|
9cdeaebb47 | ||
|
|
a9cb52c68b | ||
|
|
f33e950e83 | ||
|
|
9c9b5963fe | ||
|
|
1597054cc9 | ||
|
|
deba6aa845 | ||
|
|
2d8ba3b84e | ||
|
|
e56b28abad | ||
|
|
eb350c5a20 | ||
|
|
961d5ec77b | ||
|
|
fa566514aa | ||
|
|
6e97449bf7 | ||
|
|
016dafb3c3 | ||
|
|
675bcc8956 | ||
|
|
aba4c034fc | ||
|
|
c76d8c582f | ||
|
|
f1cb0e6f3c | ||
|
|
d296687456 | ||
|
|
5b68001c94 | ||
|
|
736d79b8c9 | ||
|
|
98c0bd5f3e | ||
|
|
8b1d9bb5a9 | ||
|
|
289a0f9122 | ||
|
|
3cd08c80c8 | ||
|
|
3d82c36250 | ||
|
|
9b9af0215a | ||
|
|
2e4cf02737 | ||
|
|
438e9e1c47 | ||
|
|
36ded70eef | ||
|
|
ba78a15a1f | ||
|
|
93061194bb | ||
|
|
6d41e4e552 | ||
|
|
3b06968d0a | ||
|
|
fc81f1aa26 | ||
|
|
59d8848125 | ||
|
|
a067695f71 | ||
|
|
be870e8145 | ||
|
|
8a17dca351 | ||
|
|
1c9f636ad1 | ||
|
|
008cc66cdd | ||
|
|
b6bf9c0032 | ||
|
|
d295898674 | ||
|
|
4fdca4691a | ||
|
|
7c055af496 | ||
|
|
60a3da283e | ||
|
|
576258ec6e | ||
|
|
01120fbc48 | ||
|
|
ad07f883b5 | ||
|
|
bb9b179e05 | ||
|
|
11a9bff57d | ||
|
|
e18f0c9dad | ||
|
|
41ad3d00de | ||
|
|
b74c1670ca | ||
|
|
33c76e842f | ||
|
|
35a7cce283 | ||
|
|
e0f569c382 | ||
|
|
d8ab88be28 | ||
|
|
04552bdef6 | ||
|
|
ad5bf89b35 | ||
|
|
88b38dfd83 | ||
|
|
75e9ca395f | ||
|
|
6fb206cc4e | ||
|
|
62cb198492 | ||
|
|
9609329f01 | ||
|
|
c93808af94 | ||
|
|
58866260ec | ||
|
|
e6157ff411 | ||
|
|
8cca8920ee | ||
|
|
ab039dbd46 | ||
|
|
9853ab3fd9 | ||
|
|
dc2bf9f13e | ||
|
|
7c90ca4040 | ||
|
|
75a90e1f39 | ||
|
|
bc4b17cc3d | ||
|
|
8c454a333e | ||
|
|
cef4b70182 | ||
|
|
3cda563583 | ||
|
|
545326a02a | ||
|
|
14ce5d7e23 | ||
|
|
b6422d1046 | ||
|
|
7196bbe221 | ||
|
|
bed16c3726 | ||
|
|
d18ca232e3 | ||
|
|
d1200d0fa9 | ||
|
|
d1c88b306f | ||
|
|
7f2723f9cb | ||
|
|
8df4bef71a | ||
|
|
aa87622606 | ||
|
|
b91339fe28 | ||
|
|
17d4973ab8 | ||
|
|
3c12548420 | ||
|
|
20c10f1645 | ||
|
|
a7843e0e3a | ||
|
|
169ea4385f | ||
|
|
9549f3a3ed | ||
|
|
ba66c2549f | ||
|
|
76c3e630cc | ||
|
|
7a0b952638 | ||
|
|
5966a3edad | ||
|
|
d44c7cd9fc | ||
|
|
46553987ac | ||
|
|
45725f1f6e | ||
|
|
58369ba65e | ||
|
|
5ce67dda2e | ||
|
|
237ff8db07 | ||
|
|
7da608ed44 | ||
|
|
60f2e86b42 | ||
|
|
b5e67a25d2 | ||
|
|
9d2ef4929c | ||
|
|
050084e552 | ||
|
|
86e9739218 | ||
|
|
bd94890da7 | ||
|
|
965f6adb90 | ||
|
|
4979569cf3 | ||
|
|
5c21a0532a | ||
|
|
a2025c0571 | ||
|
|
e07aae3fb0 | ||
|
|
65d628ffc0 | ||
|
|
bf290bbf0a | ||
|
|
3c9059025b | ||
|
|
4b0413720b | ||
|
|
f8b4ff4bd3 | ||
|
|
3b8ff171f4 | ||
|
|
dec270a10b | ||
|
|
152a339c4e | ||
|
|
395fe700e0 | ||
|
|
ec25e895dc | ||
|
|
e02e4c7ab4 | ||
|
|
e69cc9af1a | ||
|
|
98b8464e1a | ||
|
|
0170fcc111 | ||
|
|
0be5439e81 | ||
|
|
63f857b8fc | ||
|
|
a3b8ed8f91 | ||
|
|
cdd46667f3 | ||
|
|
2f8acea988 | ||
|
|
75f0e5b9f1 | ||
|
|
ce51129e84 | ||
|
|
86aa8b0a2a | ||
|
|
aeae62a45c | ||
|
|
6b12df44a0 | ||
|
|
a710183bc7 | ||
|
|
669316ba14 | ||
|
|
6c18f9a02f | ||
|
|
363edb9a50 | ||
|
|
afbf64170a | ||
|
|
14f36d0c64 | ||
|
|
ceecab395b | ||
|
|
b8eb9fd717 | ||
|
|
230a52f06b | ||
|
|
3e82608d5f | ||
|
|
cf2c2345c3 | ||
|
|
05ebe4b787 | ||
|
|
a744a43d14 | ||
|
|
5abdbfec1f | ||
|
|
0335b3b4d0 | ||
|
|
703fafd6c3 | ||
|
|
b956c4e383 | ||
|
|
d0d1fb2c8c | ||
|
|
d18a6f6e73 | ||
|
|
2994144718 | ||
|
|
62ab853605 | ||
|
|
7f7986d77a | ||
|
|
61f01cc51b | ||
|
|
86af8c6301 | ||
|
|
f1b0fcfbfc | ||
|
|
ab5ce39645 | ||
|
|
685e09ce4b | ||
|
|
8ed4f775e5 | ||
|
|
a3a3085b1f | ||
|
|
ed97640107 | ||
|
|
a9e93a679b | ||
|
|
418c36c09f | ||
|
|
935f7f1f7b | ||
|
|
9a0056b6ca | ||
|
|
cd56da5d85 | ||
|
|
97d5d853fc | ||
|
|
8adfe247b2 | ||
|
|
afe7df2989 | ||
|
|
cdb028c69c | ||
|
|
eed330662b | ||
|
|
26db10bbe0 | ||
|
|
14230bd588 | ||
|
|
699c821edd | ||
|
|
27ca13ece6 | ||
|
|
6820dfc820 | ||
|
|
471e7d9229 | ||
|
|
e0855a2c1b | ||
|
|
6a0b37a4d4 | ||
|
|
f7fd6916e2 | ||
|
|
30e61f4b7c | ||
|
|
48b37d58d8 | ||
|
|
b8c3bdc0b4 | ||
|
|
e96f18df7c | ||
|
|
7d15527831 | ||
|
|
794c0e760b | ||
|
|
e46a60d00a | ||
|
|
819aac70fd | ||
|
|
ed7db2d7c5 | ||
|
|
a450c846a6 | ||
|
|
fa774b0db2 | ||
|
|
98a56f9117 | ||
|
|
cbc4b8c59d | ||
|
|
69d266e018 | ||
|
|
4bc3ac1665 | ||
|
|
e0de9d70de | ||
|
|
493bab8163 | ||
|
|
25a2d82e82 | ||
|
|
0183677494 | ||
|
|
7ae9244896 | ||
|
|
15330cb41d | ||
|
|
166996d77a | ||
|
|
4943e0e902 | ||
|
|
1db6a8bfda | ||
|
|
57f43b256a | ||
|
|
23b2e8d682 | ||
|
|
6e1d62340f |
2
.github/helper/update_pot_file.sh
vendored
2
.github/helper/update_pot_file.sh
vendored
@@ -22,7 +22,7 @@ git config user.name "frappe-pr-bot"
|
|||||||
|
|
||||||
echo "Setting the correct git remote..."
|
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.
|
# 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
|
git remote set-url upstream https://github.com/frappe/lms.git
|
||||||
|
|
||||||
echo "Creating a new branch..."
|
echo "Creating a new branch..."
|
||||||
isodate=$(date -u +"%Y-%m-%d")
|
isodate=$(date -u +"%Y-%m-%d")
|
||||||
|
|||||||
2
.github/workflows/generate-pot-file.yml
vendored
2
.github/workflows/generate-pot-file.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
regeneratee-pot-file:
|
regenerate-pot-file:
|
||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
27
.github/workflows/make_release_pr.yml
vendored
Normal file
27
.github/workflows/make_release_pr.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Create weekly release
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 13:00 UTC -> 7pm IST on every Wednesday
|
||||||
|
- cron: '30 4 * * 3'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: octokit/request-action@v2.x
|
||||||
|
with:
|
||||||
|
route: POST /repos/{owner}/{repo}/pulls
|
||||||
|
owner: frappe
|
||||||
|
repo: lms
|
||||||
|
title: |-
|
||||||
|
"chore: merge 'develop' into 'main'"
|
||||||
|
body: "Automated weekly release"
|
||||||
|
base: main
|
||||||
|
head: develop
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
32
.github/workflows/on_release.yml
vendored
Normal file
32
.github/workflows/on_release.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Generate Semantic Release
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Entire Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Setup dependencies
|
||||||
|
run: |
|
||||||
|
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GIT_AUTHOR_NAME: "Frappe PR Bot"
|
||||||
|
GIT_AUTHOR_EMAIL: "developers@frappe.io"
|
||||||
|
GIT_COMMITTER_NAME: "Frappe PR Bot"
|
||||||
|
GIT_COMMITTER_EMAIL: "developers@frappe.io"
|
||||||
|
run: npx semantic-release
|
||||||
39
.github/workflows/release_notes.yml
vendored
Normal file
39
.github/workflows/release_notes.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# This action:
|
||||||
|
#
|
||||||
|
# 1. Generates release notes using github API.
|
||||||
|
# 2. Strips unnecessary info like chore/style etc from notes.
|
||||||
|
# 3. Updates release info.
|
||||||
|
|
||||||
|
name: 'Release Notes'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Tag of release like v2.0.0'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release:
|
||||||
|
types: [released]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regen-notes:
|
||||||
|
name: 'Regenerate release notes'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Update notes
|
||||||
|
run: |
|
||||||
|
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
||||||
|
| jq -r '.body' \
|
||||||
|
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
|
||||||
|
| sed -E 's/by @mergify //'
|
||||||
|
)
|
||||||
|
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/tags/$RELEASE_TAG | jq -r '.id')
|
||||||
|
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}
|
||||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -99,6 +99,7 @@ jobs:
|
|||||||
cd ~/frappe-bench/
|
cd ~/frappe-bench/
|
||||||
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
||||||
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
||||||
|
bench --site lms.test set-password frappe@example.com admin
|
||||||
|
|
||||||
- name: cypress pre-requisites
|
- name: cypress pre-requisites
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ 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
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "frappe-ui"]
|
||||||
|
path = frappe-ui
|
||||||
|
url = https://github.com/frappe/frappe-ui
|
||||||
21
.releaserc
Normal file
21
.releaserc
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"branches": ["develop"],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer", {
|
||||||
|
"preset": "angular"
|
||||||
|
},
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
[
|
||||||
|
"@semantic-release/exec", {
|
||||||
|
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" lms/__init__.py'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git", {
|
||||||
|
"assets": ["lms/__init__.py"],
|
||||||
|
"message": "chore(release): Bumped to Version ${nextRelease.version}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/github"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
|||||||
cy.visit("/lms/courses");
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("a").contains("New Course").click();
|
cy.get("a").contains("New").click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
@@ -31,12 +31,35 @@ describe("Course Creation", () => {
|
|||||||
.contains("Preview Video")
|
.contains("Preview Video")
|
||||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||||
cy.get(".search-input").click().type("frappe");
|
cy.get("label")
|
||||||
cy.wait(1000);
|
.contains("Category")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("button").click();
|
||||||
|
});
|
||||||
cy.get("[id^=headlessui-combobox-option-")
|
cy.get("[id^=headlessui-combobox-option-")
|
||||||
.should("be.visible")
|
.should("be.visible")
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
|
/* Instructor */
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Instructors")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("input").click().type("frappe");
|
||||||
|
cy.get("input")
|
||||||
|
.invoke("attr", "aria-controls")
|
||||||
|
.as("instructor_list_id");
|
||||||
|
});
|
||||||
|
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||||
|
cy.get(`[id^=${instructor_list_id}`)
|
||||||
|
.should("be.visible")
|
||||||
|
.within(() => {
|
||||||
|
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
cy.get("label").contains("Published").click();
|
cy.get("label").contains("Published").click();
|
||||||
cy.get("label").contains("Published On").type("2021-01-01");
|
cy.get("label").contains("Published On").type("2021-01-01");
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
@@ -61,21 +84,7 @@ describe("Course Creation", () => {
|
|||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Lesson");
|
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(
|
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."
|
"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."
|
||||||
);
|
);
|
||||||
@@ -119,12 +128,6 @@ describe("Course Creation", () => {
|
|||||||
cy.url().should("include", "/learn/1-1");
|
cy.url().should("include", "/learn/1-1");
|
||||||
cy.get("div").contains("Test Lesson");
|
cy.get("div").contains("Test Lesson");
|
||||||
|
|
||||||
cy.get("video")
|
|
||||||
.should("be.visible")
|
|
||||||
.children("source")
|
|
||||||
.invoke("attr", "src")
|
|
||||||
.should("include", "/files/Youtube");
|
|
||||||
|
|
||||||
cy.get("div").contains(
|
cy.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."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ bench new-site lms.localhost \
|
|||||||
bench --site lms.localhost install-app lms
|
bench --site lms.localhost install-app lms
|
||||||
bench --site lms.localhost set-config developer_mode 1
|
bench --site lms.localhost set-config developer_mode 1
|
||||||
bench --site lms.localhost clear-cache
|
bench --site lms.localhost clear-cache
|
||||||
bench --site lms.localhost set-config mute_emails 1
|
|
||||||
bench use lms.localhost
|
bench use lms.localhost
|
||||||
|
|
||||||
bench start
|
bench start
|
||||||
|
|||||||
Submodule frappe-ui updated: aa44431c18...8cd9b06a5e
@@ -14,14 +14,16 @@
|
|||||||
"@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/image": "^2.9.0",
|
|
||||||
"@editorjs/inline-code": "^1.5.0",
|
"@editorjs/inline-code": "^1.5.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",
|
||||||
|
"ace-builds": "^1.36.2",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
"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.69",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
|||||||
BIN
frontend/public/Quiz.mp4
Normal file
BIN
frontend/public/Quiz.mp4
Normal file
Binary file not shown.
BIN
frontend/public/Upload.mp4
Normal file
BIN
frontend/public/Upload.mp4
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/public/Youtube.mp4
Normal file
BIN
frontend/public/Youtube.mp4
Normal file
Binary file not shown.
@@ -8,12 +8,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Toasts } from 'frappe-ui'
|
import { Toasts } from 'frappe-ui'
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
import { computed, defineAsyncComponent } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useScreenSize } from './utils/composables'
|
import { useScreenSize } from './utils/composables'
|
||||||
import DesktopLayout from './components/DesktopLayout.vue'
|
import DesktopLayout from './components/DesktopLayout.vue'
|
||||||
import MobileLayout from './components/MobileLayout.vue'
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
|
import { stopSession } from '@/telemetry'
|
||||||
|
import { init as initTelemetry } from '@/telemetry'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const screenSize = useScreenSize()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
|
||||||
const Layout = computed(() => {
|
const Layout = computed(() => {
|
||||||
if (screenSize.width < 640) {
|
if (screenSize.width < 640) {
|
||||||
@@ -22,4 +26,13 @@ const Layout = computed(() => {
|
|||||||
return DesktopLayout
|
return DesktopLayout
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userResource.data) return
|
||||||
|
await initTelemetry()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSession()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
class="flex flex-col overflow-hidden"
|
class="flex flex-col overflow-hidden"
|
||||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||||
>
|
>
|
||||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
<UserDropdown :isCollapsed="isSidebarCollapsed" />
|
||||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
v-for="link in sidebarLinks"
|
v-for="link in sidebarLinks"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||||
class="pt-1"
|
class="mt-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="!isSidebarCollapsed"
|
v-if="!isSidebarCollapsed"
|
||||||
class="flex items-center text-sm font-medium text-gray-600"
|
class="flex items-center text-sm text-gray-600 my-1"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ __('Web Pages') }}
|
{{ __('More') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
||||||
@@ -100,13 +100,14 @@ import { ChevronRight, Plus } from 'lucide-vue-next'
|
|||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import PageModal from '@/components/Modals/PageModal.vue'
|
import PageModal from '@/components/Modals/PageModal.vue'
|
||||||
|
|
||||||
const { user } = sessionStore()
|
const { user, sidebarSettings } = sessionStore()
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
const showPageModal = ref(false)
|
const showPageModal = ref(false)
|
||||||
const isModerator = ref(false)
|
const isModerator = ref(false)
|
||||||
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const showWebPages = ref(false)
|
const showWebPages = ref(false)
|
||||||
|
|
||||||
@@ -115,6 +116,20 @@ onMounted(() => {
|
|||||||
unreadNotifications.reload()
|
unreadNotifications.reload()
|
||||||
})
|
})
|
||||||
addNotifications()
|
addNotifications()
|
||||||
|
sidebarSettings.reload(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (!parseInt(data[key])) {
|
||||||
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
|
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const unreadNotifications = createResource({
|
const unreadNotifications = createResource({
|
||||||
@@ -153,20 +168,16 @@ const addNotifications = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarSettings = createResource({
|
const addQuizzes = () => {
|
||||||
url: 'lms.lms.api.get_sidebar_settings',
|
if (isInstructor.value || isModerator.value) {
|
||||||
cache: 'Sidebar Settings',
|
sidebarLinks.value.push({
|
||||||
auto: true,
|
label: 'Quizzes',
|
||||||
onSuccess(data) {
|
icon: 'CircleHelp',
|
||||||
Object.keys(data).forEach((key) => {
|
to: 'Quizzes',
|
||||||
if (!parseInt(data[key])) {
|
activeFor: ['Quizzes', 'QuizForm'],
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
|
||||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
const openPageModal = (link) => {
|
const openPageModal = (link) => {
|
||||||
showPageModal.value = true
|
showPageModal.value = true
|
||||||
@@ -198,6 +209,8 @@ const getSidebarFromStorage = () => {
|
|||||||
watch(userResource, () => {
|
watch(userResource, () => {
|
||||||
if (userResource.data) {
|
if (userResource.data) {
|
||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
|
isInstructor.value = userResource.data.is_instructor
|
||||||
|
addQuizzes()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
67
frontend/src/components/Apps.vue
Normal file
67
frontend/src/components/Apps.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<Popover placement="right-start" class="flex w-full">
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
|
||||||
|
]"
|
||||||
|
@click.prevent="togglePopover()"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<LayoutGrid class="size-4 stroke-1.5" />
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
{{ __('Apps') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
<div v-for="app in apps.data" key="name">
|
||||||
|
<a
|
||||||
|
:href="app.route"
|
||||||
|
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<img class="size-8" :src="app.logo" />
|
||||||
|
<div class="text-sm" @click="app.onClick">
|
||||||
|
{{ app.title }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Popover, createResource } from 'frappe-ui'
|
||||||
|
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const apps = createResource({
|
||||||
|
url: 'frappe.apps.get_apps',
|
||||||
|
cache: 'apps',
|
||||||
|
auto: true,
|
||||||
|
transform: (data) => {
|
||||||
|
let _apps = [
|
||||||
|
{
|
||||||
|
name: 'frappe',
|
||||||
|
logo: '/assets/lms/images/desk.png',
|
||||||
|
title: __('Desk'),
|
||||||
|
route: '/app',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
data.map((app) => {
|
||||||
|
if (app.name === 'lms') return
|
||||||
|
_apps.push({
|
||||||
|
name: app.name,
|
||||||
|
logo: app.logo,
|
||||||
|
title: __(app.title),
|
||||||
|
route: app.route,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return _apps
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="flex items-center justify-between">
|
||||||
{{ __('Assessments') }}
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</div>
|
||||||
|
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="assessments.data?.length">
|
<div v-if="assessments.data?.length">
|
||||||
<ListView
|
<ListView
|
||||||
@@ -9,41 +17,76 @@
|
|||||||
:rows="assessments.data"
|
:rows="assessments.data"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
:options="{
|
:options="{
|
||||||
selectable: false,
|
|
||||||
showTooltip: false,
|
showTooltip: false,
|
||||||
getRowRoute: (row) => {
|
getRowRoute: (row) => getRowRoute(row),
|
||||||
if (row.submission) {
|
|
||||||
return {
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: row.assessment_name,
|
|
||||||
submissionName: row.submission.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: row.assessment_name,
|
|
||||||
submissionName: 'new',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in assessments.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeAssessments(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
</ListView>
|
</ListView>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-gray-600">
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
{{ __('No Assessments') }}
|
{{ __('No Assessments') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AssessmentModal
|
||||||
|
v-model="showModal"
|
||||||
|
v-model:assessments="assessments"
|
||||||
|
:batch="props.batch"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ListView, createResource } from 'frappe-ui'
|
import {
|
||||||
import { inject } from 'vue'
|
ListView,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
createResource,
|
||||||
|
Button,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
|
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const showModal = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -74,6 +117,61 @@ const assessments = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deleteAssessments = createResource({
|
||||||
|
url: 'lms.lms.api.delete_documents',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Assessment',
|
||||||
|
documents: values.assessments,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeAssessments = (selections, unselectAll) => {
|
||||||
|
deleteAssessments.submit(
|
||||||
|
{ assessments: Array.from(selections) },
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
assessments.reload()
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowRoute = (row) => {
|
||||||
|
if (row.assessment_type == 'LMS Assignment') {
|
||||||
|
if (row.submission) {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: row.submission.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: 'new',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'QuizPage',
|
||||||
|
params: {
|
||||||
|
quizID: row.assessment_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSeeAddButton = () => {
|
||||||
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
|
}
|
||||||
|
|
||||||
const getAssessmentColumns = () => {
|
const getAssessmentColumns = () => {
|
||||||
let columns = [
|
let columns = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const props = defineProps({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
audio.value = document.querySelector('audio')
|
audio.value = document.querySelector('audio')
|
||||||
console.log(audio.value)
|
|
||||||
audio.value.onloadedmetadata = () => {
|
audio.value.onloadedmetadata = () => {
|
||||||
duration.value = audio.value.duration
|
duration.value = audio.value.duration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
|
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
|
||||||
style="min-height: 150px"
|
style="min-height: 150px"
|
||||||
>
|
>
|
||||||
|
<div class="text-lg leading-5 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"
|
||||||
@@ -19,49 +22,48 @@
|
|||||||
>
|
>
|
||||||
{{ __('Sold Out') }}
|
{{ __('Sold Out') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="short-introduction text-sm text-gray-700">
|
||||||
{{ batch.title }}
|
|
||||||
</div>
|
|
||||||
<div class="short-introduction">
|
|
||||||
{{ batch.description }}
|
{{ batch.description }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="batch.amount" class="font-semibold mb-4">
|
||||||
|
{{ batch.price }}
|
||||||
|
</div>
|
||||||
<div class="flex flex-col space-y-2 mt-auto">
|
<div class="flex flex-col space-y-2 mt-auto">
|
||||||
<div v-if="batch.amount" class="font-semibold text-lg">
|
|
||||||
{{ batch.price }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
|
||||||
</div>
|
|
||||||
<DateRange
|
<DateRange
|
||||||
:startDate="batch.start_date"
|
:startDate="batch.start_date"
|
||||||
:endDate="batch.end_date"
|
:endDate="batch.end_date"
|
||||||
class="mb-3"
|
class="text-sm text-gray-700"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center text-sm text-gray-700">
|
||||||
<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">
|
<div
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
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>
|
<span>
|
||||||
{{ batch.timezone }}
|
{{ batch.timezone }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="batch.instructors?.length" class="flex avatar-group overlap">
|
</div>
|
||||||
<div
|
<div
|
||||||
class="h-6 mr-1"
|
v-if="batch.instructors?.length"
|
||||||
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
class="flex avatar-group overlap mt-4"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<div
|
||||||
v-for="instructor in batch.instructors"
|
class="h-6 mr-1"
|
||||||
:user="instructor"
|
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
||||||
/>
|
>
|
||||||
</div>
|
<UserAvatar
|
||||||
<CourseInstructors :instructors="batch.instructors" />
|
v-for="instructor in batch.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CourseInstructors :instructors="batch.instructors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -88,7 +90,7 @@ const props = defineProps({
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0.25rem 0 1.25rem;
|
margin: 0.25rem 0 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,11 @@
|
|||||||
<div class="text-xl font-semibold">
|
<div class="text-xl font-semibold">
|
||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||||
v-if="user.data?.is_moderator"
|
|
||||||
variant="solid"
|
|
||||||
@click="openCourseModal()"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('Add Course') }}
|
{{ __('Add') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="courses.data?.length">
|
<div v-if="courses.data?.length">
|
||||||
@@ -88,6 +84,7 @@ import {
|
|||||||
ListRowItem,
|
ListRowItem,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const showCourseModal = ref(false)
|
const showCourseModal = ref(false)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -132,23 +129,32 @@ const getCoursesColumns = () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeCourse = createResource({
|
const deleteCourses = createResource({
|
||||||
url: 'frappe.client.delete',
|
url: 'lms.lms.api.delete_documents',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: 'Batch Course',
|
doctype: 'Batch Course',
|
||||||
name: values.course,
|
documents: values.courses,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeCourses = (selections, unselectAll) => {
|
const removeCourses = (selections, unselectAll) => {
|
||||||
selections.forEach(async (course) => {
|
deleteCourses.submit(
|
||||||
removeCourse.submit({ course })
|
{
|
||||||
})
|
courses: Array.from(selections),
|
||||||
setTimeout(() => {
|
},
|
||||||
courses.reload()
|
{
|
||||||
unselectAll()
|
onSuccess(data) {
|
||||||
}, 1000)
|
courses.reload()
|
||||||
|
showToast(__('Success'), __('Courses deleted successfully'), 'check')
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSeeAddButton = () => {
|
||||||
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -75,13 +75,14 @@
|
|||||||
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 && batch.data.seats_left"
|
||||||
|
@click="enrollInBatch()"
|
||||||
>
|
>
|
||||||
{{ __('Enroll Now') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isModerator"
|
v-if="isModerator"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchCreation',
|
name: 'BatchForm',
|
||||||
params: {
|
params: {
|
||||||
batchName: batch.data.name,
|
batchName: batch.data.name,
|
||||||
},
|
},
|
||||||
@@ -97,11 +98,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button } from 'frappe-ui'
|
import { Badge, Button, createResource } from 'frappe-ui'
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -111,6 +114,39 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const enroll = createResource({
|
||||||
|
url: 'lms.lms.utils.enroll_in_batch',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
batch: props.batch.data.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enrollInBatch = () => {
|
||||||
|
if (!user.data) {
|
||||||
|
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||||
|
}
|
||||||
|
enroll.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(
|
||||||
|
__('Success'),
|
||||||
|
__('You have been enrolled in this batch'),
|
||||||
|
'check'
|
||||||
|
)
|
||||||
|
router.push({
|
||||||
|
name: 'Batch',
|
||||||
|
params: {
|
||||||
|
batchName: props.batch.data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const seats_left = computed(() => {
|
const seats_left = computed(() => {
|
||||||
if (props.batch.data?.seat_count) {
|
if (props.batch.data?.seat_count) {
|
||||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
|
<Button class="float-right mb-3" @click="openStudentModal()">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('Add Student') }}
|
{{ __('Add') }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Students') }}
|
{{ __('Students') }}
|
||||||
@@ -88,6 +88,7 @@ import {
|
|||||||
import { Trash2, Plus } from 'lucide-vue-next'
|
import { Trash2, Plus } from 'lucide-vue-next'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const showStudentModal = ref(false)
|
const showStudentModal = ref(false)
|
||||||
|
|
||||||
@@ -135,23 +136,28 @@ const openStudentModal = () => {
|
|||||||
showStudentModal.value = true
|
showStudentModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeStudent = createResource({
|
const deleteStudents = createResource({
|
||||||
url: 'frappe.client.delete',
|
url: 'lms.lms.api.delete_documents',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: 'Batch Student',
|
doctype: 'Batch Student',
|
||||||
name: values.student,
|
documents: values.students,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeStudents = (selections, unselectAll) => {
|
const removeStudents = (selections, unselectAll) => {
|
||||||
selections.forEach(async (student) => {
|
deleteStudents.submit(
|
||||||
removeStudent.submit({ student })
|
{
|
||||||
})
|
students: Array.from(selections),
|
||||||
setTimeout(() => {
|
},
|
||||||
students.reload()
|
{
|
||||||
unselectAll()
|
onSuccess(data) {
|
||||||
}, 500)
|
students.reload()
|
||||||
|
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
92
frontend/src/components/BrandSettings.vue
Normal file
92
frontend/src/components/BrandSettings.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col justify-between min-h-0">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-semibold mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
v-if="isDirty"
|
||||||
|
:label="__('Not Saved')"
|
||||||
|
variant="subtle"
|
||||||
|
theme="orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<SettingFields :fields="fields" :data="data.data" />
|
||||||
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
|
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||||
|
{{ __('Update') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, Button, Badge } from 'frappe-ui'
|
||||||
|
import SettingFields from '@/components/SettingFields.vue'
|
||||||
|
import { watch, ref } from 'vue'
|
||||||
|
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveSettings = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Website Settings',
|
||||||
|
name: 'Website Settings',
|
||||||
|
fieldname: values.fields,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
let fieldsToSave = {}
|
||||||
|
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
if (imageFields.includes(f.name)) {
|
||||||
|
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||||
|
} else {
|
||||||
|
fieldsToSave[f.name] = f.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
saveSettings.submit(
|
||||||
|
{
|
||||||
|
fields: fieldsToSave,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
isDirty.value = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(props.data, (newData) => {
|
||||||
|
if (newData && !isDirty.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
151
frontend/src/components/Categories.vue
Normal file
151
frontend/src/components/Categories.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-xl font-semibold mb-1">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<Button @click="() => showCategoryForm()">
|
||||||
|
<template #icon>
|
||||||
|
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||||
|
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="flex items-center justify-between my-4 space-x-2"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
ref="categoryInput"
|
||||||
|
v-model="category"
|
||||||
|
:placeholder="__('Category Name')"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addCategory()" variant="subtle">
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-scroll">
|
||||||
|
<div class="text-base divide-y">
|
||||||
|
<FormControl
|
||||||
|
:value="cat.category"
|
||||||
|
type="text"
|
||||||
|
v-for="cat in categories.data"
|
||||||
|
class="form-control"
|
||||||
|
@change.stop="(e) => update(cat.name, e.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
debounce,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Plus, X } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const category = ref(null)
|
||||||
|
const categoryInput = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = createListResource({
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
fields: ['name', 'category'],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newCategory = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
category: category.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCategory = () => {
|
||||||
|
newCategory.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
categories.reload()
|
||||||
|
category.value = null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCategoryForm = () => {
|
||||||
|
showForm.value = !showForm.value
|
||||||
|
setTimeout(() => {
|
||||||
|
categoryInput.value.$el.querySelector('input').focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategory = createResource({
|
||||||
|
url: 'frappe.client.rename_doc',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
old_name: values.name,
|
||||||
|
new_name: values.category,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = (name, value) => {
|
||||||
|
updateCategory.submit(
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
category: value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
categories.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.form-control input {
|
||||||
|
padding: 1.25rem 0;
|
||||||
|
border-color: transparent;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control input:focus {
|
||||||
|
outline: transparent;
|
||||||
|
background: white;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control input:hover {
|
||||||
|
outline: transparent;
|
||||||
|
background: white;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center rounded px-2.5 py-1.5 text-base',
|
'flex items-center rounded px-2.5 py-2 text-base',
|
||||||
{ 'bg-gray-100': active },
|
{ 'bg-gray-100': active },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -87,7 +87,16 @@
|
|||||||
name="item-label"
|
name="item-label"
|
||||||
v-bind="{ active, selected, option }"
|
v-bind="{ active, selected, option }"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<div class="flex flex-col space-y-1">
|
||||||
|
<div>
|
||||||
|
{{ option.label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="option.label != option.description"
|
||||||
|
class="text-xs text-gray-700"
|
||||||
|
v-html="option.description"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
|||||||
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="editor flex flex-col gap-1"
|
||||||
|
:style="{
|
||||||
|
height: height,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="text-xs" v-if="label">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref="editor"
|
||||||
|
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="mt-1 text-xs text-gray-600"
|
||||||
|
v-show="description"
|
||||||
|
v-html="description"
|
||||||
|
></span>
|
||||||
|
<Button
|
||||||
|
v-if="showSaveButton"
|
||||||
|
@click="emit('save', aceEditor?.getValue())"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDark } from '@vueuse/core'
|
||||||
|
import ace from 'ace-builds'
|
||||||
|
import 'ace-builds/src-min-noconflict/ext-searchbox'
|
||||||
|
import 'ace-builds/src-min-noconflict/theme-chrome'
|
||||||
|
import 'ace-builds/src-min-noconflict/theme-twilight'
|
||||||
|
import { PropType, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const isDark = useDark({
|
||||||
|
attribute: 'data-theme',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [Object, String, Array],
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
|
||||||
|
default: 'JSON',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: '250px',
|
||||||
|
},
|
||||||
|
showLineNumbers: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showSaveButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'update:modelValue'])
|
||||||
|
const editor = ref<HTMLElement | null>(null)
|
||||||
|
let aceEditor = null as ace.Ace.Editor | null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupEditor()
|
||||||
|
})
|
||||||
|
|
||||||
|
const setupEditor = () => {
|
||||||
|
aceEditor = ace.edit(editor.value as HTMLElement)
|
||||||
|
resetEditor(props.modelValue as string, true)
|
||||||
|
aceEditor.setReadOnly(props.readonly)
|
||||||
|
aceEditor.setOptions({
|
||||||
|
fontSize: '12px',
|
||||||
|
useWorker: false,
|
||||||
|
showGutter: props.showLineNumbers,
|
||||||
|
wrap: props.showLineNumbers,
|
||||||
|
})
|
||||||
|
if (props.type === 'CSS') {
|
||||||
|
import('ace-builds/src-noconflict/mode-css').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/css')
|
||||||
|
})
|
||||||
|
} else if (props.type === 'JavaScript') {
|
||||||
|
import('ace-builds/src-noconflict/mode-javascript').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/javascript')
|
||||||
|
})
|
||||||
|
} else if (props.type === 'Python') {
|
||||||
|
import('ace-builds/src-noconflict/mode-python').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/python')
|
||||||
|
})
|
||||||
|
} else if (props.type === 'JSON') {
|
||||||
|
import('ace-builds/src-noconflict/mode-json').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/json')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
import('ace-builds/src-noconflict/mode-html').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/html')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aceEditor.on('blur', () => {
|
||||||
|
try {
|
||||||
|
let value = aceEditor?.getValue() || ''
|
||||||
|
if (props.type === 'JSON') {
|
||||||
|
value = JSON.parse(value)
|
||||||
|
}
|
||||||
|
if (value === props.modelValue) return
|
||||||
|
if (!props.showSaveButton && !props.readonly) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModelValue = () => {
|
||||||
|
let value = props.modelValue || ''
|
||||||
|
try {
|
||||||
|
if (props.type === 'JSON' || typeof value === 'object') {
|
||||||
|
value = JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
return value as string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor(value: string, resetHistory = false) {
|
||||||
|
value = getModelValue()
|
||||||
|
aceEditor?.setValue(value)
|
||||||
|
aceEditor?.clearSelection()
|
||||||
|
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||||
|
props.autofocus && aceEditor?.focus()
|
||||||
|
if (resetHistory) {
|
||||||
|
aceEditor?.session.getUndoManager().reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isDark, () => {
|
||||||
|
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.type,
|
||||||
|
() => {
|
||||||
|
setupEditor()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {
|
||||||
|
resetEditor(props.modelValue as string)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ resetEditor })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.editor .ace_editor {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_scrollbar-h) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_search) {
|
||||||
|
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||||
|
@apply dark:border-gray-800;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_searchbtn) {
|
||||||
|
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||||
|
@apply dark:border-gray-800;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_button) {
|
||||||
|
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor :deep(.ace_search_field) {
|
||||||
|
@apply dark:bg-gray-900 dark:text-gray-200;
|
||||||
|
@apply dark:border-gray-800;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -108,6 +108,7 @@ const options = createResource({
|
|||||||
url: 'frappe.desk.search.search_link',
|
url: 'frappe.desk.search.search_link',
|
||||||
cache: [props.doctype, text.value],
|
cache: [props.doctype, text.value],
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
auto: true,
|
||||||
params: {
|
params: {
|
||||||
txt: text.value,
|
txt: text.value,
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
@@ -118,6 +119,7 @@ const options = createResource({
|
|||||||
return {
|
return {
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
|
description: option.description,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,24 +152,11 @@ const filterOptions = createResource({
|
|||||||
url: 'frappe.desk.search.search_link',
|
url: 'frappe.desk.search.search_link',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: [text.value, props.doctype],
|
cache: [text.value, props.doctype],
|
||||||
|
auto: true,
|
||||||
params: {
|
params: {
|
||||||
txt: text.value,
|
txt: text.value,
|
||||||
doctype: props.doctype,
|
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(() => {
|
const options = computed(() => {
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex text-center">
|
<div class="space-y-1">
|
||||||
<div v-for="index in 5">
|
<label class="block text-xs text-gray-600" v-if="props.label">
|
||||||
<Star
|
{{ props.label }}
|
||||||
:class="index <= rating ? 'fill-orange-500' : ''"
|
</label>
|
||||||
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
|
<div class="flex text-center">
|
||||||
@click="markRating(index)"
|
<div
|
||||||
/>
|
v-for="index in 5"
|
||||||
|
@mouseover="hoveredRating = index"
|
||||||
|
@mouseleave="hoveredRating = 0"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
||||||
|
:class="iconClasses(index)"
|
||||||
|
@click="markRating(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -22,10 +32,36 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const iconClasses = (index) => {
|
||||||
|
let classes = [
|
||||||
|
{
|
||||||
|
sm: 'size-4',
|
||||||
|
md: 'size-5',
|
||||||
|
lg: 'size-6',
|
||||||
|
xl: 'size-7',
|
||||||
|
}[props.size],
|
||||||
|
]
|
||||||
|
if (index <= hoveredRating.value && index > rating.value) {
|
||||||
|
classes.push('fill-yellow-200')
|
||||||
|
} else if (index <= rating.value) {
|
||||||
|
classes.push('fill-yellow-500')
|
||||||
|
}
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
let rating = ref(props.modelValue)
|
const rating = ref(props.modelValue)
|
||||||
|
const hoveredRating = ref(0)
|
||||||
|
|
||||||
let emitChange = (value) => {
|
let emitChange = (value) => {
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
@@ -35,4 +71,11 @@ function markRating(index) {
|
|||||||
emitChange(index)
|
emitChange(index)
|
||||||
rating.value = index
|
rating.value = index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
rating.value = newVal
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,28 +2,23 @@
|
|||||||
<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: 320px"
|
style="min-height: 350px"
|
||||||
>
|
>
|
||||||
<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 class="flex relative top-4 left-4 w-fit flex-wrap">
|
<div
|
||||||
<Badge
|
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
||||||
v-if="course.featured"
|
>
|
||||||
variant="subtle"
|
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
||||||
theme="green"
|
|
||||||
size="md"
|
|
||||||
class="mr-2"
|
|
||||||
>
|
|
||||||
{{ __('Featured') }}
|
{{ __('Featured') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
theme="gray"
|
theme="gray"
|
||||||
size="md"
|
size="md"
|
||||||
class="mr-2"
|
|
||||||
v-for="tag in course.tags"
|
v-for="tag in course.tags"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@@ -77,7 +72,7 @@
|
|||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="short-introduction">
|
<div class="short-introduction text-gray-700 text-sm">
|
||||||
{{ course.short_introduction }}
|
{{ course.short_introduction }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,19 @@
|
|||||||
{{ __('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="{
|
||||||
name: 'CreateCourse',
|
name: 'CourseForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: course.data.name,
|
courseName: course.data.name,
|
||||||
},
|
},
|
||||||
@@ -107,7 +116,8 @@
|
|||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { createToast } from '@/utils/'
|
import { showToast } from '@/utils/'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -129,14 +139,14 @@ const video_link = computed(() => {
|
|||||||
|
|
||||||
function enrollStudent() {
|
function enrollStudent() {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
createToast({
|
showToast(
|
||||||
title: 'Please Login',
|
__('Please Login'),
|
||||||
icon: 'alert-circle',
|
__('You need to login first to enroll for this course'),
|
||||||
iconClasses: 'text-yellow-600 bg-yellow-100',
|
'circle-warn'
|
||||||
})
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 3000)
|
}, 2000)
|
||||||
} 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',
|
||||||
@@ -146,11 +156,14 @@ function enrollStudent() {
|
|||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
createToast({
|
capture('enrolled_in_course', {
|
||||||
title: 'Enrolled Successfully',
|
course: props.course.data.name,
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-green-600 bg-green-100',
|
|
||||||
})
|
})
|
||||||
|
showToast(
|
||||||
|
__('Success'),
|
||||||
|
__('You have been enrolled in this course'),
|
||||||
|
'check'
|
||||||
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
@@ -160,7 +173,7 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 3000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,4 +187,38 @@ const is_instructor = () => {
|
|||||||
})
|
})
|
||||||
return user_is_instructor
|
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) {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
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="grid grid-cols-[70%,30%] mb-4 px-2"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-lg">
|
<div class="font-semibold text-lg leading-5">
|
||||||
{{ __(title) }}
|
{{ __(title) }}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
<DisclosurePanel>
|
<DisclosurePanel>
|
||||||
<Draggable
|
<Draggable
|
||||||
:list="chapter.lessons"
|
:list="chapter.lessons"
|
||||||
|
:disabled="!allowEdit"
|
||||||
item-key="name"
|
item-key="name"
|
||||||
group="items"
|
group="items"
|
||||||
@end="updateOutline"
|
@end="updateOutline"
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
<div class="outline-lesson pl-8 py-2 pr-4">
|
<div class="outline-lesson pl-8 py-2 pr-4">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
<Trash2
|
<Trash2
|
||||||
v-if="allowEdit"
|
v-if="allowEdit"
|
||||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||||
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
|
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
v-if="lesson.is_complete"
|
v-if="lesson.is_complete"
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateLesson',
|
name: 'LessonForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: chapter.idx,
|
chapterNumber: chapter.idx,
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, getCurrentInstance } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
@@ -137,6 +138,8 @@ const route = useRoute()
|
|||||||
const expandAll = ref(true)
|
const expandAll = ref(true)
|
||||||
const showChapterModal = ref(false)
|
const showChapterModal = ref(false)
|
||||||
const currentChapter = ref(null)
|
const currentChapter = ref(null)
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -201,9 +204,23 @@ const updateLessonIndex = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const trashLesson = (lessonName, chapterName) => {
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
deleteLesson.submit({
|
$dialog({
|
||||||
lesson: lessonName,
|
title: __('Delete Lesson'),
|
||||||
chapter: chapterName,
|
message: __('Are you sure you want to delete this lesson?'),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick(close) {
|
||||||
|
deleteLesson.submit({
|
||||||
|
lesson: lessonName,
|
||||||
|
chapter: chapterName,
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
||||||
{{ __('New {0}').format(title) }}
|
{{ __('New {0}').format(singularize(title)) }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-xl font-semibold">
|
<div class="text-xl font-semibold">
|
||||||
{{ __(title) }}
|
{{ __(title) }}
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { timeAgo } from '../utils'
|
import { singularize, 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'
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<iframe
|
<iframe
|
||||||
:src="getPDFSource(block)"
|
:src="getPDFSource(block)"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="400"
|
height="700px"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|||||||
74
frontend/src/components/LessonHelp.vue
Normal file
74
frontend/src/components/LessonHelp.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
|
@click="openHelpDialog('quiz')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('How to add a Quiz?') }}
|
||||||
|
</span>
|
||||||
|
<Info class="w-3 h-3 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
|
@click="openHelpDialog('upload')"
|
||||||
|
>
|
||||||
|
<span class="leading-5">
|
||||||
|
{{ __('How to upload content from your system?') }}
|
||||||
|
</span>
|
||||||
|
<Info class="w-3 h-3 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
|
@click="openHelpDialog('youtube')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('How to add a YouTube Video?') }}
|
||||||
|
</span>
|
||||||
|
<Info class="w-3 h-3 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Copy the URL of the video from YouTube and paste it in the editor.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ExplanationVideos v-model="showExplanation" :type="type" />
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Info } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ExplanationVideos from '@/components/Modals/ExplanationVideos.vue'
|
||||||
|
|
||||||
|
const showExplanation = ref(false)
|
||||||
|
const type = ref(null)
|
||||||
|
|
||||||
|
const openHelpDialog = (contentType) => {
|
||||||
|
type.value = contentType
|
||||||
|
showExplanation.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ __('Components') }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-5">
|
|
||||||
<Tooltip
|
|
||||||
:text="
|
|
||||||
__(
|
|
||||||
'Content such as quiz, video and image will be added in the editor you select.'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<div class="">
|
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
|
||||||
{{ __('Select an Editor') }}
|
|
||||||
</div>
|
|
||||||
<Select v-model="currentEditor" :options="getEditorOptions()" />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
<div class="flex mt-4">
|
|
||||||
<Link
|
|
||||||
v-model="quiz"
|
|
||||||
class="flex-1"
|
|
||||||
doctype="LMS Quiz"
|
|
||||||
:label="__('Select a Quiz')"
|
|
||||||
/>
|
|
||||||
<Button @click="addQuiz()" class="self-end ml-2">
|
|
||||||
<template #icon>
|
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
|
||||||
{{ __('Add an image, video, pdf or audio.') }}
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
<FileUploader
|
|
||||||
v-if="!file"
|
|
||||||
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
|
|
||||||
:validateFile="validateFile"
|
|
||||||
@success="(data) => addFile(data)"
|
|
||||||
>
|
|
||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
|
||||||
<div class="">
|
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
|
||||||
{{
|
|
||||||
uploading
|
|
||||||
? __('Uploading {0}%').format(progress)
|
|
||||||
: __('Upload a File')
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<div v-else class="">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="border rounded-md p-2 mr-2">
|
|
||||||
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-xs">
|
|
||||||
{{ file.file_name }}
|
|
||||||
</span>
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
|
||||||
import { Plus, FileText, Info } from 'lucide-vue-next'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
|
|
||||||
|
|
||||||
const quiz = ref(null)
|
|
||||||
const file = ref(null)
|
|
||||||
const lessonEditor = ref(null)
|
|
||||||
const instructorEditor = ref(null)
|
|
||||||
const currentEditor = ref('Lesson Content')
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
editor: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
notesEditor: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addQuiz = () => {
|
|
||||||
getCurrentEditor().caret.setToLastBlock('end', 0)
|
|
||||||
if (quiz.value) {
|
|
||||||
getCurrentEditor().blocks.insert('quiz', {
|
|
||||||
quiz: quiz.value,
|
|
||||||
})
|
|
||||||
quiz.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addFile = (data) => {
|
|
||||||
getCurrentEditor().caret.setToLastBlock('end', 0)
|
|
||||||
getCurrentEditor().blocks.insert('upload', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
|
|
||||||
return 'Only image and video files are allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEditorOptions = () => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Lesson Content',
|
|
||||||
value: 'Lesson Content',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Instructor Content',
|
|
||||||
value: 'Instructor Content',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCurrentEditor = () => {
|
|
||||||
return currentEditor.value == 'Lesson Content'
|
|
||||||
? lessonEditor.value
|
|
||||||
: instructorEditor.value
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.editor, props.notesEditor],
|
|
||||||
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
|
|
||||||
lessonEditor.value = newEditor
|
|
||||||
instructorEditor.value = newNotesEditor
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@@ -1,33 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<div class="flex items-center justify-between mb-5">
|
||||||
v-if="user.data.is_moderator"
|
<div class="text-lg font-semibold">
|
||||||
variant="solid"
|
{{ __('Live Class') }}
|
||||||
class="float-right mb-5"
|
</div>
|
||||||
@click="openLiveClassModal"
|
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
|
||||||
>
|
<template #prefix>
|
||||||
<template #prefix>
|
<Plus class="h-4 w-4" />
|
||||||
<Plus class="h-4 w-4" />
|
</template>
|
||||||
</template>
|
<span>
|
||||||
<span>
|
{{ __('Add') }}
|
||||||
{{ __('Add Live Class') }}
|
</span>
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
|
||||||
<div class="text-lg font-semibold mb-5">
|
|
||||||
{{ __('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"
|
||||||
class="flex flex-col border rounded-md h-full p-3"
|
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-lg mb-4">
|
<div class="font-semibold text-gray-900 text-lg mb-4">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="leading-5 text-gray-700 text-sm mb-4">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -38,8 +35,9 @@
|
|||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2 mt-auto">
|
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
||||||
<a
|
<a
|
||||||
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
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"
|
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"
|
||||||
@@ -90,7 +88,6 @@ const liveClasses = createListResource({
|
|||||||
doctype: 'LMS Live Class',
|
doctype: 'LMS Live Class',
|
||||||
filters: {
|
filters: {
|
||||||
batch_name: props.batch,
|
batch_name: props.batch,
|
||||||
date: ['>=', new Date()],
|
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
'title',
|
'title',
|
||||||
|
|||||||
203
frontend/src/components/Members.vue
Normal file
203
frontend/src/components/Members.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 flex-col text-base">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-semibold mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<!-- <div class="text-xs text-gray-600">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2">
|
||||||
|
<FormControl
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="__('Search')"
|
||||||
|
type="text"
|
||||||
|
:debounce="300"
|
||||||
|
/>
|
||||||
|
<Button @click="() => (showForm = !showForm)">
|
||||||
|
<template #icon>
|
||||||
|
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||||
|
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form to add new member -->
|
||||||
|
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="member.email"
|
||||||
|
:placeholder="__('Email')"
|
||||||
|
type="email"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="member.first_name"
|
||||||
|
:placeholder="__('First Name')"
|
||||||
|
type="test"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<Button @click="addMember()" variant="subtle">
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 pb-10 overflow-auto">
|
||||||
|
<!-- Member list -->
|
||||||
|
<div class="overflow-y-scroll">
|
||||||
|
<ul class="divide-y">
|
||||||
|
<li
|
||||||
|
v-for="member in memberList"
|
||||||
|
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
@click="openProfile(member.username)"
|
||||||
|
class="flex items-center space-x-3 col-span-2"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:image="member.user_image"
|
||||||
|
:label="member.full_name"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="text-gray-900">
|
||||||
|
{{ member.full_name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="getRole(member)">
|
||||||
|
{{ getRole(member) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700">
|
||||||
|
{{ member.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center text-gray-700 text-sm">
|
||||||
|
<div v-if="member.last_active">
|
||||||
|
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
|
||||||
|
</div>
|
||||||
|
<div v-else>-</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="memberList.length && hasNextPage"
|
||||||
|
class="flex justify-center mt-4"
|
||||||
|
>
|
||||||
|
<Button @click="members.reload()">
|
||||||
|
<template #prefix>
|
||||||
|
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { createResource, Avatar, Button, FormControl } from 'frappe-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
|
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const show = defineModel('show')
|
||||||
|
const search = ref('')
|
||||||
|
const start = ref(0)
|
||||||
|
const memberList = ref([])
|
||||||
|
const hasNextPage = ref(false)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
|
const member = reactive({
|
||||||
|
email: '',
|
||||||
|
first_name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const members = createResource({
|
||||||
|
url: 'lms.lms.api.get_members',
|
||||||
|
makeParams: () => {
|
||||||
|
return {
|
||||||
|
search: search.value,
|
||||||
|
start: start.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
memberList.value = memberList.value.concat(data)
|
||||||
|
start.value = start.value + 20
|
||||||
|
hasNextPage.value = data.length === 20
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProfile = (username) => {
|
||||||
|
show.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMember = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'User',
|
||||||
|
first_name: member.first_name,
|
||||||
|
email: member.email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
show.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: data.username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMember = () => {
|
||||||
|
newMember.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
memberList.value = []
|
||||||
|
start.value = 0
|
||||||
|
members.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getRole = (role) => {
|
||||||
|
const map = {
|
||||||
|
'LMS Student': 'Student',
|
||||||
|
'Course Creator': 'Instructor',
|
||||||
|
Moderator: 'Moderator',
|
||||||
|
'Batch Evaluator': 'Evaluator',
|
||||||
|
}
|
||||||
|
return map[role]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,14 +4,16 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="tabs"
|
v-if="sidebarSettings.data"
|
||||||
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
||||||
:style="{
|
:style="{
|
||||||
gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${
|
||||||
|
sidebarLinks.length + 1
|
||||||
|
}, minmax(0, 1fr))`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in sidebarLinks"
|
||||||
:key="tab.label"
|
:key="tab.label"
|
||||||
:class="isVisible(tab) ? 'block' : 'hidden'"
|
:class="isVisible(tab) ? 'block' : 'hidden'"
|
||||||
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
|
||||||
@@ -23,50 +25,114 @@
|
|||||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<Popover
|
||||||
|
trigger="hover"
|
||||||
|
popoverClass="bottom-28 mx-2"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<template #target>
|
||||||
|
<component
|
||||||
|
:is="icons['List']"
|
||||||
|
class="h-6 w-6 stroke-1.5 text-gray-600"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body-main>
|
||||||
|
<div class="text-base p-5 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="link in otherLinks"
|
||||||
|
:key="link.label"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
@click="handleClick(link)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="icons[link.icon]"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-600"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{{ link.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<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 { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
|
import { Popover } from 'frappe-ui'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user } = sessionStore()
|
const { logout, user, sidebarSettings } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
const otherLinks = ref([])
|
||||||
|
|
||||||
const tabs = computed(() => {
|
onMounted(() => {
|
||||||
let links = getSidebarLinks()
|
sidebarSettings.reload(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (!parseInt(data[key])) {
|
||||||
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
|
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addOtherLinks()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const addOtherLinks = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
links.push({
|
otherLinks.value.push({
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: 'Bell',
|
||||||
|
to: 'Notifications',
|
||||||
|
})
|
||||||
|
otherLinks.value.push({
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
icon: 'UserRound',
|
icon: 'UserRound',
|
||||||
activeFor: [
|
|
||||||
'Profile',
|
|
||||||
'ProfileAbout',
|
|
||||||
'ProfileCertification',
|
|
||||||
'ProfileEvaluator',
|
|
||||||
'ProfileRoles',
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
links.push({
|
otherLinks.value.push({
|
||||||
label: 'Log out',
|
label: 'Log out',
|
||||||
icon: 'LogOut',
|
icon: 'LogOut',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
links.push({
|
otherLinks.value.push({
|
||||||
label: 'Log in',
|
label: 'Log in',
|
||||||
icon: 'LogIn',
|
icon: 'LogIn',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return links
|
}
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (
|
||||||
|
userResource.data &&
|
||||||
|
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||||
|
) {
|
||||||
|
addQuizzes()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const addQuizzes = () => {
|
||||||
|
otherLinks.value.push({
|
||||||
|
label: 'Quizzes',
|
||||||
|
icon: 'CircleHelp',
|
||||||
|
to: 'Quizzes',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
|
|||||||
86
frontend/src/components/Modals/AssessmentModal.vue
Normal file
86
frontend/src/components/Modals/AssessmentModal.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add an assessment'),
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Submit'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => addAssessment(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
:options="assessmentTypes"
|
||||||
|
v-model="assessmentType"
|
||||||
|
:label="__('Type')"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="assessment"
|
||||||
|
:doctype="assessmentType"
|
||||||
|
:label="__('Assessment')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const assessmentType = ref(null)
|
||||||
|
const assessment = ref(null)
|
||||||
|
const assessments = defineModel('assessments')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assessmentResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Assessment',
|
||||||
|
parent: props.batch,
|
||||||
|
parenttype: 'LMS Batch',
|
||||||
|
parentfield: 'assessment',
|
||||||
|
assessment_type: assessmentType.value,
|
||||||
|
assessment_name: assessment.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addAssessment = (close) => {
|
||||||
|
assessmentResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
assessments.value.reload()
|
||||||
|
showToast(__('Success'), __('Assessment added successfully'), 'check')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assessmentTypes = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'Quiz', value: 'LMS Quiz' },
|
||||||
|
{ label: 'Assignment', value: 'LMS Assignment' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -15,17 +15,24 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
|
<FormControl
|
||||||
|
ref="chapterInput"
|
||||||
|
label="Title"
|
||||||
|
v-model="chapter.title"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
||||||
import { defineModel, reactive, watch, inject } from 'vue'
|
import { defineModel, reactive, watch, ref } from 'vue'
|
||||||
import { createToast, formatTime } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const outline = defineModel('outline')
|
const outline = defineModel('outline')
|
||||||
|
const chapterInput = ref(null)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
course: {
|
course: {
|
||||||
@@ -36,6 +43,7 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const chapter = reactive({
|
const chapter = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
})
|
})
|
||||||
@@ -91,10 +99,12 @@ const addChapter = (close) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
capture('chapter_created')
|
||||||
chapterReference.submit(
|
chapterReference.submit(
|
||||||
{ name: data.name },
|
{ name: data.name },
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
chapter.title = ''
|
||||||
outline.value.reload()
|
outline.value.reload()
|
||||||
createToast({
|
createToast({
|
||||||
text: 'Chapter added successfully',
|
text: 'Chapter added successfully',
|
||||||
@@ -158,4 +168,12 @@ watch(
|
|||||||
chapter.title = newChapter?.title
|
chapter.title = newChapter?.title
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chapterInput.value.$el.querySelector('input').focus()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
:options="{
|
:options="{
|
||||||
title: props.title,
|
title: singularize(props.title),
|
||||||
size: '2xl',
|
size: '2xl',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||||
import { reactive, defineModel, computed } from 'vue'
|
import { reactive, defineModel } from 'vue'
|
||||||
import { showToast } from '@/utils'
|
import { showToast, singularize } from '@/utils'
|
||||||
|
|
||||||
const topics = defineModel('reloadTopics')
|
const topics = defineModel('reloadTopics')
|
||||||
|
|
||||||
|
|||||||
@@ -130,11 +130,20 @@ function submitEvaluation(close) {
|
|||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
|
let message = err.messages?.[0] || err
|
||||||
|
let unavailabilityMessage
|
||||||
|
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
unavailabilityMessage = message?.includes('unavailable')
|
||||||
|
} else {
|
||||||
|
unavailabilityMessage = false
|
||||||
|
}
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
title: 'Error',
|
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
|
||||||
text: err.messages?.[0] || err,
|
text: message,
|
||||||
icon: 'x',
|
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
||||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
|
||||||
position: 'top-center',
|
position: 'top-center',
|
||||||
timeout: 10,
|
timeout: 10,
|
||||||
})
|
})
|
||||||
|
|||||||
378
frontend/src/components/Modals/Event.vue
Normal file
378
frontend/src/components/Modals/Event.vue
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex text-base">
|
||||||
|
<div class="flex flex-col w-1/2 p-5">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ event.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-4 text-sm text-gray-800">
|
||||||
|
<Tooltip :text="__('Email ID')">
|
||||||
|
<div class="flex items-center space-x-2 w-fit">
|
||||||
|
<User class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ event.member }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Course')">
|
||||||
|
<div class="flex items-center space-x-2 w-fit">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ event.course_title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||||
|
<div class="flex items-center space-x-2 w-fit">
|
||||||
|
<Users class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ event.batch_title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Date')">
|
||||||
|
<div class="flex items-center space-x-2 w-fit">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(event.date).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Time')">
|
||||||
|
<div class="flex items-center space-x-2 w-fit">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(event.start_time) }} -
|
||||||
|
{{ formatTime(event.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 mt-auto">
|
||||||
|
<Button
|
||||||
|
v-if="certificate.name"
|
||||||
|
@click="openCertificate(certificate)"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FileText class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('View Certificate') }}
|
||||||
|
</Button>
|
||||||
|
<Button v-else @click="openCallLink(event.venue)" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ __('Join Meeting') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
|
||||||
|
<template #default="{ tab }">
|
||||||
|
<div
|
||||||
|
v-if="tab.label == 'Evaluation'"
|
||||||
|
class="flex flex-col space-y-4 p-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Rating v-model="evaluation.rating" :label="__('Rating')" />
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
:options="statusOptions"
|
||||||
|
v-model="evaluation.status"
|
||||||
|
:label="__('Status')"
|
||||||
|
class="w-1/2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
v-model="evaluation.summary"
|
||||||
|
:label="__('Summary')"
|
||||||
|
:rows="7"
|
||||||
|
/>
|
||||||
|
<Button variant="solid" @click="saveEvaluation()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col space-y-4 p-5">
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="certificate.published"
|
||||||
|
:label="__('Published')"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="certificate.template"
|
||||||
|
:label="__('Template')"
|
||||||
|
doctype="Print Format"
|
||||||
|
:filters="{
|
||||||
|
doc_type: 'LMS Certificate',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="date"
|
||||||
|
v-model="certificate.issue_date"
|
||||||
|
:label="__('Issue Date')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="date"
|
||||||
|
v-model="certificate.expiry_date"
|
||||||
|
:label="__('Expiry Date')"
|
||||||
|
/>
|
||||||
|
<Button variant="solid" @click="saveCertificate()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
createResource,
|
||||||
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
|
Textarea,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Video,
|
||||||
|
BookOpen,
|
||||||
|
FileText,
|
||||||
|
GraduationCap,
|
||||||
|
Users,
|
||||||
|
ClipboardList,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { inject, reactive, watch, ref, computed } from 'vue'
|
||||||
|
import { formatTime, showToast } from '@/utils'
|
||||||
|
import Rating from '@/components/Controls/Rating.vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const tabIndex = ref(0)
|
||||||
|
const showCertification = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
event: {
|
||||||
|
type: [Object, null],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluation = reactive({})
|
||||||
|
|
||||||
|
const certificate = reactive({})
|
||||||
|
|
||||||
|
const defaultTemplate = createResource({
|
||||||
|
url: 'frappe.client.get_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Property Setter',
|
||||||
|
fieldname: 'value',
|
||||||
|
filters: {
|
||||||
|
doc_type: 'LMS Certificate',
|
||||||
|
property: 'default_print_format',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
certificate.template = data.value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCallLink = (link) => {
|
||||||
|
window.open(link, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluationResource = createResource({
|
||||||
|
url: 'lms.lms.api.save_evaluation_details',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
member: props.event.member,
|
||||||
|
course: props.event.course,
|
||||||
|
batch_name: props.event.batch_name,
|
||||||
|
date: props.event.date,
|
||||||
|
start_time: props.event.start_time,
|
||||||
|
end_time: props.event.end_time,
|
||||||
|
status: evaluation.status,
|
||||||
|
rating: evaluation.rating,
|
||||||
|
summary: evaluation.summary,
|
||||||
|
evaluator: props.event.evaluator,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
evaluation.name = data.name
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluationDetails = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Certificate Evaluation',
|
||||||
|
filters: {
|
||||||
|
member: props.event.member,
|
||||||
|
course: props.event.course,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
for (const key in data) {
|
||||||
|
if (key in evaluation) evaluation[key] = data[key]
|
||||||
|
if (key == 'rating') evaluation.rating = data.rating * 5
|
||||||
|
if (evaluation.status == 'Pass') showCertification.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveEvaluation = () => {
|
||||||
|
evaluationResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
if (evaluation.status == 'Pass') {
|
||||||
|
showCertification.value = true
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateResource = createResource({
|
||||||
|
url: 'lms.lms.api.save_certificate_details',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
member: props.event.member,
|
||||||
|
course: props.event.course,
|
||||||
|
batch_name: props.event.batch_name,
|
||||||
|
published: certificate.published,
|
||||||
|
issue_date: certificate.issue_date,
|
||||||
|
expiry_date: certificate.expiry_date,
|
||||||
|
template: certificate.template,
|
||||||
|
evaluator: props.event.evaluator,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
certificate.name = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const certificateDetails = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Certificate',
|
||||||
|
filters: {
|
||||||
|
member: props.event.member,
|
||||||
|
course: props.event.course,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
for (const key in data) {
|
||||||
|
if (key in certificate) certificate[key] = data[key]
|
||||||
|
certificate.name = data.name
|
||||||
|
showCertification.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
certificate.template = defaultTemplate.data.value
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveCertificate = () => {
|
||||||
|
certificateResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast(__('Success'), __('Certificate saved successfully'), 'check')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
evaluation.rating = 0
|
||||||
|
evaluation.status = 'Pending'
|
||||||
|
evaluation.summary = ''
|
||||||
|
evaluationDetails.reload()
|
||||||
|
|
||||||
|
certificate.published = true
|
||||||
|
certificate.issue_date = dayjs().format('YYYY-MM-DD')
|
||||||
|
certificate.expiry_date = null
|
||||||
|
certificate.template = null
|
||||||
|
certificate.name = null
|
||||||
|
certificateDetails.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCertificate = (certificate) => {
|
||||||
|
window.open(
|
||||||
|
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||||
|
certificate.name
|
||||||
|
}&format=${encodeURIComponent(certificate.template)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: 'Pending',
|
||||||
|
label: __('Pending'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'In Progress',
|
||||||
|
label: __('In Progress'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Pass',
|
||||||
|
label: __('Pass'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Fail',
|
||||||
|
label: __('Fail'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const tabsArray = [
|
||||||
|
{
|
||||||
|
label: __('Evaluation'),
|
||||||
|
icon: ClipboardList,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (showCertification.value) {
|
||||||
|
tabsArray.push({
|
||||||
|
label: __('Certification'),
|
||||||
|
icon: GraduationCap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabsArray
|
||||||
|
})
|
||||||
|
</script>
|
||||||
34
frontend/src/components/Modals/ExplanationVideos.vue
Normal file
34
frontend/src/components/Modals/ExplanationVideos.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '4xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-4">
|
||||||
|
<VideoBlock :file="file" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: [String, null],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = computed(() => {
|
||||||
|
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
||||||
|
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
||||||
|
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
351
frontend/src/components/Modals/Question.vue
Normal file
351
frontend/src/components/Modals/Question.vue
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="dialogOptions">
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-if="!editMode"
|
||||||
|
class="flex items-center text-xs text-gray-700 space-x-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="existing"
|
||||||
|
value="existing"
|
||||||
|
v-model="questionType"
|
||||||
|
class="w-3 h-3 accent-gray-900"
|
||||||
|
/>
|
||||||
|
<label for="existing">
|
||||||
|
{{ __('Add an existing question') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="new"
|
||||||
|
value="new"
|
||||||
|
v-model="questionType"
|
||||||
|
class="w-3 h-3"
|
||||||
|
/>
|
||||||
|
<label for="new">
|
||||||
|
{{ __('Create a new question') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="questionType == 'new' || editMode" class="space-y-2">
|
||||||
|
<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
|
||||||
|
v-model="question.marks"
|
||||||
|
:label="__('Marks')"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Type')"
|
||||||
|
v-model="question.type"
|
||||||
|
type="select"
|
||||||
|
:options="['Choices', 'User Input', 'Open Ended']"
|
||||||
|
class="pb-2"
|
||||||
|
/>
|
||||||
|
<div v-if="question.type == 'Choices'" class="divide-y border-t">
|
||||||
|
<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[`is_correct_${n}`]"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="question.type == 'User Input'"
|
||||||
|
v-for="n in 4"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Possibility') + ' ' + n"
|
||||||
|
v-model="question[`possibility_${n}`]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="questionType == 'existing'" class="space-y-2">
|
||||||
|
<Link
|
||||||
|
v-model="existingQuestion.question"
|
||||||
|
:label="__('Select a question')"
|
||||||
|
doctype="LMS Question"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="existingQuestion.marks"
|
||||||
|
:label="__('Marks')"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||||
|
import { computed, watch, reactive, ref } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const quiz = defineModel('quiz')
|
||||||
|
const questionType = ref(null)
|
||||||
|
const editMode = ref(false)
|
||||||
|
|
||||||
|
const existingQuestion = reactive({
|
||||||
|
question: '',
|
||||||
|
marks: 0,
|
||||||
|
})
|
||||||
|
const question = reactive({
|
||||||
|
question: '',
|
||||||
|
type: 'Choices',
|
||||||
|
marks: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const populateFields = () => {
|
||||||
|
let fields = ['option', 'is_correct', 'explanation', 'possibility']
|
||||||
|
let counter = 1
|
||||||
|
fields.forEach((field) => {
|
||||||
|
while (counter <= 4) {
|
||||||
|
question[`${field}_${counter}`] = field === 'is_correct' ? false : ''
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
populateFields()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: __('Add a new question'),
|
||||||
|
},
|
||||||
|
questionDetail: {
|
||||||
|
type: [Object, null],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionData = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Question',
|
||||||
|
name: props.questionDetail.question,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
let counter = 1
|
||||||
|
editMode.value = true
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (Object.hasOwn(question, key)) question[key] = data[key]
|
||||||
|
})
|
||||||
|
while (counter <= 4) {
|
||||||
|
question[`is_correct_${counter}`] = data[`is_correct_${counter}`]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
question.marks = props.questionDetail.marks
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
editMode.value = false
|
||||||
|
if (props.questionDetail.question) questionData.fetch()
|
||||||
|
else {
|
||||||
|
;(question.question = ''), (question.marks = 0)
|
||||||
|
question.type = 'Choices'
|
||||||
|
existingQuestion.question = ''
|
||||||
|
existingQuestion.marks = 0
|
||||||
|
questionType.value = null
|
||||||
|
populateFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.questionDetail.marks) question.marks = props.questionDetail.marks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionRow = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Quiz Question',
|
||||||
|
parent: quiz.value.data.name,
|
||||||
|
parentfield: 'questions',
|
||||||
|
parenttype: 'LMS Quiz',
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionCreation = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Question',
|
||||||
|
...question,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitQuestion = (close) => {
|
||||||
|
if (props.questionDetail?.question) updateQuestion(close)
|
||||||
|
else addQuestion(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addQuestion = (close) => {
|
||||||
|
if (questionType.value == 'existing') {
|
||||||
|
addQuestionRow(
|
||||||
|
{
|
||||||
|
question: existingQuestion.question,
|
||||||
|
marks: existingQuestion.marks,
|
||||||
|
},
|
||||||
|
close
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
questionCreation.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
addQuestionRow(
|
||||||
|
{
|
||||||
|
question: data.name,
|
||||||
|
marks: question.marks,
|
||||||
|
},
|
||||||
|
close
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addQuestionRow = (question, close) => {
|
||||||
|
questionRow.submit(
|
||||||
|
{
|
||||||
|
...question,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
show.value = false
|
||||||
|
showToast(__('Success'), __('Question added successfully'), 'check')
|
||||||
|
quiz.value.reload()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionUpdate = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
auto: false,
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Question',
|
||||||
|
name: questionData.data?.name,
|
||||||
|
fieldname: {
|
||||||
|
...question,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const marksUpdate = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
auto: false,
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Quiz Question',
|
||||||
|
name: props.questionDetail.name,
|
||||||
|
fieldname: {
|
||||||
|
marks: question.marks,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateQuestion = (close) => {
|
||||||
|
questionUpdate.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
marksUpdate.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
show.value = false
|
||||||
|
showToast(
|
||||||
|
__('Success'),
|
||||||
|
__('Question updated successfully'),
|
||||||
|
'check'
|
||||||
|
)
|
||||||
|
quiz.value.reload()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogOptions = computed(() => {
|
||||||
|
return {
|
||||||
|
title: __(props.title),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Submit'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => {
|
||||||
|
submitQuestion(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
input[type='radio']:checked {
|
||||||
|
background-color: theme('colors.gray.900') !important;
|
||||||
|
border-color: theme('colors.gray.900') !important;
|
||||||
|
--tw-ring-color: theme('colors.gray.900') !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
327
frontend/src/components/Modals/Settings.vue
Normal file
327
frontend/src/components/Modals/Settings.vue
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: '4xl' }">
|
||||||
|
<template #body>
|
||||||
|
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||||
|
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
|
||||||
|
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</h1>
|
||||||
|
<div v-for="tab in tabs" :key="tab.label">
|
||||||
|
<div
|
||||||
|
v-if="!tab.hideLabel"
|
||||||
|
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<span>{{ __(tab.label) }}</span>
|
||||||
|
</div>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<SidebarLink
|
||||||
|
v-for="item in tab.items"
|
||||||
|
:link="item"
|
||||||
|
:key="item.label"
|
||||||
|
class="w-full"
|
||||||
|
:class="
|
||||||
|
activeTab?.label == item.label
|
||||||
|
? 'bg-white shadow-sm'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
"
|
||||||
|
@click="activeTab = item"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="activeTab && data.doc"
|
||||||
|
:key="activeTab.label"
|
||||||
|
class="flex flex-1 flex-col px-10 py-8"
|
||||||
|
>
|
||||||
|
<Members
|
||||||
|
v-if="activeTab.label === 'Members'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
v-model:show="show"
|
||||||
|
/>
|
||||||
|
<Categories
|
||||||
|
v-else-if="activeTab.label === 'Categories'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
/>
|
||||||
|
<PaymentSettings
|
||||||
|
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
:data="data"
|
||||||
|
:fields="activeTab.fields"
|
||||||
|
/>
|
||||||
|
<BrandSettings
|
||||||
|
v-else-if="activeTab.label === 'Branding'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
:fields="activeTab.fields"
|
||||||
|
:data="branding"
|
||||||
|
/>
|
||||||
|
<SettingDetails
|
||||||
|
v-else
|
||||||
|
:fields="activeTab.fields"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
:data="data"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
|
import SettingDetails from '../SettingDetails.vue'
|
||||||
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
|
import Members from '@/components/Members.vue'
|
||||||
|
import Categories from '@/components/Categories.vue'
|
||||||
|
import BrandSettings from '@/components/BrandSettings.vue'
|
||||||
|
import PaymentSettings from '@/components/PaymentSettings.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const doctype = ref('LMS Settings')
|
||||||
|
const activeTab = ref(null)
|
||||||
|
const settingsStore = useSettings()
|
||||||
|
|
||||||
|
const data = createDocumentResource({
|
||||||
|
doctype: doctype.value,
|
||||||
|
name: doctype.value,
|
||||||
|
fields: ['*'],
|
||||||
|
cache: doctype.value,
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const branding = createResource({
|
||||||
|
url: 'lms.lms.api.get_branding',
|
||||||
|
auto: true,
|
||||||
|
cache: 'brand',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabsStructure = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Members',
|
||||||
|
description: 'Manage the members of your learning system',
|
||||||
|
icon: 'UserRoundPlus',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Payment Gateway',
|
||||||
|
icon: 'DollarSign',
|
||||||
|
description:
|
||||||
|
'Configure the payment gateway and other payment related settings',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Payment Gateway',
|
||||||
|
name: 'payment_gateway',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Payment Gateway',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Default Currency',
|
||||||
|
name: 'default_currency',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Currency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Apply GST for India',
|
||||||
|
name: 'apply_gst',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show USD equivalent amount',
|
||||||
|
name: 'show_usd_equivalent',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Apply rounding on equivalent',
|
||||||
|
name: 'apply_rounding',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Categories',
|
||||||
|
description: 'Manage the members of your learning system',
|
||||||
|
icon: 'Network',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Customise',
|
||||||
|
hideLabel: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Branding',
|
||||||
|
icon: 'Blocks',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Brand Name',
|
||||||
|
name: 'app_name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Logo',
|
||||||
|
name: 'banner_image',
|
||||||
|
type: 'Upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Favicon',
|
||||||
|
name: 'favicon',
|
||||||
|
type: 'Upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Footer Logo',
|
||||||
|
name: 'footer_logo',
|
||||||
|
type: 'Upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Address',
|
||||||
|
name: 'address',
|
||||||
|
type: 'textarea',
|
||||||
|
rows: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Footer "Powered By"',
|
||||||
|
name: 'footer_powered',
|
||||||
|
type: 'textarea',
|
||||||
|
rows: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copyright',
|
||||||
|
name: 'copyright',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sidebar',
|
||||||
|
icon: 'PanelLeftIcon',
|
||||||
|
description: 'Choose the items you want to show in the sidebar',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Courses',
|
||||||
|
name: 'courses',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batches',
|
||||||
|
name: 'batches',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certified Participants',
|
||||||
|
name: 'certified_participants',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Jobs',
|
||||||
|
name: 'jobs',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Statistics',
|
||||||
|
name: 'statistics',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
name: 'notifications',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Email Templates',
|
||||||
|
icon: 'MailPlus',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Batch Confirmation Template',
|
||||||
|
name: 'batch_confirmation_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certification Template',
|
||||||
|
name: 'certification_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assignment Submission Template',
|
||||||
|
name: 'assignment_submission_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Signup',
|
||||||
|
icon: 'LogIn',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Custom Content',
|
||||||
|
name: 'custom_signup_content',
|
||||||
|
type: 'Code',
|
||||||
|
mode: 'htmlmixed',
|
||||||
|
rows: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ask for Occupation',
|
||||||
|
name: 'user_category',
|
||||||
|
type: 'checkbox',
|
||||||
|
description:
|
||||||
|
'Enable this option to ask users to select their occupation during the signup process.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
return tabsStructure.value.map((tab) => {
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
items: tab.items.filter((item) => {
|
||||||
|
return !item.condition || item.condition()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(show, async () => {
|
||||||
|
if (show.value) {
|
||||||
|
const currentTab = await tabs.value
|
||||||
|
.flatMap((tab) => tab.items)
|
||||||
|
.find((item) => item.label === settingsStore.activeTab)
|
||||||
|
activeTab.value = currentTab || tabs.value[0].items[0]
|
||||||
|
} else {
|
||||||
|
activeTab.value = null
|
||||||
|
settingsStore.isSettingsOpen = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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>
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div>
|
<div>
|
||||||
{{ __('Please login to access this page.') }}
|
{{ __('Please login to access this page.') }}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
|
<Button @click="redirectToLogin()" class="mt-4">
|
||||||
{{ __('Login') }}
|
{{ __('Login') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
109
frontend/src/components/PaymentSettings.vue
Normal file
109
frontend/src/components/PaymentSettings.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-xl font-semibold mb-1">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<!-- <Badge
|
||||||
|
v-if="isDirty"
|
||||||
|
:label="__('Not Saved')"
|
||||||
|
variant="subtle"
|
||||||
|
theme="orange"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-scroll">
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
|
||||||
|
<SettingFields
|
||||||
|
v-if="paymentGateway.data"
|
||||||
|
:fields="paymentGateway.data.fields"
|
||||||
|
:data="paymentGateway.data.data"
|
||||||
|
class="w-1/2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
|
<Button variant="solid" @click="update">
|
||||||
|
{{ __('Update') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import SettingFields from '@/components/SettingFields.vue'
|
||||||
|
import { createResource, Badge, Button } from 'frappe-ui'
|
||||||
|
import { watch, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const paymentGateway = createResource({
|
||||||
|
url: 'lms.lms.api.get_payment_gateway_details',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
payment_gateway: props.data.doc.payment_gateway,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveSettings = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
let fields = {}
|
||||||
|
Object.keys(paymentGateway.data.data).forEach((key) => {
|
||||||
|
if (
|
||||||
|
paymentGateway.data.data[key] &&
|
||||||
|
typeof paymentGateway.data.data[key] === 'object'
|
||||||
|
) {
|
||||||
|
fields[key] = paymentGateway.data.data[key].file_url
|
||||||
|
} else {
|
||||||
|
fields[key] = paymentGateway.data.data[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
doctype: paymentGateway.data.doctype,
|
||||||
|
name: paymentGateway.data.docname,
|
||||||
|
fieldname: fields,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
paymentGateway.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
if (f.type != 'Column Break') {
|
||||||
|
props.data.doc[f.name] = f.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
props.data.save.submit()
|
||||||
|
saveSettings.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data.doc.payment_gateway,
|
||||||
|
() => {
|
||||||
|
paymentGateway.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
<div
|
||||||
<div class="leading-relaxed">
|
class="bg-blue-100 space-y-1 py-2 px-2 rounded-md text-sm text-blue-800"
|
||||||
|
>
|
||||||
|
<div class="leading-5">
|
||||||
{{
|
{{
|
||||||
__('This quiz consists of {0} questions.').format(
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
quiz.data.questions.length
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-if="quiz.data?.duration" class="leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Please ensure that you complete all the questions in {0} minutes.'
|
||||||
|
).format(quiz.data.duration)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-if="quiz.data?.duration" class="leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -24,14 +38,16 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="quiz.data.time" class="leading-relaxed">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'The quiz has a time limit. For each question you will be given {0} seconds.'
|
|
||||||
).format(quiz.data.time)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
|
||||||
|
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
|
||||||
|
<ProgressBar :progress="timerProgress" />
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ formatTimer(timer) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="activeQuestion == 0">
|
<div v-if="activeQuestion == 0">
|
||||||
<div class="border text-center p-20 rounded-md">
|
<div class="border text-center p-20 rounded-md">
|
||||||
<div class="font-semibold text-lg">
|
<div class="font-semibold text-lg">
|
||||||
@@ -59,25 +75,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!quizSubmission.data">
|
<div v-else-if="!quizSubmission.data">
|
||||||
<div v-for="(question, qtidx) in quiz.data.questions">
|
<div v-for="(question, qtidx) in questions">
|
||||||
<div
|
<div
|
||||||
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
|
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
|
||||||
class="border rounded-md p-5"
|
class="border rounded-md p-5"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="text-sm">
|
<div class="text-sm text-gray-600">
|
||||||
<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') }}
|
{{ getInstructions(questionDetails.data) }}
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{
|
|
||||||
questionDetails.data.multiple
|
|
||||||
? __('Choose all answers that apply')
|
|
||||||
: __('Choose one answer')
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-900 text-sm font-semibold item-left">
|
<div class="text-gray-900 text-sm font-semibold item-left">
|
||||||
@@ -86,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-gray-900 font-semibold mt-2"
|
class="text-gray-900 font-semibold mt-2 leading-5"
|
||||||
v-html="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">
|
||||||
@@ -141,7 +150,7 @@
|
|||||||
{{ questionDetails.data[`explanation_${index}`] }}
|
{{ questionDetails.data[`explanation_${index}`] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else-if="questionDetails.data.type == 'User Input'">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="possibleAnswer"
|
v-model="possibleAnswer"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -161,17 +170,31 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-5">
|
<div v-else>
|
||||||
<div>
|
<TextEditor
|
||||||
|
class="mt-4"
|
||||||
|
:content="possibleAnswer"
|
||||||
|
@change="(val) => (possibleAnswer = 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>
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
{{
|
{{
|
||||||
__('Question {0} of {1}').format(
|
__('Question {0} of {1}').format(
|
||||||
activeQuestion,
|
activeQuestion,
|
||||||
quiz.data.questions.length
|
questions.length
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="quiz.data.show_answers && !showAnswers.length"
|
v-if="
|
||||||
|
quiz.data.show_answers &&
|
||||||
|
!showAnswers.length &&
|
||||||
|
questionDetails.data.type != 'Open Ended'
|
||||||
|
"
|
||||||
@click="checkAnswer()"
|
@click="checkAnswer()"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -179,7 +202,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-else-if="activeQuestion != quiz.data.questions.length"
|
v-else-if="activeQuestion != questions.length"
|
||||||
@click="nextQuetion()"
|
@click="nextQuetion()"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -195,11 +218,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="border rounded-md p-20 text-center">
|
<div v-else class="border rounded-md p-20 text-center space-y-4">
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Quiz Summary') }}
|
{{ __('Quiz Summary') }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div v-if="quizSubmission.data.is_open_ended">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'You got {0}% correct answers with a score of {1} out of {2}'
|
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||||
@@ -238,19 +268,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge, Button, createResource, ListView } from 'frappe-ui'
|
import {
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
Badge,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
ListView,
|
||||||
|
TextEditor,
|
||||||
|
FormControl,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch, reactive, inject, computed } 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'
|
import ProgressBar from '@/components/ProgressBar.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([])
|
||||||
|
let questions = reactive([])
|
||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
|
const timer = ref(0)
|
||||||
|
let timerInterval = null
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -270,15 +310,62 @@ const quiz = createResource({
|
|||||||
cache: ['quiz', props.quizName],
|
cache: ['quiz', props.quizName],
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
if (data.shuffle_questions) {
|
populateQuestions()
|
||||||
data.questions = data.questions.sort(() => Math.random() - 0.5)
|
setupTimer()
|
||||||
}
|
|
||||||
if (data.limit_questions_to) {
|
|
||||||
data.questions = data.questions.slice(0, data.limit_questions_to)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const populateQuestions = () => {
|
||||||
|
let data = quiz.data
|
||||||
|
if (data.shuffle_questions) {
|
||||||
|
questions = shuffleArray(data.questions)
|
||||||
|
if (data.limit_questions_to) {
|
||||||
|
questions = questions.slice(0, data.limit_questions_to)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
questions = data.questions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupTimer = () => {
|
||||||
|
if (quiz.data.duration) {
|
||||||
|
timer.value = quiz.data.duration * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTimer = () => {
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
timer.value--
|
||||||
|
if (timer.value == 0) {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
submitQuiz()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimer = (seconds) => {
|
||||||
|
const hrs = Math.floor(seconds / 3600)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')
|
||||||
|
const secs = (seconds % 60).toString().padStart(2, '0')
|
||||||
|
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerProgress = computed(() => {
|
||||||
|
return (timer.value / (quiz.data.duration * 60)) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const shuffleArray = (array) => {
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[array[i], array[j]] = [array[j], array[i]]
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
const attempts = createResource({
|
const attempts = createResource({
|
||||||
url: 'frappe.client.get_list',
|
url: 'frappe.client.get_list',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -310,7 +397,7 @@ const attempts = createResource({
|
|||||||
watch(
|
watch(
|
||||||
() => quiz.data,
|
() => quiz.data,
|
||||||
() => {
|
() => {
|
||||||
if (quiz.data) {
|
if (quiz.data && quiz.data.max_attempts) {
|
||||||
attempts.reload()
|
attempts.reload()
|
||||||
resetQuiz()
|
resetQuiz()
|
||||||
}
|
}
|
||||||
@@ -355,6 +442,7 @@ watch(
|
|||||||
const startQuiz = () => {
|
const startQuiz = () => {
|
||||||
activeQuestion.value = 1
|
activeQuestion.value = 1
|
||||||
localStorage.removeItem(quiz.data.title)
|
localStorage.removeItem(quiz.data.title)
|
||||||
|
if (quiz.data.duration) startTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAnswer = (index) => {
|
const markAnswer = (index) => {
|
||||||
@@ -425,7 +513,7 @@ const checkAnswer = () => {
|
|||||||
const addToLocalStorage = () => {
|
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_name: currentQuestion.value,
|
||||||
answer: getAnswers().join(),
|
answer: getAnswers().join(),
|
||||||
is_correct: showAnswers.filter((answer) => {
|
is_correct: showAnswers.filter((answer) => {
|
||||||
return answer != undefined
|
return answer != undefined
|
||||||
@@ -436,9 +524,10 @@ const addToLocalStorage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextQuetion = () => {
|
const nextQuetion = () => {
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||||
checkAnswer()
|
checkAnswer()
|
||||||
} else {
|
} else {
|
||||||
|
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||||
resetQuestion()
|
resetQuestion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,7 +542,8 @@ const resetQuestion = () => {
|
|||||||
|
|
||||||
const submitQuiz = () => {
|
const submitQuiz = () => {
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers) {
|
||||||
checkAnswer()
|
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||||
|
else checkAnswer()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
createSubmission()
|
createSubmission()
|
||||||
}, 500)
|
}, 500)
|
||||||
@@ -463,9 +553,15 @@ const submitQuiz = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSubmission = () => {
|
const createSubmission = () => {
|
||||||
quizSubmission.reload().then(() => {
|
quizSubmission.submit(
|
||||||
attempts.reload()
|
{},
|
||||||
})
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||||
|
if (quiz.data.duration) clearInterval(timerInterval)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetQuiz = () => {
|
const resetQuiz = () => {
|
||||||
@@ -473,6 +569,15 @@ const resetQuiz = () => {
|
|||||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||||
showAnswers.length = 0
|
showAnswers.length = 0
|
||||||
quizSubmission.reset()
|
quizSubmission.reset()
|
||||||
|
populateQuestions()
|
||||||
|
setupTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInstructions = (question) => {
|
||||||
|
if (question.type == 'Choices')
|
||||||
|
if (question.multiple) return __('Choose all answers that apply')
|
||||||
|
else return __('Choose one answer')
|
||||||
|
else return __('Type your answer')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSubmissionColumns = () => {
|
const getSubmissionColumns = () => {
|
||||||
|
|||||||
58
frontend/src/components/QuizPlugin.vue
Normal file
58
frontend/src/components/QuizPlugin.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Add a quiz to your lesson') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
v-model="quiz"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
:label="__('Select a quiz')"
|
||||||
|
:onCreate="(value, close) => redirectToQuizForm()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<Button variant="solid" @click="addQuiz()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Button } from 'frappe-ui'
|
||||||
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const quiz = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
onQuizAddition: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
show.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
props.onQuizAddition(quiz.value)
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToQuizForm = () => {
|
||||||
|
window.open('/lms/quizzes/new', '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
frontend/src/components/SettingDetails.vue
Normal file
70
frontend/src/components/SettingDetails.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col justify-between h-full">
|
||||||
|
<div>
|
||||||
|
<div class="flex itemsc-center justify-between">
|
||||||
|
<div class="text-xl font-semibold leading-none mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
v-if="data.isDirty"
|
||||||
|
:label="__('Not Saved')"
|
||||||
|
variant="subtle"
|
||||||
|
theme="orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingFields :fields="fields" :data="data.doc" />
|
||||||
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
|
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||||
|
{{ __('Update') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Button, Badge } from 'frappe-ui'
|
||||||
|
import SettingFields from '@/components/SettingFields.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
if (f.type != 'Column Break') {
|
||||||
|
props.data.doc[f.name] = f.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
props.data.save.submit()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.CodeMirror pre.CodeMirror-line,
|
||||||
|
.CodeMirror pre.CodeMirror-line-like {
|
||||||
|
font-family: revert;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
144
frontend/src/components/SettingFields.vue
Normal file
144
frontend/src/components/SettingFields.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="my-5"
|
||||||
|
:class="{ 'flex justify-between w-full': columns.length > 1 }"
|
||||||
|
>
|
||||||
|
<div v-for="(column, index) in columns" :key="index">
|
||||||
|
<div
|
||||||
|
class="flex flex-col space-y-5"
|
||||||
|
:class="columns.length > 1 ? 'w-72' : 'w-full'"
|
||||||
|
>
|
||||||
|
<div v-for="field in column">
|
||||||
|
<Link
|
||||||
|
v-if="field.type == 'Link'"
|
||||||
|
v-model="data[field.name]"
|
||||||
|
:doctype="field.doctype"
|
||||||
|
:label="__(field.label)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="field.type == 'Code'">
|
||||||
|
<CodeEditor
|
||||||
|
:label="__(field.label)"
|
||||||
|
type="HTML"
|
||||||
|
description="The HTML you add here will be shown on your sign up page."
|
||||||
|
v-model="data[field.name]"
|
||||||
|
height="250px"
|
||||||
|
class="shrink-0"
|
||||||
|
:showLineNumbers="true"
|
||||||
|
>
|
||||||
|
</CodeEditor>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="field.type == 'Upload'">
|
||||||
|
<div class="text-sm text-gray-600 mb-1">
|
||||||
|
{{ __(field.label) }}
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!data[field.name]"
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => (data[field.name] = file)"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
|
>
|
||||||
|
<div class="">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center text-sm space-x-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded border border-outline-gray-1 w-[15rem] py-5"
|
||||||
|
>
|
||||||
|
<img :src="data[field.name]?.file_url" class="h-6 rounded" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-wrap">
|
||||||
|
<span class="break-all">
|
||||||
|
{{ data[field.name]?.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(data[field.name]?.file_size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
@click="data[field.name] = null"
|
||||||
|
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
v-else-if="field.type == 'checkbox'"
|
||||||
|
size="sm"
|
||||||
|
:label="__(field.label)"
|
||||||
|
:description="__(field.description)"
|
||||||
|
v-model="data[field.name]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
v-else
|
||||||
|
:key="field.name"
|
||||||
|
v-model="data[field.name]"
|
||||||
|
:label="__(field.label)"
|
||||||
|
:type="field.type"
|
||||||
|
:rows="field.rows"
|
||||||
|
:options="field.options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { getFileSize, validateFile } from '@/utils'
|
||||||
|
import { X, FileText } from 'lucide-vue-next'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
const cols = []
|
||||||
|
let currentColumn = []
|
||||||
|
|
||||||
|
props.fields.forEach((field) => {
|
||||||
|
if (field.type === 'Column Break') {
|
||||||
|
if (currentColumn.length > 0) {
|
||||||
|
cols.push(currentColumn)
|
||||||
|
currentColumn = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (field.type == 'checkbox') {
|
||||||
|
field.value = props.data[field.name] ? true : false
|
||||||
|
} else {
|
||||||
|
field.value = props.data[field.name]
|
||||||
|
}
|
||||||
|
currentColumn.push(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (currentColumn.length > 0) {
|
||||||
|
cols.push(currentColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
: 'ml-2 w-auto opacity-100'
|
: 'ml-2 w-auto opacity-100'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ __(link.label) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||||
{{ link.count }}
|
{{ link.count }}
|
||||||
|
|||||||
53
frontend/src/components/UploadPlugin.vue
Normal file
53
frontend/src/components/UploadPlugin.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<FileUploader
|
||||||
|
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(data) => addFile(data)"
|
||||||
|
ref="fileUploader"
|
||||||
|
class="hide"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FileUploader } from 'frappe-ui'
|
||||||
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const fileUploader = ref(null)
|
||||||
|
const emit = defineEmits(['fileUploaded'])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
onFileUploaded: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
const fileInput = fileUploader.value.$el.querySelector('input[type="file"]')
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const addFile = (file) => {
|
||||||
|
props.onFileUploaded({
|
||||||
|
file_url: file.file_url,
|
||||||
|
file_type: file.file_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
|
||||||
|
return 'Only image and video files are allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = (type) => {
|
||||||
|
return ['mov', 'mp4', 'avi', 'mkv', 'webm'].includes(type.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAudio = (type) => {
|
||||||
|
return ['mp3', 'wav', 'ogg'].includes(type.toLowerCase())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown :options="userDropdownOptions">
|
<Dropdown class="p-2" :options="userDropdownOptions">
|
||||||
<template v-slot="{ open }">
|
<template v-slot="{ open }">
|
||||||
<button
|
<button
|
||||||
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
|
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
: 'hover:bg-gray-200 px-2 w-52'
|
: 'hover:bg-gray-200 px-2 w-52'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span
|
<img
|
||||||
v-if="branding.data?.brand_html"
|
v-if="branding.data?.banner_image"
|
||||||
v-html="branding.data?.brand_html"
|
:src="branding.data?.banner_image.file_url"
|
||||||
class="w-8 h-8 rounded flex-shrink-0"
|
class="w-8 h-8 rounded flex-shrink-0"
|
||||||
></span>
|
/>
|
||||||
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
||||||
@@ -28,11 +28,10 @@
|
|||||||
<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="
|
v-if="
|
||||||
branding.data?.brand_name &&
|
branding.data?.app_name && branding.data?.app_name != 'Frappe'
|
||||||
branding.data?.brand_name != 'Frappe'
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ branding.data?.brand_name }}
|
{{ branding.data?.app_name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else> Learning </span>
|
<span v-else> Learning </span>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,27 +55,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<SettingsModal
|
||||||
|
v-if="userResource.data?.is_moderator"
|
||||||
|
v-model="showSettingsModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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 } from 'frappe-ui'
|
||||||
import {
|
import Apps from '@/components/Apps.vue'
|
||||||
ChevronDown,
|
import { ChevronDown, LogIn, LogOut, User, Settings } from 'lucide-vue-next'
|
||||||
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 { usersStore } from '@/stores/user'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
|
import { markRaw, watch, ref } from 'vue'
|
||||||
|
import SettingsModal from '@/components/Modals/Settings.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { logout, branding } = sessionStore()
|
const { logout, branding } = sessionStore()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
|
const settingsStore = useSettings()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
const showSettingsModal = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isCollapsed: {
|
isCollapsed: {
|
||||||
@@ -85,6 +88,13 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.isSettingsOpen,
|
||||||
|
(value) => {
|
||||||
|
showSettingsModal.value = value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const userDropdownOptions = [
|
const userDropdownOptions = [
|
||||||
{
|
{
|
||||||
icon: User,
|
icon: User,
|
||||||
@@ -97,11 +107,7 @@ const userDropdownOptions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ArrowRightLeft,
|
component: markRaw(Apps),
|
||||||
label: 'Switch to Desk',
|
|
||||||
onClick: () => {
|
|
||||||
window.location.href = '/app'
|
|
||||||
},
|
|
||||||
condition: () => {
|
condition: () => {
|
||||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||||
let system_user = cookies.get('system_user')
|
let system_user = cookies.get('system_user')
|
||||||
@@ -109,6 +115,16 @@ const userDropdownOptions = [
|
|||||||
else return false
|
else return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
label: 'Settings',
|
||||||
|
onClick: () => {
|
||||||
|
settingsStore.isSettingsOpen = true
|
||||||
|
},
|
||||||
|
condition: () => {
|
||||||
|
return userResource.data?.is_moderator
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: LogOut,
|
icon: LogOut,
|
||||||
label: 'Log out',
|
label: 'Log out',
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="videoContainer" class="video-block group relative">
|
<div ref="videoContainer" class="video-block group relative">
|
||||||
<video @timeupdate="updateTime" @ended="videoEnded" class="rounded-lg">
|
<video
|
||||||
|
@timeupdate="updateTime"
|
||||||
|
@ended="videoEnded"
|
||||||
|
@click="togglePlay"
|
||||||
|
class="rounded-lg border border-gray-100 group cursor-pointer"
|
||||||
|
ref="videoRef"
|
||||||
|
>
|
||||||
<source :src="fileURL" :type="type" />
|
<source :src="fileURL" :type="type" />
|
||||||
</video>
|
</video>
|
||||||
<div
|
<div
|
||||||
class="flex items-center space-x-2 bg-gray-200 rounded-lg p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
|
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 invisible group-hover:visible"
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -67,7 +73,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.value = document.querySelector('video')
|
|
||||||
videoRef.value.onloadedmetadata = () => {
|
videoRef.value.onloadedmetadata = () => {
|
||||||
duration.value = videoRef.value.duration
|
duration.value = videoRef.value.duration
|
||||||
}
|
}
|
||||||
@@ -102,6 +107,14 @@ const pauseVideo = () => {
|
|||||||
playing.value = false
|
playing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (playing.value) {
|
||||||
|
pauseVideo()
|
||||||
|
} else {
|
||||||
|
playVideo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const videoEnded = () => {
|
const videoEnded = () => {
|
||||||
playing.value = false
|
playing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import router from './router'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import dayjs from '@/utils/dayjs'
|
import dayjs from '@/utils/dayjs'
|
||||||
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import translationPlugin from './translation'
|
import translationPlugin from './translation'
|
||||||
import { usersStore } from './stores/user'
|
import { usersStore } from './stores/user'
|
||||||
import { sessionStore } from './stores/session'
|
import { sessionStore } from './stores/session'
|
||||||
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
|
|||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
app.provide('$allUsers', allUsers)
|
app.provide('$allUsers', allUsers)
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
|
app.config.globalProperties.$dialog = createDialog
|
||||||
|
|||||||
@@ -13,13 +13,9 @@
|
|||||||
<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 mb-4 space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl v-model="batch.title" :label="__('Title')" />
|
||||||
v-model="batch.title"
|
|
||||||
:label="__('Title')"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -236,6 +232,7 @@ 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'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -274,6 +271,8 @@ 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()
|
||||||
|
} else {
|
||||||
|
capture('batch_form_opened')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
@@ -377,6 +376,7 @@ const createNewBatch = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
capture('batch_created')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
params: {
|
params: {
|
||||||
@@ -447,7 +447,7 @@ const breadcrumbs = computed(() => {
|
|||||||
}
|
}
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
||||||
route: { name: 'BatchCreation', params: { batchName: props.batchName } },
|
route: { name: 'BatchForm', params: { batchName: props.batchName } },
|
||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
@@ -5,21 +5,29 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
|
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex">
|
<div class="flex space-x-2">
|
||||||
|
<div class="w-44">
|
||||||
|
<Select
|
||||||
|
v-if="categories.data?.length"
|
||||||
|
v-model="currentCategory"
|
||||||
|
:options="categories.data"
|
||||||
|
:placeholder="__('Category')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data?.is_moderator"
|
v-if="user.data?.is_moderator"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchCreation',
|
name: 'BatchForm',
|
||||||
params: { batchName: 'new' },
|
params: { batchName: 'new' },
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid">
|
<Button variant="solid">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New Batch') }}
|
{{ __('New') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
:tabs="tabs"
|
:tabs="makeTabs"
|
||||||
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 }">
|
||||||
@@ -87,13 +95,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Breadcrumbs, Button, Tabs, Badge } from 'frappe-ui'
|
import {
|
||||||
|
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 } from 'vue'
|
import { inject, ref, computed, onMounted, watch } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
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',
|
||||||
@@ -102,35 +126,76 @@ const batches = createListResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const categories = createResource({
|
||||||
const tabs = [
|
url: 'lms.lms.api.get_categories',
|
||||||
{
|
makeParams() {
|
||||||
label: 'Upcoming',
|
return {
|
||||||
batches: computed(() => batches.data?.upcoming || []),
|
doctype: 'LMS Batch',
|
||||||
count: computed(() => batches.data?.upcoming?.length),
|
filters: {
|
||||||
|
published: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
]
|
cache: ['batchCategories'],
|
||||||
|
auto: true,
|
||||||
|
transform(data) {
|
||||||
|
data.unshift({
|
||||||
|
label: '',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (user.data?.is_moderator) {
|
const tabIndex = ref(0)
|
||||||
|
let tabs
|
||||||
|
|
||||||
|
const makeTabs = computed(() => {
|
||||||
|
tabs = []
|
||||||
|
addToTabs('Upcoming')
|
||||||
|
|
||||||
|
if (user.data?.is_moderator) {
|
||||||
|
addToTabs('Archived')
|
||||||
|
addToTabs('Private')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.data) {
|
||||||
|
addToTabs('Enrolled')
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs
|
||||||
|
})
|
||||||
|
|
||||||
|
const getBatches = (type) => {
|
||||||
|
if (currentCategory.value && currentCategory.value != '') {
|
||||||
|
return batches.data[type].filter(
|
||||||
|
(batch) => batch.category == currentCategory.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return batches.data[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToTabs = (label) => {
|
||||||
|
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label: 'Archived',
|
label,
|
||||||
batches: computed(() => batches.data?.archived),
|
batches: computed(() => batches),
|
||||||
count: computed(() => batches.data?.archived?.length),
|
count: computed(() => batches.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(() => {
|
const pageMeta = computed(() => {
|
||||||
return {
|
return {
|
||||||
title: 'Batches',
|
title: 'Batches',
|
||||||
|
|||||||
@@ -1,44 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="">
|
||||||
|
<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
|
||||||
|
class="h-7"
|
||||||
|
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
<div
|
<div
|
||||||
v-if="access.data?.access && orderSummary.data"
|
v-if="access.data?.access && orderSummary.data"
|
||||||
class="mt-10 w-1/2 mx-auto"
|
class="pt-5 pb-10 mx-5"
|
||||||
>
|
>
|
||||||
<div class="text-3xl font-bold">
|
<!-- <div class="mb-5">
|
||||||
{{ __('Billing Details') }}
|
<div class="text-lg font-semibold">
|
||||||
</div>
|
{{ __('Address') }}
|
||||||
<div class="text-gray-600 mt-1">
|
|
||||||
{{ __('Enter the billing information to complete the payment.') }}
|
|
||||||
</div>
|
|
||||||
<div class="border rounded-md p-5 mt-5">
|
|
||||||
<div class="text-xl font-semibold">
|
|
||||||
{{ __('Summary') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600 mt-1">
|
</div> -->
|
||||||
{{ __('Review the details of your purchase.') }}
|
<div class="flex flex-col lg:flex-row justify-between">
|
||||||
</div>
|
<div
|
||||||
<div class="mt-5">
|
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
|
||||||
<div class="flex items-center justify-between">
|
>
|
||||||
<div>
|
<div class="flex items-center justify-between space-x-2">
|
||||||
|
<div class="text-gray-600">
|
||||||
|
{{ __('Ordered Item') }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
{{ orderSummary.data.title }}
|
{{ orderSummary.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
:class="{
|
<div
|
||||||
'font-semibold text-xl': !orderSummary.data.gst_applied,
|
v-if="orderSummary.data.gst_applied"
|
||||||
}"
|
class="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
{{
|
<div class="text-gray-600">
|
||||||
orderSummary.data.gst_applied
|
{{ __('Original Amount') }}
|
||||||
? orderSummary.data.original_amount_formatted
|
</div>
|
||||||
: orderSummary.data.total_amount_formatted
|
<div class="">
|
||||||
}}
|
{{ orderSummary.data.original_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
v-if="orderSummary.data.gst_applied"
|
||||||
class="flex items-center justify-between mt-2"
|
class="flex items-center justify-between mt-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="text-gray-600">
|
||||||
{{ __('GST Amount') }}
|
{{ __('GST Amount') }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -46,107 +52,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
|
||||||
class="flex items-center justify-between mt-2"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Total Amount') }}
|
{{ __('Total') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-2xl">
|
<div class="text-lg font-semibold">
|
||||||
{{ orderSummary.data.total_amount_formatted }}
|
{{ orderSummary.data.total_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xl font-semibold mt-10">
|
<div class="flex-1 lg:mr-10">
|
||||||
{{ __('Address') }}
|
<div class="mb-5">
|
||||||
</div>
|
<div class="text-lg font-semibold">
|
||||||
<div class="text-gray-600 mt-1">
|
{{ __('Address') }}
|
||||||
{{ __('Specify your billing address correctly.') }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-5 mt-4">
|
|
||||||
<div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('Billing Name') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.billing_name" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('Address Line 1') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.address_line1" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('Address Line 2') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.address_line2" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('City') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.city" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('State') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.state" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div class="mt-4">
|
<div class="space-y-4">
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
<FormControl
|
||||||
{{ __('Country') }}
|
:label="__('Billing Name')"
|
||||||
</div>
|
v-model="billingDetails.billing_name"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Address Line 1')"
|
||||||
|
v-model="billingDetails.address_line1"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Address Line 2')"
|
||||||
|
v-model="billingDetails.address_line2"
|
||||||
|
/>
|
||||||
|
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||||
|
<FormControl
|
||||||
|
:label="__('State')"
|
||||||
|
v-model="billingDetails.state"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
<Link
|
<Link
|
||||||
doctype="Country"
|
doctype="Country"
|
||||||
:value="billingDetails.country"
|
:value="billingDetails.country"
|
||||||
@change="(option) => changeCurrency(option)"
|
@change="(option) => changeCurrency(option)"
|
||||||
|
:label="__('Country')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Postal Code')"
|
||||||
|
v-model="billingDetails.pincode"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Phone Number')"
|
||||||
|
v-model="billingDetails.phone"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('Postal Code') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.pincode" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('Phone Number') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="billingDetails.phone" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
|
||||||
{{ __('Source') }}
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
doctype="LMS Source"
|
doctype="LMS Source"
|
||||||
:value="billingDetails.source"
|
:value="billingDetails.source"
|
||||||
@change="(option) => (billingDetails.source = option)"
|
@change="(option) => (billingDetails.source = option)"
|
||||||
|
:label="__('Where did you hear about us?')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="billingDetails.country == 'India'"
|
||||||
|
:label="__('GST Number')"
|
||||||
|
v-model="billingDetails.gstin"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="billingDetails.country == 'India'"
|
||||||
|
:label="__('Pan Number')"
|
||||||
|
v-model="billingDetails.pan"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
</div>
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
<div class="flex items-center justify-between border-t pt-4 mt-8">
|
||||||
{{ __('GST Number') }}
|
<p class="text-gray-600">
|
||||||
</div>
|
{{
|
||||||
<Input type="text" v-model="billingDetails.gstin" />
|
__(
|
||||||
</div>
|
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
||||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
)
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
}}
|
||||||
{{ __('Pan Number') }}
|
</p>
|
||||||
</div>
|
<Button variant="solid" size="md" @click="generatePaymentLink()">
|
||||||
<Input type="text" v-model="billingDetails.pan" />
|
{{ __('Proceed to Payment') }}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
|
||||||
{{ __('Proceed to Payment') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="access.data?.message">
|
<div v-else-if="access.data?.message">
|
||||||
@@ -167,11 +155,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Input, Button, createResource } from 'frappe-ui'
|
import {
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
FormControl,
|
||||||
|
Breadcrumbs,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { reactive, inject, onMounted, ref } from 'vue'
|
import { reactive, inject, onMounted, ref } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import NotPermitted from '@/components/NotPermitted.vue'
|
import NotPermitted from '@/components/NotPermitted.vue'
|
||||||
import { createToast } from '@/utils/'
|
import { showToast } from '@/utils/'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
@@ -202,8 +197,8 @@ const access = createResource({
|
|||||||
name: props.name,
|
name: props.name,
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
orderSummary.submit()
|
|
||||||
setBillingDetails(data.address)
|
setBillingDetails(data.address)
|
||||||
|
orderSummary.submit()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -224,84 +219,49 @@ const orderSummary = createResource({
|
|||||||
const billingDetails = reactive({})
|
const billingDetails = reactive({})
|
||||||
|
|
||||||
const setBillingDetails = (data) => {
|
const setBillingDetails = (data) => {
|
||||||
billingDetails.billing_name = data.billing_name || ''
|
billingDetails.billing_name = data?.billing_name || ''
|
||||||
billingDetails.address_line1 = data.address_line1 || ''
|
billingDetails.address_line1 = data?.address_line1 || ''
|
||||||
billingDetails.address_line2 = data.address_line2 || ''
|
billingDetails.address_line2 = data?.address_line2 || ''
|
||||||
billingDetails.city = data.city || ''
|
billingDetails.city = data?.city || ''
|
||||||
billingDetails.state = data.state || ''
|
billingDetails.state = data?.state || ''
|
||||||
billingDetails.country = data.country || ''
|
billingDetails.country = data?.country || ''
|
||||||
billingDetails.pincode = data.pincode || ''
|
billingDetails.pincode = data?.pincode || ''
|
||||||
billingDetails.phone = data.phone || ''
|
billingDetails.phone = data?.phone || ''
|
||||||
billingDetails.source = data.source || ''
|
billingDetails.source = data?.source || ''
|
||||||
billingDetails.gstin = data.gstin || ''
|
billingDetails.gstin = data?.gstin || ''
|
||||||
billingDetails.pan = data.pan || ''
|
billingDetails.pan = data?.pan || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentOptions = createResource({
|
const paymentLink = createResource({
|
||||||
url: 'lms.lms.utils.get_payment_options',
|
url: 'lms.lms.payments.get_payment_link',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||||
docname: props.name,
|
docname: props.name,
|
||||||
phone: billingDetails.phone,
|
title: orderSummary.data.title,
|
||||||
country: billingDetails.country,
|
amount: orderSummary.data.original_amount,
|
||||||
|
total_amount: orderSummary.data.amount,
|
||||||
|
currency: orderSummary.data.currency,
|
||||||
|
address: billingDetails,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const generatePaymentLink = () => {
|
const generatePaymentLink = () => {
|
||||||
paymentOptions.submit(
|
paymentLink.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
validate(params) {
|
validate() {
|
||||||
|
if (!billingDetails.source) {
|
||||||
|
return __('Please let us know where you heard about us from.')
|
||||||
|
}
|
||||||
return validateAddress()
|
return validateAddress()
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
data.handler = (response) => {
|
window.location.href = data
|
||||||
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
|
|
||||||
let docname = props.name
|
|
||||||
handleSuccess(response, doctype, docname, data.order_id)
|
|
||||||
}
|
|
||||||
let rzp1 = new Razorpay(data)
|
|
||||||
rzp1.open()
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showError(err)
|
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentResource = createResource({
|
|
||||||
url: 'lms.lms.utils.verify_payment',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
response: values.response,
|
|
||||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
|
||||||
docname: props.name,
|
|
||||||
address: billingDetails,
|
|
||||||
order_id: values.orderId,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSuccess = (response, doctype, docname, orderId) => {
|
|
||||||
paymentResource.submit(
|
|
||||||
{
|
|
||||||
response: response,
|
|
||||||
orderId: orderId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
createToast({
|
|
||||||
title: 'Success',
|
|
||||||
text: 'Payment Successful',
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
|
||||||
})
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = data
|
|
||||||
}, 3000)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search Participants"
|
placeholder="Search"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@input="participants.reload()"
|
@input="participants.reload()"
|
||||||
|
class="w-40"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
||||||
@@ -18,7 +19,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
||||||
<div v-if="participants.data" v-for="participant in participants.data">
|
<div
|
||||||
|
v-if="participants.data?.length"
|
||||||
|
v-for="participant in participantsList"
|
||||||
|
>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -59,12 +63,7 @@ const searchQuery = ref('')
|
|||||||
const participants = createResource({
|
const participants = createResource({
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
debounce: 300,
|
cache: 'certified-participants',
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
search_query: searchQuery.value,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,5 +78,16 @@ const pageMeta = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -109,6 +109,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-1/2 mb-4">
|
||||||
|
<Link
|
||||||
|
doctype="LMS Category"
|
||||||
|
v-model="course.category"
|
||||||
|
:label="__('Category')"
|
||||||
|
:onCreate="(value, close) => openSettings(close)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="instructors"
|
v-model="instructors"
|
||||||
doctype="User"
|
doctype="User"
|
||||||
@@ -119,7 +127,7 @@
|
|||||||
<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-2 gap-10 mb-4">
|
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||||
<div
|
<div
|
||||||
v-if="user.data?.is_moderator"
|
v-if="user.data?.is_moderator"
|
||||||
class="flex flex-col space-y-3"
|
class="flex flex-col space-y-3"
|
||||||
@@ -147,11 +155,18 @@
|
|||||||
v-model="course.featured"
|
v-model="course.featured"
|
||||||
:label="__('Featured')"
|
:label="__('Featured')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.disable_self_learning"
|
v-model="course.disable_self_learning"
|
||||||
:label="__('Disable Self Enrollment')"
|
:label="__('Disable Self Enrollment')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.enable_certification"
|
||||||
|
:label="__('Completion Certificate')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,17 +229,20 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
getFileSize,
|
getFileSize,
|
||||||
updateDocumentTitle,
|
updateDocumentTitle,
|
||||||
} from '../utils'
|
} 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'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const instructors = ref([])
|
const instructors = ref([])
|
||||||
|
const settingsStore = useSettings()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -244,6 +262,7 @@ const course = reactive({
|
|||||||
featured: false,
|
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: '',
|
||||||
@@ -260,6 +279,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
courseResource.reload()
|
||||||
|
} else {
|
||||||
|
capture('course_form_opened')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
@@ -337,6 +358,7 @@ const courseResource = createResource({
|
|||||||
'disable_self_learning',
|
'disable_self_learning',
|
||||||
'paid_course',
|
'paid_course',
|
||||||
'featured',
|
'featured',
|
||||||
|
'enable_certification',
|
||||||
]
|
]
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
@@ -379,9 +401,10 @@ const submitCourse = () => {
|
|||||||
} else {
|
} else {
|
||||||
courseCreationResource.submit(course, {
|
courseCreationResource.submit(course, {
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
capture('course_created')
|
||||||
showToast('Success', 'Course created successfully', 'check')
|
showToast('Success', 'Course created successfully', 'check')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'CreateCourse',
|
name: 'CourseForm',
|
||||||
params: { courseName: data.name },
|
params: { courseName: data.name },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -407,7 +430,7 @@ const validateMandatoryFields = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (course.paid_course && (!course.course_price || !course.currency)) {
|
if (course.paid_course && (!course.course_price || !course.currency)) {
|
||||||
return 'Course price and currency are mandatory for paid courses'
|
return __('Course price and currency are mandatory for paid courses')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +446,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', 'webp'].includes(extension)) {
|
||||||
return 'Only image file is allowed.'
|
return __('Only image file is allowed.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +473,12 @@ const removeImage = () => {
|
|||||||
course.course_image = null
|
course.course_image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSettings = (close) => {
|
||||||
|
close()
|
||||||
|
settingsStore.activeTab = 'Categories'
|
||||||
|
settingsStore.isSettingsOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
const check_permission = () => {
|
const check_permission = () => {
|
||||||
let user_is_instructor = false
|
let user_is_instructor = false
|
||||||
if (user.data?.is_moderator) return
|
if (user.data?.is_moderator) return
|
||||||
@@ -480,7 +509,7 @@ const breadcrumbs = computed(() => {
|
|||||||
}
|
}
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||||
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
|
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
@@ -5,22 +5,33 @@
|
|||||||
>
|
>
|
||||||
<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 space-x-2 justify-end">
|
||||||
<FormControl
|
<div class="w-46 md:w-44">
|
||||||
type="text"
|
<FormControl
|
||||||
placeholder="Search Course"
|
v-if="categories.data?.length"
|
||||||
v-model="searchQuery"
|
type="select"
|
||||||
@input="courses.reload()"
|
v-model="currentCategory"
|
||||||
>
|
:options="categories.data"
|
||||||
<template #prefix>
|
:placeholder="__('Category')"
|
||||||
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
/>
|
||||||
</template>
|
</div>
|
||||||
</FormControl>
|
<div class="w-28 md:w-36">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="courses.reload()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateCourse',
|
name: 'CourseForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: 'new',
|
courseName: 'new',
|
||||||
},
|
},
|
||||||
@@ -30,7 +41,7 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New Course') }}
|
{{ __('New') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,11 +128,19 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import { Plus, Search } from 'lucide-vue-next'
|
import { Plus, Search } from 'lucide-vue-next'
|
||||||
import { ref, computed, inject } from 'vue'
|
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const currentCategory = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
let queries = new URLSearchParams(location.search)
|
||||||
|
if (queries.has('category')) {
|
||||||
|
currentCategory.value = queries.get('category')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const courses = createResource({
|
const courses = createResource({
|
||||||
url: 'lms.lms.utils.get_courses',
|
url: 'lms.lms.utils.get_courses',
|
||||||
@@ -134,29 +153,30 @@ let tabs
|
|||||||
|
|
||||||
const makeTabs = computed(() => {
|
const makeTabs = computed(() => {
|
||||||
tabs = []
|
tabs = []
|
||||||
addToTabs('Live', getCourses('live'))
|
addToTabs('Live')
|
||||||
addToTabs('New', getCourses('new'))
|
addToTabs('New')
|
||||||
addToTabs('Upcoming', getCourses('upcoming'))
|
addToTabs('Upcoming')
|
||||||
|
|
||||||
if (user.data) {
|
if (user.data) {
|
||||||
addToTabs('Enrolled', getCourses('enrolled'))
|
addToTabs('Enrolled')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user.data.is_moderator ||
|
user.data.is_moderator ||
|
||||||
user.data.is_instructor ||
|
user.data.is_instructor ||
|
||||||
courses.data?.created?.length
|
courses.data?.created?.length
|
||||||
) {
|
) {
|
||||||
addToTabs('Created', getCourses('created'))
|
addToTabs('Created')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.data.is_moderator) {
|
if (user.data.is_moderator) {
|
||||||
addToTabs('Under Review', getCourses('under_review'))
|
addToTabs('Under Review')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tabs
|
return tabs
|
||||||
})
|
})
|
||||||
|
|
||||||
const addToTabs = (label, courses) => {
|
const addToTabs = (label) => {
|
||||||
|
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label,
|
label,
|
||||||
courses: computed(() => courses),
|
courses: computed(() => courses),
|
||||||
@@ -165,14 +185,57 @@ const addToTabs = (label, courses) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCourses = (type) => {
|
const getCourses = (type) => {
|
||||||
|
let courseList = courses.data[type]
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
return courses.data[type].filter((course) =>
|
let query = searchQuery.value.toLowerCase()
|
||||||
course.title.toLowerCase().includes(searchQuery.value.toLowerCase())
|
courseList = courseList.filter(
|
||||||
|
(course) =>
|
||||||
|
course.title.toLowerCase().includes(query) ||
|
||||||
|
course.short_introduction.toLowerCase().includes(query) ||
|
||||||
|
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return courses.data[type]
|
if (currentCategory.value && currentCategory.value != '') {
|
||||||
|
courseList = courseList.filter(
|
||||||
|
(course) => course.category == currentCategory.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return courseList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categories = createResource({
|
||||||
|
url: 'lms.lms.api.get_categories',
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Course',
|
||||||
|
filters: {
|
||||||
|
published: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cache: ['courseCategories'],
|
||||||
|
auto: true,
|
||||||
|
transform(data) {
|
||||||
|
data.unshift({
|
||||||
|
label: '',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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(() => {
|
const pageMeta = computed(() => {
|
||||||
return {
|
return {
|
||||||
title: 'Courses',
|
title: 'Courses',
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const newJob = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'Job Opportunity',
|
doctype: 'Job Opportunity',
|
||||||
company_logo: job.image.file_url,
|
company_logo: job.image?.file_url,
|
||||||
...job,
|
...job,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,51 +50,89 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="job.data" class="w-3/4 mx-auto">
|
<div v-if="job.data" class="max-w-3xl mx-auto">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex mb-4">
|
<div class="space-y-5 mb-10">
|
||||||
<img
|
<div class="flex items-center">
|
||||||
:src="job.data.company_logo"
|
<img
|
||||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
:src="job.data.company_logo"
|
||||||
:alt="job.data.company_name"
|
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||||
/>
|
:alt="job.data.company_name"
|
||||||
<div>
|
/>
|
||||||
<div class="text-2xl font-semibold mb-4">
|
<div class="text-2xl font-semibold mb-4">
|
||||||
{{ 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">
|
<div>
|
||||||
<div class="flex items-center space-x-2">
|
<div
|
||||||
<Building2 class="h-4 w-4 stroke-1.5" />
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
|
||||||
<span>{{ job.data.company_name }}</span>
|
>
|
||||||
</div>
|
<div class="flex items-center space-x-2">
|
||||||
<div class="flex items-center space-x-2">
|
<span class="p-4 bg-green-50 rounded-full">
|
||||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
<Building2 class="h-4 w-4 text-green-500" />
|
||||||
<span>{{ job.data.location }}</span>
|
</span>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||||
|
{{ __('Organisation') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold">
|
||||||
|
{{ job.data.company_name }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-2">
|
<div class="flex items-center space-x-2">
|
||||||
<div class="flex items-center space-x-2">
|
<span class="p-4 bg-red-50 rounded-full">
|
||||||
<ClipboardType class="h-4 w-4 stroke-1.5" />
|
<MapPin class="h-4 w-4 text-red-500" />
|
||||||
<span>{{ job.data.type }}</span>
|
</span>
|
||||||
</div>
|
<div class="flex flex-col space-y-2">
|
||||||
<div class="flex items-center space-x-2">
|
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||||
<CalendarDays class="h-4 w-4 stroke-1.5" />
|
{{ __('Location') }}
|
||||||
<span>{{
|
</span>
|
||||||
dayjs(job.data.creation).format('DD MMM YYYY')
|
<span class="text-sm font-semibold">
|
||||||
}}</span>
|
{{ job.data.location }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 h-fit">
|
<div class="flex items-center space-x-2">
|
||||||
<div
|
<span class="p-4 bg-yellow-50 rounded-full">
|
||||||
v-if="applicationCount.data"
|
<ClipboardType class="h-4 w-4 text-yellow-500" />
|
||||||
class="flex items-center space-x-2"
|
</span>
|
||||||
>
|
<div class="flex flex-col space-y-2">
|
||||||
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
<span class="text-xs font-medium text-gray-600 uppercase">
|
||||||
<span
|
{{ __('Category') }}
|
||||||
>{{ applicationCount.data }}
|
</span>
|
||||||
{{ __('applications received') }}</span
|
<span class="text-sm font-semibold">
|
||||||
>
|
{{ job.data.type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="p-4 bg-blue-50 rounded-full">
|
||||||
|
<CalendarDays class="h-4 w-4 text-blue-500" />
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||||
|
{{ __('Posted on') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold">
|
||||||
|
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="applicationCount.data"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<span class="p-4 bg-purple-50 rounded-full">
|
||||||
|
<SquareUserRound class="h-4 w-4 text-purple-500" />
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||||
|
{{ __('Applications Received') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold">
|
||||||
|
{{ applicationCount.data }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<router-link
|
<router-link
|
||||||
v-if="allowEdit()"
|
v-if="allowEdit()"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateLesson',
|
name: 'LessonForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: props.chapterNumber,
|
chapterNumber: props.chapterNumber,
|
||||||
@@ -90,6 +90,17 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -109,7 +120,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
lesson.data.instructor_content?.blocks?.length &&
|
lesson.data.instructor_content &&
|
||||||
|
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||||
allowInstructorContent()
|
allowInstructorContent()
|
||||||
"
|
"
|
||||||
class="bg-gray-100 p-3 rounded-md mt-6"
|
class="bg-gray-100 p-3 rounded-md mt-6"
|
||||||
@@ -160,12 +172,12 @@
|
|||||||
{{ 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(lesson.data.membership.progress) }}% completed
|
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
v-if="user && lesson.data.membership"
|
v-if="user && lesson.data.membership"
|
||||||
:progress="lesson.data.membership.progress"
|
:progress="lessonProgress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseOutline
|
<CourseOutline
|
||||||
@@ -179,7 +191,7 @@
|
|||||||
</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 } from 'vue'
|
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } 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'
|
||||||
@@ -196,6 +208,9 @@ const route = useRoute()
|
|||||||
const allowDiscussions = ref(false)
|
const allowDiscussions = ref(false)
|
||||||
const editor = ref(null)
|
const editor = ref(null)
|
||||||
const instructorEditor = ref(null)
|
const instructorEditor = ref(null)
|
||||||
|
const lessonProgress = ref(0)
|
||||||
|
const timer = ref(0)
|
||||||
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -212,6 +227,10 @@ 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],
|
||||||
@@ -224,9 +243,12 @@ const lesson = createResource({
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
markProgress(data)
|
lessonProgress.value = data.membership?.progress
|
||||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||||
if (data.instructor_content?.blocks?.length)
|
if (
|
||||||
|
data.instructor_content &&
|
||||||
|
JSON.parse(data.instructor_content)?.blocks?.length > 1
|
||||||
|
)
|
||||||
instructorEditor.value = renderEditor(
|
instructorEditor.value = renderEditor(
|
||||||
'instructor-content',
|
'instructor-content',
|
||||||
data.instructor_content
|
data.instructor_content
|
||||||
@@ -256,8 +278,10 @@ const renderEditor = (holder, content) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const markProgress = (data) => {
|
const markProgress = () => {
|
||||||
if (user.data && !data.progress) progress.submit()
|
if (user.data && lesson.data && !lesson.data.progress) {
|
||||||
|
progress.submit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = createResource({
|
const progress = createResource({
|
||||||
@@ -268,6 +292,9 @@ const progress = createResource({
|
|||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
lessonProgress.value = data
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
@@ -304,10 +331,27 @@ watch(
|
|||||||
chapter: newChapterNumber,
|
chapter: newChapterNumber,
|
||||||
lesson: newLessonNumber,
|
lesson: newLessonNumber,
|
||||||
})
|
})
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
timer.value = 0
|
||||||
|
startTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const startTimer = () => {
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
timer.value++
|
||||||
|
if (timer.value == 30) {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
markProgress()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const checkIfDiscussionsAllowed = () => {
|
const checkIfDiscussionsAllowed = () => {
|
||||||
let quizPresent = false
|
let quizPresent = false
|
||||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||||
@@ -408,6 +452,10 @@ updateDocumentTitle(pageMeta)
|
|||||||
max-width: unset;
|
max-width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codex-editor__redactor {
|
||||||
|
padding-bottom: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.codeBoxHolder {
|
.codeBoxHolder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -497,4 +545,13 @@ updateDocumentTitle(pageMeta)
|
|||||||
color: #383a42;
|
color: #383a42;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codeBoxTextArea {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border-top: 3px solid theme('colors.gray.700');
|
||||||
|
border-bottom: 3px solid theme('colors.gray.700');
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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 mt-6 py-3"
|
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>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
id="content"
|
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 mt-6 py-3"
|
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>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="sticky top-0 p-5">
|
<div class="sticky top-0 p-5">
|
||||||
<LessonPlugins :editor="editor" :notesEditor="instructorEditor" />
|
<LessonHelp />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,16 +70,25 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
||||||
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
reactive,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
ref,
|
||||||
|
onBeforeUnmount,
|
||||||
|
} from 'vue'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonPlugins from '@/components/LessonPlugins.vue'
|
import LessonHelp from '@/components/LessonHelp.vue'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
|
||||||
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)
|
||||||
|
let autoSaveInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -100,6 +109,7 @@ 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'
|
||||||
}
|
}
|
||||||
|
capture('lesson_form_opened')
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
instructorEditor.value = renderEditor('instructor-notes')
|
instructorEditor.value = renderEditor('instructor-notes')
|
||||||
})
|
})
|
||||||
@@ -107,7 +117,7 @@ onMounted(() => {
|
|||||||
const renderEditor = (holder) => {
|
const renderEditor = (holder) => {
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(),
|
tools: getEditorTools(true),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -133,33 +143,52 @@ const lessonDetails = createResource({
|
|||||||
Object.keys(data.lesson).forEach((key) => {
|
Object.keys(data.lesson).forEach((key) => {
|
||||||
lesson[key] = data.lesson[key]
|
lesson[key] = data.lesson[key]
|
||||||
})
|
})
|
||||||
lesson.include_in_preview = data.include_in_preview ? true : false
|
lesson.include_in_preview = data?.lesson?.include_in_preview
|
||||||
editor.value.isReady.then(() => {
|
? true
|
||||||
if (data.lesson.content) {
|
: false
|
||||||
editor.value.render(JSON.parse(data.lesson.content))
|
addLessonContent(data)
|
||||||
} else if (data.lesson.body) {
|
addInstructorNotes(data)
|
||||||
let blocks = convertToJSON(data.lesson)
|
enableAutoSave()
|
||||||
editor.value.render({
|
|
||||||
blocks: blocks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
instructorEditor.value.isReady.then(() => {
|
|
||||||
if (data.lesson.instructor_content) {
|
|
||||||
instructorEditor.value.render(
|
|
||||||
JSON.parse(data.lesson.instructor_content)
|
|
||||||
)
|
|
||||||
} else if (data.lesson.instructor_notes) {
|
|
||||||
let blocks = convertToJSON(data.lesson)
|
|
||||||
instructorEditor.value.render({
|
|
||||||
blocks: blocks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const addLessonContent = (data) => {
|
||||||
|
editor.value.isReady.then(() => {
|
||||||
|
if (data.lesson.content) {
|
||||||
|
editor.value.render(JSON.parse(data.lesson.content))
|
||||||
|
} else if (data.lesson.body) {
|
||||||
|
let blocks = convertToJSON(data.lesson)
|
||||||
|
editor.value.render({
|
||||||
|
blocks: blocks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addInstructorNotes = (data) => {
|
||||||
|
instructorEditor.value.isReady.then(() => {
|
||||||
|
if (data.lesson.instructor_content) {
|
||||||
|
instructorEditor.value.render(JSON.parse(data.lesson.instructor_content))
|
||||||
|
} else if (data.lesson.instructor_notes) {
|
||||||
|
let blocks = convertToJSON(data.lesson)
|
||||||
|
instructorEditor.value.render({
|
||||||
|
blocks: blocks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableAutoSave = () => {
|
||||||
|
autoSaveInterval = setInterval(() => {
|
||||||
|
saveLesson()
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(autoSaveInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -335,6 +364,7 @@ const createNewLesson = () => {
|
|||||||
{ lesson: data.name },
|
{ lesson: data.name },
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
capture('lesson_created')
|
||||||
showToast('Success', 'Lesson created successfully', 'check')
|
showToast('Success', 'Lesson created successfully', 'check')
|
||||||
lessonDetails.reload()
|
lessonDetails.reload()
|
||||||
},
|
},
|
||||||
@@ -357,9 +387,6 @@ const editCurrentLesson = () => {
|
|||||||
validate() {
|
validate() {
|
||||||
return validateLesson()
|
return validateLesson()
|
||||||
},
|
},
|
||||||
onSuccess() {
|
|
||||||
showToast('Success', 'Lesson updated successfully', 'check')
|
|
||||||
},
|
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message, 'x')
|
showToast('Error', err.message, 'x')
|
||||||
},
|
},
|
||||||
@@ -398,7 +425,7 @@ const breadcrumbs = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: lessonDetails.data?.course_title,
|
label: lessonDetails.data?.course_title,
|
||||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -418,7 +445,7 @@ const breadcrumbs = computed(() => {
|
|||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
|
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
|
||||||
route: {
|
route: {
|
||||||
name: 'CreateLesson',
|
name: 'LessonForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: props.courseName,
|
courseName: props.courseName,
|
||||||
chapterNumber: props.chapterNumber,
|
chapterNumber: props.chapterNumber,
|
||||||
@@ -439,7 +466,8 @@ const pageMeta = computed(() => {
|
|||||||
updateDocumentTitle(pageMeta)
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.embed-tool__caption {
|
.embed-tool__caption,
|
||||||
|
.cdx-simple-image__caption {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +475,10 @@ updateDocumentTitle(pageMeta)
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codex-editor--narrow .ce-toolbar__actions {
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.ce-toolbar__content {
|
.ce-toolbar__content {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
@@ -519,10 +551,6 @@ updateDocumentTitle(pageMeta)
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeBoxSelectItem:hover {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeBoxSelectedItem {
|
.codeBoxSelectedItem {
|
||||||
background-color: lightblue !important;
|
background-color: lightblue !important;
|
||||||
}
|
}
|
||||||
@@ -540,4 +568,17 @@ updateDocumentTitle(pageMeta)
|
|||||||
color: #383a42;
|
color: #383a42;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codeBoxTextArea {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||||
|
overflow-x: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border-top: 3px solid theme('colors.gray.700');
|
||||||
|
border-bottom: 3px solid theme('colors.gray.700');
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -146,7 +146,7 @@ const coverImage = createResource({
|
|||||||
|
|
||||||
const setActiveTab = () => {
|
const setActiveTab = () => {
|
||||||
let fragments = route.path.split('/')
|
let fragments = route.path.split('/')
|
||||||
let sections = ['certificates', 'roles', 'evaluations']
|
let sections = ['certificates', 'roles', 'slots', 'schedule']
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
if (fragments.includes(section)) {
|
if (fragments.includes(section)) {
|
||||||
activeTab.value = convertToTitleCase(section)
|
activeTab.value = convertToTitleCase(section)
|
||||||
@@ -161,7 +161,8 @@ watchEffect(() => {
|
|||||||
About: { name: 'ProfileAbout' },
|
About: { name: 'ProfileAbout' },
|
||||||
Certificates: { name: 'ProfileCertificates' },
|
Certificates: { name: 'ProfileCertificates' },
|
||||||
Roles: { name: 'ProfileRoles' },
|
Roles: { name: 'ProfileRoles' },
|
||||||
Evaluations: { name: 'ProfileEvaluator' },
|
Slots: { name: 'ProfileEvaluator' },
|
||||||
|
Schedule: { name: 'ProfileEvaluationSchedule' },
|
||||||
}[activeTab.value]
|
}[activeTab.value]
|
||||||
router.push(route)
|
router.push(route)
|
||||||
}
|
}
|
||||||
@@ -185,8 +186,13 @@ const isSessionUser = () => {
|
|||||||
const getTabButtons = () => {
|
const getTabButtons = () => {
|
||||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||||
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||||
if (isSessionUser() && $user.data?.is_evaluator)
|
if (
|
||||||
buttons.push({ label: 'Evaluations' })
|
isSessionUser() &&
|
||||||
|
($user.data?.is_evaluator || $user.data?.is_moderator)
|
||||||
|
) {
|
||||||
|
buttons.push({ label: 'Slots' })
|
||||||
|
buttons.push({ label: 'Schedule' })
|
||||||
|
}
|
||||||
|
|
||||||
return buttons
|
return buttons
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
{{ __('Achievements') }}
|
{{ __('Achievements') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-5 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
<div v-for="badge in badges.data">
|
<div v-for="badge in badges.data">
|
||||||
<Popover trigger="hover" :leaveDelay="Number(0.01)">
|
<Popover trigger="hover" :leaveDelay="Number(0.01)">
|
||||||
<template #target>
|
<template #target>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<img
|
<img
|
||||||
:src="badge.badge_image"
|
:src="badge.badge_image"
|
||||||
:alt="badge.badge"
|
:alt="badge.badge"
|
||||||
class="bg-gray-100 rounded-t-md"
|
class="bg-gray-100 rounded-t-md h-[200px] mx-auto"
|
||||||
/>
|
/>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-2xl font-semibold mb-2">
|
||||||
@@ -142,7 +142,7 @@ const shareOnSocial = (badge, medium) => {
|
|||||||
const summary = `I am happy to announce that I earned the ${
|
const summary = `I am happy to announce that I earned the ${
|
||||||
badge.badge
|
badge.badge
|
||||||
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
||||||
branding.data?.brand_name
|
branding.data?.app_name
|
||||||
}.`
|
}.`
|
||||||
|
|
||||||
if (medium == 'LinkedIn')
|
if (medium == 'LinkedIn')
|
||||||
|
|||||||
102
frontend/src/pages/ProfileEvaluationSchedule.vue
Normal file
102
frontend/src/pages/ProfileEvaluationSchedule.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-7 mb-20">
|
||||||
|
<div class="flex h-screen flex-col overflow-hidden">
|
||||||
|
<Calendar
|
||||||
|
v-if="evaluations.data?.length"
|
||||||
|
:config="{
|
||||||
|
defaultMode: 'Month',
|
||||||
|
disableModes: ['Day', 'Week'],
|
||||||
|
redundantCellHeight: 100,
|
||||||
|
enableShortcuts: false,
|
||||||
|
}"
|
||||||
|
:events="evaluations.data"
|
||||||
|
@click="(event) => openEvent(event)"
|
||||||
|
>
|
||||||
|
<template #header="{ currentMonthYear, decrement, increment }">
|
||||||
|
<div class="mb-2 flex justify-between">
|
||||||
|
<span class="text-lg font-semibold">
|
||||||
|
{{ currentMonthYear }}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-x-1">
|
||||||
|
<Button
|
||||||
|
@click="decrement()"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-4 w-4"
|
||||||
|
icon="chevron-left"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="increment()"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-4 w-4"
|
||||||
|
icon="chevron-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Calendar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Event v-model="showEvent" :event="currentEvent" />
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Calendar, createListResource, Button } from 'frappe-ui'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
|
import Event from '@/components/Modals/Event.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const currentEvent = ref(null)
|
||||||
|
const showEvent = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluations = createListResource({
|
||||||
|
doctype: 'LMS Certificate Request',
|
||||||
|
filters: {
|
||||||
|
evaluator: user.data?.name,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member_name',
|
||||||
|
'member',
|
||||||
|
'course',
|
||||||
|
'course_title',
|
||||||
|
'batch_name',
|
||||||
|
'batch_title',
|
||||||
|
'evaluator',
|
||||||
|
'evaluator_name',
|
||||||
|
'date',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'google_meet_link',
|
||||||
|
],
|
||||||
|
auto: true,
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
limit: 100,
|
||||||
|
cache: ['schedule', user.data?.name],
|
||||||
|
transform(data) {
|
||||||
|
return data.map((d) => {
|
||||||
|
let mappedData = Object.assign({}, d)
|
||||||
|
|
||||||
|
mappedData.title = `${d.member_name}'s Evaluation`
|
||||||
|
mappedData.participant = d.member_name
|
||||||
|
mappedData.id = d.name
|
||||||
|
mappedData.venue = d.google_meet_link
|
||||||
|
mappedData.fromDate = `${d.date} ${d.start_time}`
|
||||||
|
mappedData.toDate = `${d.date} ${d.end_time}`
|
||||||
|
mappedData.color = 'green'
|
||||||
|
|
||||||
|
return mappedData
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const openEvent = (event) => {
|
||||||
|
currentEvent.value = event.calendarEvent
|
||||||
|
showEvent.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
467
frontend/src/pages/QuizForm.vue
Normal file
467
frontend/src/pages/QuizForm.vue
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<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 class="space-x-2">
|
||||||
|
<router-link
|
||||||
|
v-if="quizDetails.data?.name"
|
||||||
|
:to="{
|
||||||
|
name: 'QuizPage',
|
||||||
|
params: {
|
||||||
|
quizID: quizDetails.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Open') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-if="quizDetails.data?.name"
|
||||||
|
:to="{
|
||||||
|
name: 'QuizSubmissionList',
|
||||||
|
params: {
|
||||||
|
quizID: quizDetails.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Submission List') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="submitQuiz()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="w-3/4 mx-auto py-5">
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="font-semibold mb-4">
|
||||||
|
{{ __('Details') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.title"
|
||||||
|
:label="
|
||||||
|
quizDetails.data?.name
|
||||||
|
? __('Title')
|
||||||
|
: __('Enter a title and save the quiz to proceed')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-if="quizDetails.data?.name">
|
||||||
|
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||||
|
<FormControl
|
||||||
|
type="number"
|
||||||
|
v-model="quiz.max_attempts"
|
||||||
|
:label="__('Maximun Attempts')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="number"
|
||||||
|
v-model="quiz.duration"
|
||||||
|
:label="__('Duration (in minutes)')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.total_marks"
|
||||||
|
:label="__('Total Marks')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.passing_percentage"
|
||||||
|
:label="__('Passing Percentage')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="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')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="font-semibold mb-4">
|
||||||
|
{{ __('Shuffle Settings') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3">
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.shuffle_questions"
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Shuffle Questions')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="quiz.shuffle_questions"
|
||||||
|
v-model="quiz.limit_questions_to"
|
||||||
|
:label="__('Limit Questions To')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Questions -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="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)"
|
||||||
|
>
|
||||||
|
<ListRowItem :item="item">
|
||||||
|
<div
|
||||||
|
v-if="column.key == 'question_detail'"
|
||||||
|
class="text-xs truncate h-4"
|
||||||
|
v-html="item"
|
||||||
|
></div>
|
||||||
|
<div v-else class="text-xs">
|
||||||
|
{{ item }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="deleteQuestions(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Question
|
||||||
|
v-model="showQuestionModal"
|
||||||
|
:questionDetail="currentQuestion"
|
||||||
|
v-model:quiz="quizDetails"
|
||||||
|
:title="
|
||||||
|
currentQuestion.question
|
||||||
|
? __('Edit the question')
|
||||||
|
: __('Add a new question')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
createResource,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
Button,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
onBeforeUnmount,
|
||||||
|
watch,
|
||||||
|
isReactive,
|
||||||
|
} from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import Question from '@/components/Modals/Question.vue'
|
||||||
|
import { showToast, updateDocumentTitle } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const showQuestionModal = ref(false)
|
||||||
|
const currentQuestion = reactive({
|
||||||
|
question: '',
|
||||||
|
marks: 0,
|
||||||
|
name: '',
|
||||||
|
})
|
||||||
|
const user = inject('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const quiz = reactive({
|
||||||
|
title: '',
|
||||||
|
total_marks: 0,
|
||||||
|
passing_percentage: 0,
|
||||||
|
max_attempts: 0,
|
||||||
|
duration: 0,
|
||||||
|
limit_questions_to: 0,
|
||||||
|
show_answers: true,
|
||||||
|
show_submission_history: false,
|
||||||
|
shuffle_questions: false,
|
||||||
|
questions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (
|
||||||
|
props.quizID == 'new' &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data?.is_instructor
|
||||||
|
) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
if (props.quizID !== 'new') {
|
||||||
|
quizDetails.reload()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (
|
||||||
|
e.key === 's' &&
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
!e.target.classList.contains('ProseMirror')
|
||||||
|
) {
|
||||||
|
submitQuiz()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.quizID !== 'new',
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
quizDetails.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const quizDetails = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
makeParams(values) {
|
||||||
|
return { doctype: 'LMS Quiz', name: props.quizID }
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
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 quizCreate = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
auto: false,
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Quiz',
|
||||||
|
...quiz,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const quizUpdate = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
auto: false,
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Quiz',
|
||||||
|
name: values.quizID,
|
||||||
|
fieldname: {
|
||||||
|
total_marks: calculateTotalMarks(),
|
||||||
|
...quiz,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitQuiz = () => {
|
||||||
|
if (quizDetails.data?.name) updateQuiz()
|
||||||
|
else createQuiz()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createQuiz = () => {
|
||||||
|
quizCreate.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(__('Success'), __('Quiz created successfully'), 'check')
|
||||||
|
router.push({
|
||||||
|
name: 'QuizForm',
|
||||||
|
params: { quizID: data.name },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuiz = () => {
|
||||||
|
quizUpdate.submit(
|
||||||
|
{ quizID: quizDetails.data?.name },
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
quiz.total_marks = data.total_marks
|
||||||
|
showToast(__('Success'), __('Quiz updated successfully'), 'check')
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalMarks = () => {
|
||||||
|
let totalMarks = 0
|
||||||
|
if (quiz.limit_questions_to && quiz.questions.length > 0)
|
||||||
|
return quiz.questions[0].marks * quiz.limit_questions_to
|
||||||
|
quiz.questions.forEach((question) => {
|
||||||
|
totalMarks += question.marks
|
||||||
|
})
|
||||||
|
return totalMarks
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('ID'),
|
||||||
|
key: 'question',
|
||||||
|
width: '10rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Question'),
|
||||||
|
key: __('question_detail'),
|
||||||
|
width: '40rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Marks'),
|
||||||
|
key: 'marks',
|
||||||
|
width: '5rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const openQuestionModal = (question = null) => {
|
||||||
|
if (question) {
|
||||||
|
currentQuestion.question = question.question
|
||||||
|
currentQuestion.marks = question.marks
|
||||||
|
currentQuestion.name = question.name
|
||||||
|
} else {
|
||||||
|
currentQuestion.question = ''
|
||||||
|
currentQuestion.marks = 0
|
||||||
|
currentQuestion.name = ''
|
||||||
|
}
|
||||||
|
showQuestionModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteQuestionResource = createResource({
|
||||||
|
url: 'lms.lms.api.delete_documents',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Quiz Question',
|
||||||
|
documents: values.questions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteQuestions = (selections, unselectAll) => {
|
||||||
|
deleteQuestionResource.submit(
|
||||||
|
{
|
||||||
|
questions: Array.from(selections),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
showToast(__('Success'), __('Questions deleted successfully'), 'check')
|
||||||
|
quizDetails.reload()
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [
|
||||||
|
{
|
||||||
|
label: __('Quizzes'),
|
||||||
|
route: {
|
||||||
|
name: 'Quizzes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
/* if (quizDetails.data) {
|
||||||
|
crumbs.push({
|
||||||
|
label: quiz.title,
|
||||||
|
})
|
||||||
|
} */
|
||||||
|
crumbs.push({
|
||||||
|
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||||
|
route: { name: 'QuizForm', params: { quizID: props.quizID } },
|
||||||
|
})
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||||
|
description: __('Form to create and edit quizzes'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
|
</script>
|
||||||
58
frontend/src/pages/QuizPage.vue
Normal file
58
frontend/src/pages/QuizPage.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<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="md:w-7/12 md:mx-auto mx-4 py-10">
|
||||||
|
<Quiz :quizName="quizID" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import Quiz from '@/components/Quiz.vue'
|
||||||
|
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = createResource({
|
||||||
|
url: 'frappe.client.get_value',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Quiz',
|
||||||
|
fieldname: 'title',
|
||||||
|
filters: {
|
||||||
|
name: props.quizID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: title.data?.title,
|
||||||
|
description: __('Quiz Submission'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
|
</script>
|
||||||
122
frontend/src/pages/QuizSubmission.vue
Normal file
122
frontend/src/pages/QuizSubmission.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<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 v-if="submisisonDetails.doc" :items="breadcrumbs" />
|
||||||
|
<div class="space-x-2">
|
||||||
|
<Badge
|
||||||
|
v-if="submisisonDetails.isDirty"
|
||||||
|
:label="__('Not Saved')"
|
||||||
|
variant="subtle"
|
||||||
|
theme="orange"
|
||||||
|
/>
|
||||||
|
<Button variant="solid" @click="saveSubmission()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="submisisonDetails.doc.quiz_title"
|
||||||
|
:label="__('Quiz')"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="submisisonDetails.doc.member_name"
|
||||||
|
:label="__('Member')"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="submisisonDetails.doc.score"
|
||||||
|
:label="__('Score')"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="submisisonDetails.doc.percentage"
|
||||||
|
:label="__('Percentage')"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="row in submisisonDetails.doc.result"
|
||||||
|
class="border p-5 rounded-md space-y-4"
|
||||||
|
>
|
||||||
|
<div class="font-semibold">{{ row.idx }}. {{ row.question }}</div>
|
||||||
|
<div v-html="row.answer" class="leading-5"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl v-model="row.marks" :label="__('Marks')" />
|
||||||
|
<FormControl
|
||||||
|
v-model="row.marks_out_of"
|
||||||
|
:label="__('Marks out of')"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createDocumentResource,
|
||||||
|
Breadcrumbs,
|
||||||
|
FormControl,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, onMounted, inject } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
submission: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submisisonDetails = createDocumentResource({
|
||||||
|
doctype: 'LMS Quiz Submission',
|
||||||
|
name: props.submission,
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Quiz Submissions'),
|
||||||
|
route: {
|
||||||
|
name: 'QuizSubmissionList',
|
||||||
|
params: {
|
||||||
|
quizID: submisisonDetails.doc.quiz,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: submisisonDetails.doc.quiz_title,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveSubmission = () => {
|
||||||
|
submisisonDetails.save.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<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 v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
|
<ListView
|
||||||
|
:columns="quizColumns"
|
||||||
|
:rows="submissions.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 submissions.data"
|
||||||
|
:to="{
|
||||||
|
name: 'QuizSubmission',
|
||||||
|
params: {
|
||||||
|
submission: row.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListRow :row="row" />
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createListResource,
|
||||||
|
Breadcrumbs,
|
||||||
|
ListView,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, onMounted, inject } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissions = createListResource({
|
||||||
|
doctype: 'LMS Quiz Submission',
|
||||||
|
filters: {
|
||||||
|
quiz: props.quizID,
|
||||||
|
},
|
||||||
|
fields: ['name', 'member_name', 'score', 'percentage', 'quiz_title'],
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const quizColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Quiz'),
|
||||||
|
key: 'quiz_title',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Score'),
|
||||||
|
key: 'score',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Percentage'),
|
||||||
|
key: 'percentage',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [{ label: __('Quiz Submissions') }]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
136
frontend/src/pages/Quizzes.vue
Normal file
136
frontend/src/pages/Quizzes.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<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" />
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'QuizForm',
|
||||||
|
params: {
|
||||||
|
quizID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('New Quiz') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</header>
|
||||||
|
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-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: 'QuizForm',
|
||||||
|
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 { useRouter } from 'vue-router'
|
||||||
|
import { computed, inject, onMounted } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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],
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: __('Quizzes'),
|
||||||
|
description: __('List of quizzes'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
|
</script>
|
||||||
@@ -79,9 +79,15 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ProfileEvaluator',
|
name: 'ProfileEvaluator',
|
||||||
path: 'evaluations',
|
path: 'slots',
|
||||||
component: () => import('@/pages/ProfileEvaluator.vue'),
|
component: () => import('@/pages/ProfileEvaluator.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'ProfileEvaluationSchedule',
|
||||||
|
path: 'schedule',
|
||||||
|
component: () =>
|
||||||
|
import('@/pages/ProfileEvaluationSchedule.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,20 +103,20 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/courses/:courseName/edit',
|
path: '/courses/:courseName/edit',
|
||||||
name: 'CreateCourse',
|
name: 'CourseForm',
|
||||||
component: () => import('@/pages/CreateCourse.vue'),
|
component: () => import('@/pages/CourseForm.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
|
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
|
||||||
name: 'CreateLesson',
|
name: 'LessonForm',
|
||||||
component: () => import('@/pages/CreateLesson.vue'),
|
component: () => import('@/pages/LessonForm.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/batches/:batchName/edit',
|
path: '/batches/:batchName/edit',
|
||||||
name: 'BatchCreation',
|
name: 'BatchForm',
|
||||||
component: () => import('@/pages/BatchCreation.vue'),
|
component: () => import('@/pages/BatchForm.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,6 +147,35 @@ const routes = [
|
|||||||
component: () => import('@/pages/Badge.vue'),
|
component: () => import('@/pages/Badge.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/quizzes',
|
||||||
|
name: 'Quizzes',
|
||||||
|
component: () => import('@/pages/Quizzes.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/quizzes/:quizID',
|
||||||
|
name: 'QuizForm',
|
||||||
|
component: () => import('@/pages/QuizForm.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/quiz/:quizID',
|
||||||
|
name: 'QuizPage',
|
||||||
|
component: () => import('@/pages/QuizPage.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/quiz-submissions/:quizID',
|
||||||
|
name: 'QuizSubmissionList',
|
||||||
|
component: () => import('@/pages/QuizSubmissionList.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/quiz-submission/:submission',
|
||||||
|
name: 'QuizSubmission',
|
||||||
|
component: () => import('@/pages/QuizSubmission.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
|
|||||||
@@ -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 } = usersStore()
|
let { userResource, allUsers } = usersStore()
|
||||||
|
|
||||||
function sessionUser() {
|
function sessionUser() {
|
||||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||||
@@ -17,6 +17,9 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user = ref(sessionUser())
|
let user = ref(sessionUser())
|
||||||
|
if (user.value) {
|
||||||
|
allUsers.reload()
|
||||||
|
}
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|
||||||
const login = createResource({
|
const login = createResource({
|
||||||
@@ -50,11 +53,18 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sidebarSettings = createResource({
|
||||||
|
url: 'lms.lms.api.get_sidebar_settings',
|
||||||
|
cache: 'Sidebar Settings',
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
branding,
|
branding,
|
||||||
|
sidebarSettings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
12
frontend/src/stores/settings.js
Normal file
12
frontend/src/stores/settings.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useSettings = defineStore('settings', () => {
|
||||||
|
const isSettingsOpen = ref(false)
|
||||||
|
const activeTab = ref(null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSettingsOpen,
|
||||||
|
activeTab,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -15,7 +15,6 @@ export const usersStore = defineStore('lms-users', () => {
|
|||||||
const allUsers = createResource({
|
const allUsers = createResource({
|
||||||
url: 'lms.lms.api.get_all_users',
|
url: 'lms.lms.api.get_all_users',
|
||||||
cache: ['allUsers'],
|
cache: ['allUsers'],
|
||||||
auto: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
98
frontend/src/telemetry.ts
Normal file
98
frontend/src/telemetry.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useStorage } from "@vueuse/core";
|
||||||
|
import { call } from "frappe-ui";
|
||||||
|
import "../../../frappe/frappe/public/js/lib/posthog.js";
|
||||||
|
|
||||||
|
const APP = "lms";
|
||||||
|
const SITENAME = window.location.hostname;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
posthog: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const telemetry = useStorage("telemetry", {
|
||||||
|
enabled: false,
|
||||||
|
project_id: "",
|
||||||
|
host: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
await set_enabled();
|
||||||
|
if (!telemetry.value.enabled) return;
|
||||||
|
try {
|
||||||
|
await set_credentials();
|
||||||
|
window.posthog.init(telemetry.value.project_id, {
|
||||||
|
api_host: telemetry.value.host,
|
||||||
|
autocapture: false,
|
||||||
|
person_profiles: "always",
|
||||||
|
capture_pageview: true,
|
||||||
|
capture_pageleave: true,
|
||||||
|
disable_session_recording: false,
|
||||||
|
session_recording: {
|
||||||
|
maskAllInputs: false,
|
||||||
|
maskInputOptions: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loaded: (posthog) => {
|
||||||
|
window.posthog = posthog;
|
||||||
|
window.posthog.identify(SITENAME);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.trace("Failed to initialize telemetry", e);
|
||||||
|
telemetry.value.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function set_enabled() {
|
||||||
|
if (telemetry.value.enabled) return;
|
||||||
|
|
||||||
|
await call("lms.lms.telemetry.is_enabled").then((res) => {
|
||||||
|
telemetry.value.enabled = res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function set_credentials() {
|
||||||
|
if (!telemetry.value.enabled) return;
|
||||||
|
if (telemetry.value.project_id && telemetry.value.host) return;
|
||||||
|
|
||||||
|
await call("lms.lms.telemetry.get_credentials").then((res) => {
|
||||||
|
telemetry.value.project_id = res.project_id;
|
||||||
|
telemetry.value.host = res.telemetry_host;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaptureOptions {
|
||||||
|
data: {
|
||||||
|
user: string;
|
||||||
|
[key: string]: string | number | boolean | object;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capture(
|
||||||
|
event: string,
|
||||||
|
options: CaptureOptions = { data: { user: "" } }
|
||||||
|
) {
|
||||||
|
if (!telemetry.value.enabled) return;
|
||||||
|
window.posthog.capture(`${APP}_${event}`, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSession() {
|
||||||
|
if (!telemetry.value.enabled) return;
|
||||||
|
if (window.posthog && window.posthog.__loaded) {
|
||||||
|
window.posthog.startSessionRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSession() {
|
||||||
|
if (!telemetry.value.enabled) return;
|
||||||
|
if (
|
||||||
|
window.posthog &&
|
||||||
|
window.posthog.__loaded &&
|
||||||
|
window.posthog.sessionRecordingStarted()
|
||||||
|
) {
|
||||||
|
window.posthog.stopSessionRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export class CodeBox {
|
|||||||
|
|
||||||
static get toolbox() {
|
static get toolbox() {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }),
|
render: () => h(Code, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import InlineCode from '@editorjs/inline-code'
|
|||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import dayjs from '@/utils/dayjs'
|
import dayjs from '@/utils/dayjs'
|
||||||
import Embed from '@editorjs/embed'
|
import Embed from '@editorjs/embed'
|
||||||
|
import SimpleImage from '@editorjs/simple-image'
|
||||||
|
|
||||||
export function createToast(options) {
|
export function createToast(options) {
|
||||||
toast({
|
toast({
|
||||||
@@ -79,15 +80,21 @@ export function getFileSize(file_size) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(title, text, icon) {
|
export function showToast(title, text, icon, iconClasses = null) {
|
||||||
|
if (!iconClasses) {
|
||||||
|
if (icon == 'check') {
|
||||||
|
iconClasses = 'bg-green-600 text-white rounded-md p-px'
|
||||||
|
} else if (icon == 'circle-warn') {
|
||||||
|
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
|
||||||
|
} else {
|
||||||
|
iconClasses = '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: 5,
|
||||||
})
|
})
|
||||||
@@ -133,6 +140,7 @@ 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,
|
||||||
@@ -144,9 +152,9 @@ export function getEditorTools() {
|
|||||||
class: CodeBox,
|
class: CodeBox,
|
||||||
config: {
|
config: {
|
||||||
themeURL:
|
themeURL:
|
||||||
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
|
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-dark.min.css',
|
||||||
themeName: 'atom-one-dark', // Optional
|
themeName: 'atom-one-dark',
|
||||||
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
|
useDefaultTheme: 'dark',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
@@ -164,16 +172,74 @@ export function getEditorTools() {
|
|||||||
inlineToolbar: false,
|
inlineToolbar: false,
|
||||||
config: {
|
config: {
|
||||||
services: {
|
services: {
|
||||||
youtube: true,
|
youtube: {
|
||||||
|
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
|
||||||
|
embedUrl:
|
||||||
|
'https://www.youtube.com/embed/<%= remote_id %>',
|
||||||
|
html: '<iframe style="width:100%; height: 30rem;" frameborder="0" allowfullscreen></iframe>',
|
||||||
|
height: 320,
|
||||||
|
width: 580,
|
||||||
|
id: ([id, params]) => {
|
||||||
|
if (!params && id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsMap = {
|
||||||
|
start: 'start',
|
||||||
|
end: 'end',
|
||||||
|
t: 'start',
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
time_continue: 'start',
|
||||||
|
list: 'list',
|
||||||
|
}
|
||||||
|
|
||||||
|
let newParams = params
|
||||||
|
.slice(1)
|
||||||
|
.split('&')
|
||||||
|
.map((param) => {
|
||||||
|
const [name, value] = param.split('=')
|
||||||
|
|
||||||
|
if (!id && name === 'v') {
|
||||||
|
id = value
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paramsMap[name]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value === 'LL' ||
|
||||||
|
value.startsWith('RDMM') ||
|
||||||
|
value.startsWith('FL')
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${paramsMap[name]}=${value}`
|
||||||
|
})
|
||||||
|
.filter((param) => !!param)
|
||||||
|
|
||||||
|
return id + '?' + newParams.join('&')
|
||||||
|
},
|
||||||
|
},
|
||||||
vimeo: true,
|
vimeo: true,
|
||||||
codepen: true,
|
codepen: true,
|
||||||
aparat: 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,
|
github: true,
|
||||||
slides: {
|
slides: {
|
||||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
|
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/,
|
||||||
embedUrl:
|
embedUrl:
|
||||||
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
|
'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>",
|
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
},
|
},
|
||||||
drive: {
|
drive: {
|
||||||
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
|
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
|
||||||
@@ -197,7 +263,7 @@ export function getEditorTools() {
|
|||||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||||
embedUrl:
|
embedUrl:
|
||||||
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
|
'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>",
|
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
},
|
},
|
||||||
codesandbox: {
|
codesandbox: {
|
||||||
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
|
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
|
||||||
@@ -361,15 +427,15 @@ export function getSidebarLinks() {
|
|||||||
'Courses',
|
'Courses',
|
||||||
'CourseDetail',
|
'CourseDetail',
|
||||||
'Lesson',
|
'Lesson',
|
||||||
'CreateCourse',
|
'CourseForm',
|
||||||
'CreateLesson',
|
'LessonForm',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Batches',
|
label: 'Batches',
|
||||||
icon: 'Users',
|
icon: 'Users',
|
||||||
to: 'Batches',
|
to: 'Batches',
|
||||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchCreation'],
|
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Participants',
|
label: 'Certified Participants',
|
||||||
@@ -420,3 +486,26 @@ export function getLineStartPosition(string, position) {
|
|||||||
|
|
||||||
return position
|
return position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function singularize(word) {
|
||||||
|
const endings = {
|
||||||
|
ves: 'fe',
|
||||||
|
ies: 'y',
|
||||||
|
i: 'us',
|
||||||
|
zes: 'ze',
|
||||||
|
ses: 's',
|
||||||
|
es: 'e',
|
||||||
|
s: '',
|
||||||
|
}
|
||||||
|
return word.replace(
|
||||||
|
new RegExp(`(${Object.keys(endings).join('|')})$`),
|
||||||
|
(r) => endings[r]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||||
|
return __('Only image file is allowed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import QuizBlock from '@/components/QuizBlock.vue'
|
import QuizBlock from '@/components/QuizBlock.vue'
|
||||||
import { createApp } from 'vue'
|
import QuizPlugin from '@/components/QuizPlugin.vue'
|
||||||
|
import { createApp, h } from 'vue'
|
||||||
import { usersStore } from '../stores/user'
|
import { usersStore } from '../stores/user'
|
||||||
import translationPlugin from '../translation'
|
import translationPlugin from '../translation'
|
||||||
|
import { CircleHelp } from 'lucide-vue-next'
|
||||||
|
|
||||||
export class Quiz {
|
export class Quiz {
|
||||||
constructor({ data, api, readOnly }) {
|
constructor({ data, api, readOnly }) {
|
||||||
@@ -9,17 +11,31 @@ export class Quiz {
|
|||||||
this.readOnly = readOnly
|
this.readOnly = readOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get toolbox() {
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const div = document.createElement('div')
|
||||||
|
app.mount(div)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: __('Quiz'),
|
||||||
|
icon: div.innerHTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static get isReadOnlySupported() {
|
static get isReadOnlySupported() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.wrapper = document.createElement('div')
|
this.wrapper = document.createElement('div')
|
||||||
if (this.data) {
|
if (Object.keys(this.data).length) {
|
||||||
let renderedQuiz = this.renderQuiz(this.data.quiz)
|
this.renderQuiz(this.data.quiz)
|
||||||
if (!this.readOnly) {
|
} else {
|
||||||
this.wrapper.innerHTML = renderedQuiz
|
this.renderQuizModal()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return this.wrapper
|
return this.wrapper
|
||||||
}
|
}
|
||||||
@@ -27,7 +43,7 @@ export class Quiz {
|
|||||||
renderQuiz(quiz) {
|
renderQuiz(quiz) {
|
||||||
if (this.readOnly) {
|
if (this.readOnly) {
|
||||||
const app = createApp(QuizBlock, {
|
const app = createApp(QuizBlock, {
|
||||||
quiz: quiz, // Pass quiz content as prop
|
quiz: quiz,
|
||||||
})
|
})
|
||||||
app.use(translationPlugin)
|
app.use(translationPlugin)
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
@@ -35,11 +51,23 @@ export class Quiz {
|
|||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return `<div class='border rounded-md p-10 text-center mb-2'>
|
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
Quiz: ${quiz}
|
Quiz: ${quiz}
|
||||||
</span>
|
</span>
|
||||||
</div>`
|
</div>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderQuizModal() {
|
||||||
|
const app = createApp(QuizPlugin, {
|
||||||
|
onQuizAddition: (quiz) => {
|
||||||
|
this.data.quiz = quiz
|
||||||
|
this.renderQuiz(quiz)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.mount(this.wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import AudioBlock from '@/components/AudioBlock.vue'
|
import AudioBlock from '@/components/AudioBlock.vue'
|
||||||
import VideoBlock from '@/components/VideoBlock.vue'
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
import { createApp } from 'vue'
|
import UploadPlugin from '@/components/UploadPlugin.vue'
|
||||||
|
import { h, createApp } from 'vue'
|
||||||
|
import { Upload as UploadIcon } from 'lucide-vue-next'
|
||||||
|
import translationPlugin from '../translation'
|
||||||
|
|
||||||
export class Upload {
|
export class Upload {
|
||||||
constructor({ data, api, readOnly }) {
|
constructor({ data, api, readOnly }) {
|
||||||
@@ -8,17 +11,38 @@ export class Upload {
|
|||||||
this.readOnly = readOnly
|
this.readOnly = readOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get toolbox() {
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h(UploadIcon, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const div = document.createElement('div')
|
||||||
|
app.mount(div)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'Upload',
|
||||||
|
icon: div.innerHTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static get isReadOnlySupported() {
|
static get isReadOnlySupported() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.wrapper = document.createElement('div')
|
this.wrapper = document.createElement('div')
|
||||||
this.renderUpload(this.data)
|
|
||||||
|
if (this.data && this.data.file_url) {
|
||||||
|
this.renderFile(this.data)
|
||||||
|
} else {
|
||||||
|
this.renderFileUploader()
|
||||||
|
}
|
||||||
|
|
||||||
return this.wrapper
|
return this.wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUpload(file) {
|
renderFile(file) {
|
||||||
if (this.isVideo(file.file_type)) {
|
if (this.isVideo(file.file_type)) {
|
||||||
const app = createApp(VideoBlock, {
|
const app = createApp(VideoBlock, {
|
||||||
file: file.file_url,
|
file: file.file_url,
|
||||||
@@ -32,9 +56,11 @@ export class Upload {
|
|||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
return
|
||||||
} else if (file.file_type == 'PDF') {
|
} else if (file.file_type == 'PDF') {
|
||||||
this.wrapper.innerHTML = `<iframe src="${encodeURI(
|
this.wrapper.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${
|
||||||
|
window.location.origin
|
||||||
|
}${encodeURI(
|
||||||
file.file_url
|
file.file_url
|
||||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
)}&embedded=true" width='100%' height='700px' class="mb-4" type="application/pdf"></iframe>`
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
||||||
@@ -44,6 +70,25 @@ export class Upload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderFileUploader() {
|
||||||
|
const app = createApp(UploadPlugin, {
|
||||||
|
onFileUploaded: (file) => {
|
||||||
|
this.data.file_url = file.file_url
|
||||||
|
this.data.file_type = file.file_type
|
||||||
|
this.renderFile(file)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(savedData) {
|
||||||
|
if (!savedData.file_url || !savedData.file_type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
return {
|
return {
|
||||||
file_url: this.data.file_url,
|
file_url: this.data.file_url,
|
||||||
|
|||||||
1662
frontend/yarn.lock
1662
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
__version__ = "2.0.0"
|
__version__ = "2.9.0"
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
from frappe import _
|
|
||||||
|
|
||||||
|
|
||||||
def get_data():
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"module_name": "Community",
|
|
||||||
"color": "grey",
|
|
||||||
"icon": "octicon octicon-file-directory",
|
|
||||||
"type": "module",
|
|
||||||
"label": _("Community"),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""
|
|
||||||
Configuration for docs
|
|
||||||
"""
|
|
||||||
|
|
||||||
# source_link = "https://github.com/[org_name]/community"
|
|
||||||
# docs_base_url = "https://[org_name].github.io/community"
|
|
||||||
# headline = "App that does everything"
|
|
||||||
# sub_heading = "Yes, you got that right the first time, everything"
|
|
||||||
|
|
||||||
|
|
||||||
def get_context(context):
|
|
||||||
context.brand_html = "Community"
|
|
||||||
File diff suppressed because it is too large
Load Diff
51
lms/fixtures/lms_category.json
Normal file
51
lms/fixtures/lms_category.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "Web Development",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2024-09-20 12:58:16.841571",
|
||||||
|
"name": "Web Development"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Business",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2024-09-20 12:58:32.304850",
|
||||||
|
"name": "Business"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Design",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2024-09-20 12:59:12.621022",
|
||||||
|
"name": "Design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Personal Development",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2024-09-20 12:59:19.287404",
|
||||||
|
"name": "Personal Development"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Finance",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2024-09-20 12:58:28.579714",
|
||||||
|
"name": "Finance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Frontend",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2024-05-08 14:05:16.979275",
|
||||||
|
"name": "Frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Framework",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "LMS Category",
|
||||||
|
"modified": "2023-06-15 18:01:41.598282",
|
||||||
|
"name": "Framework"
|
||||||
|
}
|
||||||
|
]
|
||||||
63
lms/hooks.py
63
lms/hooks.py
@@ -4,9 +4,11 @@ app_name = "frappe_lms"
|
|||||||
app_title = "Frappe LMS"
|
app_title = "Frappe LMS"
|
||||||
app_publisher = "Frappe"
|
app_publisher = "Frappe"
|
||||||
app_description = "Frappe LMS App"
|
app_description = "Frappe LMS App"
|
||||||
app_icon = "octicon octicon-file-directory"
|
app_icon_url = "/assets/lms/images/lms-logo.png"
|
||||||
|
app_icon_title = "Learning"
|
||||||
|
app_icon_route = "/lms"
|
||||||
app_color = "grey"
|
app_color = "grey"
|
||||||
app_email = "school@frappe.io"
|
app_email = "jannat@frappe.io"
|
||||||
app_license = "AGPL"
|
app_license = "AGPL"
|
||||||
|
|
||||||
# Includes in <head>
|
# Includes in <head>
|
||||||
@@ -61,8 +63,6 @@ web_include_js = ["website.bundle.js"]
|
|||||||
after_install = "lms.install.after_install"
|
after_install = "lms.install.after_install"
|
||||||
after_sync = "lms.install.after_sync"
|
after_sync = "lms.install.after_sync"
|
||||||
before_uninstall = "lms.install.before_uninstall"
|
before_uninstall = "lms.install.before_uninstall"
|
||||||
|
|
||||||
|
|
||||||
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
|
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
|
||||||
|
|
||||||
# Desk Notifications
|
# Desk Notifications
|
||||||
@@ -115,7 +115,7 @@ scheduler_events = {
|
|||||||
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||||
}
|
}
|
||||||
|
|
||||||
fixtures = ["Custom Field", "Function", "Industry"]
|
fixtures = ["Custom Field", "Function", "Industry", "LMS Category"]
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
# -------
|
# -------
|
||||||
@@ -177,50 +177,15 @@ update_website_context = [
|
|||||||
|
|
||||||
jinja = {
|
jinja = {
|
||||||
"methods": [
|
"methods": [
|
||||||
"lms.page_renderers.get_profile_url",
|
"lms.lms.utils.get_signup_optin_checks",
|
||||||
"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_tags",
|
||||||
|
"lms.lms.utils.get_lesson_count",
|
||||||
"lms.lms.utils.get_instructors",
|
"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_index",
|
||||||
"lms.lms.utils.get_lesson_url",
|
"lms.lms.utils.get_lesson_url",
|
||||||
"lms.lms.utils.get_chapters",
|
"lms.page_renderers.get_profile_url",
|
||||||
"lms.lms.utils.get_slugified_chapter_title",
|
"lms.overrides.user.get_palette",
|
||||||
"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.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": [],
|
||||||
}
|
}
|
||||||
@@ -267,3 +232,13 @@ profile_url_prefix = "/users/"
|
|||||||
signup_form_template = "lms.plugins.show_custom_signup"
|
signup_form_template = "lms.plugins.show_custom_signup"
|
||||||
|
|
||||||
on_session_creation = "lms.overrides.user.on_session_creation"
|
on_session_creation = "lms.overrides.user.on_session_creation"
|
||||||
|
|
||||||
|
add_to_apps_screen = [
|
||||||
|
{
|
||||||
|
"name": "lms",
|
||||||
|
"logo": "/assets/lms/images/lms-logo.png",
|
||||||
|
"title": "Learning",
|
||||||
|
"route": "/lms",
|
||||||
|
"has_permission": "lms.lms.api.check_app_permission",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user